import SwiftUI import AppKit import CoreImage.CIFilterBuiltins import Charts import UserNotifications // MARK: - Models struct TunnelItem: Identifiable, Codable { var id: String var name: String var target: String var listen_port: Int var whitelist: [String] var enabled: Bool var owner: String? var duration_minutes: Int? var started_at: String? var expires_at: String? var historical_bytes_in: Int64? var historical_bytes_out: Int64? var last_duration_sec: Int64? var max_bandwidth_mb: Double? var auth_code: String? var notes: String? var metrics: TunnelMetrics? var idle_close_minutes: Int? var effective_idle_close_minutes: Int? var close_reason: String? var first_connected_at: String? var connected_sec: Int64? var last_connected_sec: Int64? var activity: TunnelActivityItem? var rate_history: RateSeriesItem? /// 所属通道组 var group_id: String? /// 可延长的设备与延长申请(通道关闭后 2 小时内仍有) var renewals: [RenewalItem]? } /// 近 10 分钟流量曲线:固定 120 个点(5 秒一个),最后一点为当前 struct RateSeriesItem: Codable { var end: Int64 var step: Int var up: [Int64] var down: [Int64] var peak: Int64 { max(up.max() ?? 0, down.max() ?? 0) } } /// 通道当前使用情况:是否在用、闲置起点、预计自动关闭时间、实际连接时长 struct TunnelActivityItem: Codable { var in_use: Bool var idle_since: String? var auto_close_at: String? var connected_sec: Int64 } struct TunnelMetrics: Codable { var tunnel_id: String? var listen_port: Int? var active_conns: Int? var total_conns: Int64? var bytes_in: Int64? var bytes_out: Int64? var rate_in: Int64? var rate_out: Int64? var blocked_attempts: Int64? var status: String? var error: String? var dynamic_whitelist: [String]? var dynamic_leases: [DynamicLeaseItem]? var invites: [InviteItem]? var pending: [AccessRequestItem]? } struct DynamicLeaseItem: Identifiable, Codable { var id: String { ip } var ip: String var expires_at: String var name: String? var invite: String? var duration: String? /// 已授权但尚未首次连接:计时未开始,expires_at 为首次连接截止时间 var awaiting_connect: Bool? var started_at: String? } struct InviteUseItem: Codable, Hashable { var name: String var ip: String var at: String } struct InviteItem: Identifiable, Codable { var id: String { code } var code: String var note: String? var created_at: String var expires_at: String var max_uses: Int /// 通过该邀请授权后可访问多久(每个邀请单独设置) var lease_duration: String? /// 通过该邀请提交的申请是否需要审批 var require_approval: Bool? var uses: [InviteUseItem]? } struct AccessRequestItem: Identifiable, Codable { var id: String var tunnel_id: String var group_id: String? var name: String var ip: String var invite: String? var at: String var status: String /// 授权已结束(或即将结束)的设备申请延长,批准时由管理员决定时长 var renewal: Bool? } /// 可延长的设备:访问结束 2 小时内(通道关闭后也保留),或提交了延长申请 struct RenewalItem: Identifiable, Codable { var id: String { ip } var tunnel_id: String? var group_id: String? var ip: String var name: String? var invite: String? /// 访问结束时间;授权仍有效时为空 var ended_at: String? /// 可以延长的截止时间 var renew_before: String? /// 待处理的延长申请 var request: AccessRequestItem? } /// 通道组:组内通道共用邀请、授权与审批,闲置计时联动 struct GroupItem: Identifiable, Codable { var id: String var name: String var owner: String? var created_at: String? /// 成员通道,按监听端口排序 var tunnel_ids: [String] var metrics: GroupMetricsItem? var rate_history: RateSeriesItem? var renewals: [RenewalItem]? } struct GroupMetricsItem: Codable { var group_id: String? var dynamic_leases: [DynamicLeaseItem]? var invites: [InviteItem]? var pending: [AccessRequestItem]? } /// 添加通道表单将创建(或已创建)的通道 struct PlannedTunnel: Codable, Identifiable { var id: String var name: String var target: String var listen_port: Int } struct GroupRef: Codable { var id: String var name: String } /// /api/tunnels/batch 的返回:加入的通道组(未分组为 nil)与创建的通道 struct BatchResp: Codable { var group: GroupRef? var tunnels: [PlannedTunnel] } /// 添加 / 编辑通道表单的内容;新建时 target 可以写多个目标 struct TunnelForm { var name: String var owner: String var target: String /// 0 表示自动分配;多个目标时总是自动分配一段连续端口 var port: Int var whitelist: [String] var durationMinutes: Int var idleCloseMinutes: Int var maxBandwidthMB: Double /// 加入已有通道组 var groupId: String /// 新建通道组的名称 var newGroup: String var body: [String: Any] { [ "name": name, "owner": owner, "target": target, "listen_port": port, // 空数组表示“仅授权链接” "whitelist": whitelist, "duration_minutes": durationMinutes, "idle_close_minutes": idleCloseMinutes, "max_bandwidth_mb": maxBandwidthMB, "group_id": groupId, "new_group": newGroup ] } } /// 授权的作用范围:单个通道,或整个通道组 enum AccessScope: Hashable { case tunnel(String) case group(String) var key: String { switch self { case .tunnel(let id): return "t:\(id)" case .group(let id): return "g:\(id)" } } /// 授权相关接口的前缀(/invites、/requests、/leases 挂在其下) var apiPath: String { switch self { case .tunnel(let id): return "/api/tunnels/\(id)" case .group(let id): return "/api/groups/\(id)" } } var historyQuery: String { switch self { case .tunnel(let id): return "tunnel_id=\(id)" case .group(let id): return "group_id=\(id)" } } var isGroup: Bool { if case .group = self { return true } return false } } struct InviteShareResp: Codable { var invite: InviteItem var link: String var share_text: String } struct ConnectionsResp: Codable { var active_conns: [ActiveClientInfo] var security_events: [SecurityEventItem] } struct ActiveClientInfo: Identifiable, Codable { var id: String { conn_id } var conn_id: String var tunnel_id: String var client_ip: String var connected_at: String var bytes_in: Int64 var bytes_out: Int64 var rate_in: Int64 var rate_out: Int64 } struct ClientHistoryRecord: Identifiable, Codable { var id: String var tunnel_id: String var tunnel_name: String var port: Int var client_ip: String var connected_at: String var disconnected_at: String var duration_sec: Int64 var bytes_in: Int64 var bytes_out: Int64 } struct SecurityEventItem: Identifiable, Codable { var id: String var tunnel_id: String var port: Int var client_ip: String var timestamp: String var reason: String } struct ServerStatusResp: Codable { var connected: Bool var server_addr: String var port_range: String var public_host: String? var last_error: String? var invite_base: String? var total_rate_history: RateSeriesItem? } /// 连通性测试结果 struct ProbeOutcome { let ok: Bool /// 延迟,或失败原因 let detail: String } struct ProbeResp: Codable { var target: String var reachable: Bool var rtt: String var error: String? } struct RecommendPortResp: Codable { var recommended_port: Int } struct SettingsResp: Codable { var server_addr: String var token: String var public_host: String? } // Diagnostic Item Model enum DiagnosticStatus { case ok case warning case error case checking } struct DiagnosticItem: Identifiable { let id = UUID() let title: String let desc: String let status: DiagnosticStatus let statusText: String let actionTitle: String? let action: (() -> Void)? } // MARK: - Helper Formatting func formatBytes(_ bytes: Int64) -> String { if bytes <= 0 { return "0 B" } let d = Double(bytes) let k = 1024.0 let sizes = ["B", "KB", "MB", "GB", "TB"] let i = Int(log(d) / log(k)) let clampedI = min(i, sizes.count - 1) let val = d / pow(k, Double(clampedI)) return String(format: "%.1f %@", val, sizes[clampedI]) } func formatRate(_ bytesPerSec: Int64) -> String { return "\(formatBytes(bytesPerSec))/s" } func parseISO8601(_ str: String?) -> Date? { guard let s = str, !s.isEmpty else { return nil } let formatter = ISO8601DateFormatter() formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] if let d = formatter.date(from: s) { return d } formatter.formatOptions = [.withInternetDateTime] return formatter.date(from: s) } func formatRemainingTime(expiresAt: String?) -> (text: String, isExpiringSoon: Bool) { guard let exp = parseISO8601(expiresAt) else { return ("持续开启 (无限制)", false) } let diff = exp.timeIntervalSince(Date()) if diff <= 0 { return ("已自动到期停止", true) } let totalSec = Int(diff) let hours = totalSec / 3600 let mins = (totalSec % 3600) / 60 let secs = totalSec % 60 let isSoon = diff < 300 // less than 5 min if hours > 0 { return (String(format: "剩余 %02d:%02d:%02d", hours, mins, secs), isSoon) } else { return (String(format: "剩余 %02d:%02d", mins, secs), isSoon) } } func formatElapsedTime(startedAt: String?) -> String { guard let start = parseISO8601(startedAt) else { return "-" } let diff = Date().timeIntervalSince(start) if diff < 0 { return "00:00" } let totalSec = Int(diff) let hours = totalSec / 3600 let mins = (totalSec % 3600) / 60 let secs = totalSec % 60 if hours > 0 { return String(format: "%02d:%02d:%02d", hours, mins, secs) } else { return String(format: "%02d:%02d", mins, secs) } } func formatSecondsToDuration(_ totalSec: Int64) -> String { if totalSec <= 0 { return "0秒" } let hours = totalSec / 3600 let mins = (totalSec % 3600) / 60 let secs = totalSec % 60 if hours > 0 { return "\(hours)小时\(mins)分" } else if mins > 0 { return "\(mins)分\(secs)秒" } else { return "\(secs)秒" } } /// 生成二维码图片 func makeQRCode(_ text: String, size: CGFloat = 180) -> NSImage? { let filter = CIFilter.qrCodeGenerator() filter.message = Data(text.utf8) filter.correctionLevel = "M" guard let output = filter.outputImage else { return nil } let scale = size / output.extent.width let scaled = output.transformed(by: CGAffineTransform(scaleX: scale, y: scale)) let rep = NSCIImageRep(ciImage: scaled) let image = NSImage(size: rep.size) image.addRepresentation(rep) return image } let leaseDurationOptions: [(value: String, title: String)] = [ ("30m", "30 分钟"), ("1h", "1 小时"), ("2h", "2 小时"), ("8h", "8 小时"), ("today", "当天有效"), ("tunnel", "通道开启期间") ] /// 延长授权的常用时长(分钟) let extendOptions: [(Int, String)] = [(30, "30 分钟"), (60, "1 小时"), (120, "2 小时"), (480, "8 小时")] /// 自定义时长的上限:30 天 let maxCustomMinutes = 30 * 24 * 60 /// "30 分钟"、"2 小时"、"1 小时 30 分钟"、"3 天" func describeMinutes(_ n: Int) -> String { if n >= 1440 && n % 1440 == 0 { return "\(n / 1440) 天" } if n >= 60 && n % 60 == 0 { return "\(n / 60) 小时" } if n > 60 { return "\(n / 60) 小时 \(n % 60) 分钟" } return "\(n) 分钟" } /// 自定义授权时长("90m")对应的分钟数 func customLeaseMinutes(_ v: String) -> Int? { guard v.hasSuffix("m"), let n = Int(v.dropLast()) else { return nil } return n } func leaseDurationTitle(_ v: String?) -> String { let value = v ?? "2h" if let opt = leaseDurationOptions.first(where: { $0.value == value }) { return opt.title } if let n = customLeaseMinutes(value) { return describeMinutes(n) } return "2 小时" } func copyToPasteboard(_ text: String) { NSPasteboard.general.clearContents() NSPasteboard.general.setString(text, forType: .string) } // MARK: - Shared UI Components /// 列表行统一卡片样式(tint 用于需要强调的行,如待审批) struct RowCard: ViewModifier { var tint: Color? = nil func body(content: Content) -> some View { content .background(RoundedRectangle(cornerRadius: 8).fill(tint.map { $0.opacity(0.08) } ?? Color.primary.opacity(0.035))) .overlay(RoundedRectangle(cornerRadius: 8).strokeBorder(tint.map { $0.opacity(0.35) } ?? Color.primary.opacity(0.08), lineWidth: 1)) } } extension View { func rowCard(tint: Color? = nil) -> some View { modifier(RowCard(tint: tint)) } } /// 流式布局:一行放不下时自动换行(时间口径与状态标签共用一行,空间不够再折行) struct FlowLayout: Layout { var spacing: CGFloat = 14 var lineSpacing: CGFloat = 6 private func rows(_ subviews: Subviews, maxWidth: CGFloat) -> [[(Int, CGSize)]] { var rows: [[(Int, CGSize)]] = [[]] var x: CGFloat = 0 for (i, v) in subviews.enumerated() { let size = v.sizeThatFits(.unspecified) let needed = rows[rows.count - 1].isEmpty ? size.width : x + spacing + size.width if !rows[rows.count - 1].isEmpty && needed > maxWidth { rows.append([]) x = size.width } else { x = needed } rows[rows.count - 1].append((i, size)) } return rows } func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { let maxWidth = proposal.width ?? .infinity let rs = rows(subviews, maxWidth: maxWidth) var width: CGFloat = 0 var height: CGFloat = 0 for (n, row) in rs.enumerated() where !row.isEmpty { let w = row.map { $0.1.width }.reduce(0, +) + spacing * CGFloat(row.count - 1) width = max(width, w) height += (row.map { $0.1.height }.max() ?? 0) + (n > 0 ? lineSpacing : 0) } return CGSize(width: proposal.width ?? width, height: height) } func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { var y = bounds.minY for row in rows(subviews, maxWidth: bounds.width) where !row.isEmpty { let h = row.map { $0.1.height }.max() ?? 0 var x = bounds.minX for (i, size) in row { subviews[i].place(at: CGPoint(x: x, y: y + (h - size.height) / 2), proposal: ProposedViewSize(size)) x += size.width + spacing } y += h + lineSpacing } } } let downColor = Color.blue let upColor = Color.teal /// 流量曲线(面积 + 平滑线),纵轴随峰值自动缩放 struct RateSparkline: View { let series: RateSeriesItem? var height: CGFloat = 40 var showsArea: Bool = true struct Point: Identifiable { let id: String let i: Int let v: Double let dir: String } var points: [Point] { guard let s = series else { return [] } var out: [Point] = [] for (i, v) in s.down.enumerated() { out.append(Point(id: "d\(i)", i: i, v: Double(v), dir: "down")) } for (i, v) in s.up.enumerated() { out.append(Point(id: "u\(i)", i: i, v: Double(v), dir: "up")) } return out } var body: some View { let peak = Double(series?.peak ?? 0) let count = max((series?.down.count ?? 120) - 1, 1) Chart(points) { p in if showsArea { AreaMark(x: .value("t", p.i), y: .value("rate", p.v), series: .value("dir", p.dir), stacking: .unstacked) .interpolationMethod(.monotone) .foregroundStyle(LinearGradient( colors: [(p.dir == "down" ? downColor : upColor).opacity(0.30), (p.dir == "down" ? downColor : upColor).opacity(0.02)], startPoint: .top, endPoint: .bottom)) } LineMark(x: .value("t", p.i), y: .value("rate", p.v), series: .value("dir", p.dir)) .interpolationMethod(.monotone) .lineStyle(StrokeStyle(lineWidth: 1.4, lineCap: .round, lineJoin: .round)) .foregroundStyle(p.dir == "down" ? downColor : upColor) } .chartXScale(domain: 0...count) .chartYScale(domain: 0...max(peak * 1.18, 1024)) .chartXAxis(.hidden) .chartYAxis(.hidden) .chartLegend(.hidden) .frame(height: height) } } /// 当前速率图例:● ↓ 下行 ● ↑ 上行 struct RateLegend: View { let down: Int64 let up: Int64 var body: some View { HStack(spacing: 10) { item(downColor, "↓ \(formatRate(down))") item(upColor, "↑ \(formatRate(up))") } .lineLimit(1) } func item(_ color: Color, _ text: String) -> some View { HStack(spacing: 4) { Circle().fill(color).frame(width: 6, height: 6) Text(verbatim: text) .font(.system(size: 11, weight: .medium, design: .monospaced)) } } } /// 卡片内的流量曲线:不加背景,直接融入卡片;底部一条淡基线 + 时间范围与峰值 struct InlineTrafficChart: View { let series: RateSeriesItem? var body: some View { let peak = series?.peak ?? 0 VStack(spacing: 3) { ZStack(alignment: .bottom) { Rectangle() .fill(Color.primary.opacity(0.10)) .frame(height: 1) if peak > 0 { RateSparkline(series: series, height: 44) } else { Text("近 10 分钟无流量") .font(.system(size: 10)) .foregroundColor(.secondary) .frame(maxHeight: .infinity) } } .frame(height: 44) HStack { Text("近 10 分钟") Spacer(minLength: 0) if peak > 0 { Text(verbatim: "峰值 \(formatRate(peak))") } } .font(.system(size: 10)) .foregroundColor(.secondary) .monospacedDigit() .lineLimit(1) } } } struct CardWidthKey: PreferenceKey { static var defaultValue: CGFloat = 0 static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { value = nextValue() } } /// 小号胶囊标签 struct TagView: View { let text: String var color: Color = .secondary init(_ text: String, color: Color = .secondary) { self.text = text self.color = color } var body: some View { Text(verbatim: text) .font(.system(size: 10, weight: .medium)) .lineLimit(1) .padding(.horizontal, 5) .padding(.vertical, 1.5) .background(color.opacity(0.13)) .foregroundColor(color == .secondary ? .primary.opacity(0.75) : color) .cornerRadius(4) } } /// 上下行速率:固定宽度 + 等宽数字,避免数值变化时挤压左侧内容导致换行或跳动 struct RateText: View { let up: Int64 let down: Int64 var size: CGFloat = 11 var body: some View { Text(verbatim: "↑ \(formatRate(up)) ↓ \(formatRate(down))") .font(.system(size: size, design: .monospaced)) .foregroundColor(.secondary) .lineLimit(1) .frame(width: size * 18.5, alignment: .trailing) } } /// 统一的选项按钮(开启时长、限速、授权时长、邀请选项) struct ChipLabel: View { let title: String let selected: Bool var tint: Color = .accentColor var body: some View { Text(verbatim: title) .font(.system(size: 11, weight: selected ? .semibold : .regular)) .lineLimit(1) .padding(.horizontal, 8) .padding(.vertical, 3) .background(selected ? tint.opacity(0.15) : Color(NSColor.controlBackgroundColor)) .foregroundColor(selected ? tint : .primary) .cornerRadius(4) .overlay( RoundedRectangle(cornerRadius: 4) .stroke(selected ? tint.opacity(0.8) : Color(NSColor.separatorColor), lineWidth: 1) ) } } struct StringChip: View { let title: String let value: String @Binding var selection: String var body: some View { Button(action: { selection = value }) { ChipLabel(title: title, selected: selection == value) } .buttonStyle(.borderless) } } /// 自定义时长输入:数字 + 分钟 / 小时 struct CustomMinutesForm: View { let actionTitle: String let onDone: (Int) -> Void @State private var amount: String @State private var hours: Bool init(actionTitle: String, initialMinutes: Int? = nil, onDone: @escaping (Int) -> Void) { self.actionTitle = actionTitle self.onDone = onDone let m = initialMinutes ?? 90 let whole = m >= 60 && m % 60 == 0 _hours = State(initialValue: whole) _amount = State(initialValue: whole ? "\(m / 60)" : "\(m)") } var minutes: Int? { guard let n = Int(amount.trimmingCharacters(in: .whitespaces)), n > 0 else { return nil } let m = hours ? n * 60 : n return m <= maxCustomMinutes ? m : nil } var body: some View { VStack(alignment: .leading, spacing: 8) { HStack(spacing: 8) { TextField("数量", text: $amount) .textFieldStyle(.roundedBorder) .frame(width: 64) .onSubmit { if let m = minutes { onDone(m) } } Picker("", selection: $hours) { Text("分钟").tag(false) Text("小时").tag(true) } .pickerStyle(.segmented) .labelsHidden() .fixedSize() Button(actionTitle) { if let m = minutes { onDone(m) } } .buttonStyle(.borderedProminent) .controlSize(.small) .disabled(minutes == nil) } Text(verbatim: minutes.map { "共 \(describeMinutes($0))" } ?? "请输入 1 分钟到 30 天之间的时长") .font(.system(size: 11)) .foregroundColor(minutes == nil ? .red : .secondary) } .padding(12) } } /// 「自定义…」时长选项:选中时显示具体时长,点击弹出输入框 struct CustomDurationChip: View { let isCustom: Bool let currentMinutes: Int? let apply: (Int) -> Void @State private var editing = false var body: some View { Button(action: { editing = true }) { ChipLabel(title: isCustom ? "自定义 · \(describeMinutes(currentMinutes ?? 0))" : "自定义…", selected: isCustom) } .buttonStyle(.borderless) .popover(isPresented: $editing, arrowEdge: .bottom) { CustomMinutesForm(actionTitle: "确定", initialMinutes: isCustom ? currentMinutes : nil) { m in apply(m) editing = false } } } } /// 「延长 ▾」:选一个时长立即生效,并批准该设备的延长申请;通道已关闭时会自动重新开启 struct ExtendMenu: View { @ObservedObject var vm: PortRelayViewModel let scope: AccessScope let ip: String var title: String = "延长" var prominent: Bool = false @State private var showingCustom = false @State private var working = false @State private var error: String? = nil var body: some View { Menu { ForEach(extendOptions, id: \.0) { opt in Button("延长 \(opt.1)") { extend(opt.0) } } Divider() Button("自定义时长…") { showingCustom = true } } label: { Text(working ? "处理中…" : title) } .menuStyle(.button) .modifier(ExtendMenuStyle(prominent: prominent)) .controlSize(.small) .fixedSize() .disabled(working) .help(vm.scopeEnabled(scope) ? "选择延长多久,立即生效" : "选择延长多久;通道已关闭,会自动重新开启") .popover(isPresented: $showingCustom, arrowEdge: .bottom) { CustomMinutesForm(actionTitle: "延长") { m in showingCustom = false extend(m) } } .alert("延长失败", isPresented: Binding(get: { error != nil }, set: { if !$0 { error = nil } })) { Button("好") {} } message: { Text(verbatim: error ?? "") } } func extend(_ minutes: Int) { working = true Task { error = await vm.extendLease(scope: scope, ip: ip, minutes: minutes) working = false } } } struct ExtendMenuStyle: ViewModifier { let prominent: Bool func body(content: Content) -> some View { if prominent { content.buttonStyle(.borderedProminent) } else { content.buttonStyle(.bordered) } } } // MARK: - Tunnel Search /// 通道搜索:多个关键字(空格分隔)需全部命中;不区分大小写;中文支持全拼与拼音首字母 @MainActor enum TunnelSearch { private static var pinyinCache: [String: (full: String, initials: String)] = [:] /// "MySQL 数据库" -> ("mysqlshujuku", "msjk") static func pinyin(_ text: String) -> (full: String, initials: String) { if let hit = pinyinCache[text] { return hit } let latin = (text.applyingTransform(.toLatin, reverse: false) ?? text) .applyingTransform(.stripDiacritics, reverse: false)?.lowercased() ?? text.lowercased() let words = latin.split(whereSeparator: { !$0.isLetter && !$0.isNumber }) let result = (words.joined(), String(words.compactMap { $0.first })) if pinyinCache.count > 2000 { pinyinCache.removeAll() } pinyinCache[text] = result return result } static func fields(_ t: TunnelItem, publicHost: String) -> [String] { var f: [String] = [t.name, t.target, "\(t.listen_port)", "\(publicHost):\(t.listen_port)", t.owner ?? "", t.notes ?? "", t.whitelist.joined(separator: " ")] f.append(t.enabled ? "运行中 开启" : "已暂停 关闭") f.append(t.whitelist.isEmpty ? "仅授权链接" : (t.whitelist.contains("*") ? "全部放通" : "固定ip 白名单")) f += (t.metrics?.dynamic_leases ?? []).compactMap { $0.name } f += (t.metrics?.dynamic_leases ?? []).map { $0.ip } f += (t.metrics?.invites ?? []).compactMap { $0.note } return f.filter { !$0.isEmpty } } static func matches(_ t: TunnelItem, query: String, publicHost: String) -> Bool { let tokens = query.lowercased().split(whereSeparator: { $0.isWhitespace }).map(String.init) if tokens.isEmpty { return true } let fs = fields(t, publicHost: publicHost) let plain = fs.joined(separator: " ").lowercased() // 拼音只对可能含中文的字段计算(名称、责任人、备注、授权人姓名) let py = ([t.name, t.owner ?? "", t.notes ?? ""] + (t.metrics?.dynamic_leases ?? []).compactMap { $0.name }) .filter { !$0.isEmpty } .map { pinyin($0) } return tokens.allSatisfy { tok in plain.contains(tok) || py.contains { $0.full.contains(tok) || $0.initials.contains(tok) } } } } extension TunnelSearch { /// 任意文本(如通道组名称、责任人)是否命中全部关键字 static func matches(texts: [String], query: String) -> Bool { let tokens = query.lowercased().split(whereSeparator: { $0.isWhitespace }).map(String.init) if tokens.isEmpty { return true } let plain = texts.joined(separator: " ").lowercased() let py = texts.filter { !$0.isEmpty }.map { pinyin($0) } return tokens.allSatisfy { tok in plain.contains(tok) || py.contains { $0.full.contains(tok) || $0.initials.contains(tok) } } } } enum TunnelStatusFilter: String, CaseIterable { case all = "全部", running = "运行中", paused = "已暂停" func accepts(_ t: TunnelItem) -> Bool { switch self { case .all: return true case .running: return t.enabled case .paused: return !t.enabled } } } /// 从 "ip:port" / "[ipv6]:port" 中取出 IP func hostOf(_ addr: String) -> String { if addr.hasPrefix("["), let end = addr.firstIndex(of: "]") { return String(addr[addr.index(after: addr.startIndex).. String { guard let idx = addr.lastIndex(of: ":"), addr.filter({ $0 == ":" }).count == 1 || addr.hasPrefix("[") else { return "" } return String(addr[addr.index(after: idx)...]) } /// 同一 IP 的在线连接合并展示 struct ClientIPGroup: Identifiable { let ip: String let conns: [ActiveClientInfo] var id: String { ip } var bytesIn: Int64 { conns.reduce(0) { $0 + $1.bytes_in } } var bytesOut: Int64 { conns.reduce(0) { $0 + $1.bytes_out } } var rateIn: Int64 { conns.reduce(0) { $0 + $1.rate_in } } var rateOut: Int64 { conns.reduce(0) { $0 + $1.rate_out } } var firstConnectedAt: String? { conns.map { $0.connected_at }.min() } } /// 距现在的秒数(正数表示过去) func secondsSince(_ str: String?) -> Int64 { guard let d = parseISO8601(str) else { return 0 } return Int64(Date().timeIntervalSince(d)) } let idleCloseOptions: [(Int, String)] = [(15, "15 分钟"), (30, "30 分钟"), (60, "1 小时"), (120, "2 小时"), (0, "不自动关闭")] func idleCloseTitle(_ minutes: Int) -> String { idleCloseOptions.first { $0.0 == minutes }?.1 ?? "\(minutes) 分钟" } /// 今天显示 HH:mm:ss,否则显示 MM-dd HH:mm func formatDateTimeShort(_ str: String?) -> String { guard let d = parseISO8601(str) else { return "-" } let df = DateFormatter() df.dateFormat = Calendar.current.isDateInToday(d) ? "HH:mm:ss" : "MM-dd HH:mm" return df.string(from: d) } /// 时刻(不带秒):今天为 "15:20",其他日期为 "10-13 15:20" func formatClock(_ str: String?) -> String { guard let d = parseISO8601(str) else { return "-" } let df = DateFormatter() df.dateFormat = Calendar.current.isDateInToday(d) ? "HH:mm" : "MM-dd HH:mm" return df.string(from: d) } /// 同一 IP 的历史连接合并展示 struct HistoryIPGroup: Identifiable { let ip: String let records: [ClientHistoryRecord] // 按接入时间倒序 var id: String { ip } var bytesIn: Int64 { records.reduce(0) { $0 + $1.bytes_in } } var bytesOut: Int64 { records.reduce(0) { $0 + $1.bytes_out } } var totalDuration: Int64 { records.reduce(0) { $0 + $1.duration_sec } } var lastSeen: String? { records.map { $0.disconnected_at }.max() } var firstSeen: String? { records.map { $0.connected_at }.min() } } func formatTimeHHmm(_ str: String?) -> String { guard let d = parseISO8601(str) else { return "-" } let df = DateFormatter() df.dateFormat = "HH:mm:ss" return df.string(from: d) } // MARK: - ViewModel @MainActor class PortRelayViewModel: ObservableObject { static let shared = PortRelayViewModel() weak var appDelegate: AppDelegate? @Published var isConnected: Bool = false @Published var agentReachable: Bool = true @Published var lastError: String = "" @Published var serverAddr: String = "" /// 访问者连接通道时使用的网关地址(设置中可修改) @Published var publicHost: String = "" /// 邀请链接前缀 http://<对外访问地址>:<控制端口>/i/ @Published var inviteBase: String = "" /// 所有通道合计的近 10 分钟流量曲线 @Published var totalRateHistory: RateSeriesItem? = nil /// 已提醒过的审批申请,避免重复通知 private var notifiedRequests: Set = [] /// 所有待审批申请(单个通道的与通道组的) var pendingRequests: [(title: String, scope: AccessScope, request: AccessRequestItem)] { var out: [(title: String, scope: AccessScope, request: AccessRequestItem)] = [] for t in tunnels { for r in t.metrics?.pending ?? [] { out.append((title: t.name, scope: .tunnel(t.id), request: r)) } } for g in groups { for r in g.metrics?.pending ?? [] { out.append((title: g.name, scope: .group(g.id), request: r)) } } return out } /// 所有待处理的延长申请(单个通道的与通道组的,含已关闭的通道) var renewalRequests: [(title: String, scope: AccessScope, renewal: RenewalItem)] { var out: [(title: String, scope: AccessScope, renewal: RenewalItem)] = [] for t in tunnels { for r in t.renewals ?? [] where r.request != nil { out.append((title: t.name, scope: .tunnel(t.id), renewal: r)) } } for g in groups { for r in g.renewals ?? [] where r.request != nil { out.append((title: g.name, scope: .group(g.id), renewal: r)) } } return out } /// 菜单栏上提醒的待处理数:首次访问申请 + 延长申请 var attentionCount: Int { pendingRequests.count + renewalRequests.count } // MARK: 通道组与授权范围 func members(of g: GroupItem) -> [TunnelItem] { g.tunnel_ids.compactMap { id in tunnels.first { $0.id == id } } } func group(_ id: String?) -> GroupItem? { guard let id = id, !id.isEmpty else { return nil } return groups.first { $0.id == id } } func scopeTunnels(_ s: AccessScope) -> [TunnelItem] { switch s { case .tunnel(let id): return tunnels.filter { $0.id == id } case .group(let id): return group(id).map { members(of: $0) } ?? [] } } func scopeTitle(_ s: AccessScope) -> String { switch s { case .tunnel(let id): return tunnels.first { $0.id == id }?.name ?? "" case .group(let id): return group(id)?.name ?? "通道组" } } /// 通道已开启,或通道组内至少有一个通道开启 func scopeEnabled(_ s: AccessScope) -> Bool { scopeTunnels(s).contains { $0.enabled } } func scopeLeases(_ s: AccessScope) -> [DynamicLeaseItem] { switch s { case .tunnel(let id): return tunnels.first { $0.id == id }?.metrics?.dynamic_leases ?? [] case .group(let id): return group(id)?.metrics?.dynamic_leases ?? [] } } func scopeInvites(_ s: AccessScope) -> [InviteItem] { switch s { case .tunnel(let id): return tunnels.first { $0.id == id }?.metrics?.invites ?? [] case .group(let id): return group(id)?.metrics?.invites ?? [] } } func scopeRenewals(_ s: AccessScope) -> [RenewalItem] { switch s { case .tunnel(let id): return tunnels.first { $0.id == id }?.renewals ?? [] case .group(let id): return group(id)?.renewals ?? [] } } func scopePending(_ s: AccessScope) -> [AccessRequestItem] { switch s { case .tunnel(let id): return tunnels.first { $0.id == id }?.metrics?.pending ?? [] case .group(let id): return group(id)?.metrics?.pending ?? [] } } /// 服务器控制端口(网页授权链接使用),取自控制地址 var controlPort: String { if let idx = serverAddr.lastIndex(of: ":") { let port = serverAddr[serverAddr.index(after: idx)...] if !port.isEmpty { return String(port) } } return "33000" } @Published var tunnels: [TunnelItem] = [] @Published var groups: [GroupItem] = [] @Published var activeConns: [ActiveClientInfo] = [] @Published var securityEvents: [SecurityEventItem] = [] @Published var totalActiveConns: Int = 0 @Published var totalHistoricalConns: Int64 = 0 @Published var totalRateIn: Int64 = 0 @Published var totalRateOut: Int64 = 0 @Published var totalTransferred: Int64 = 0 @Published var totalBlockedAttempts: Int64 = 0 // Diagnostics @Published var diagnostics: [DiagnosticItem] = [] @Published var isRunningDiagnostics: Bool = false private var timer: Timer? private var agentProcess: Process? private var agentFailures: Int = 0 private var lastAgentLaunch: Date = .distantPast private var lastMenuSignature: String = "" /// preview = true 时不启动后台引擎、不轮询、不请求通知权限(用于界面预览与离屏渲染检查) /// 预览模式:网络调用返回下面的模拟数据 private(set) var isPreview = false var previewHistory: [ClientHistoryRecord] = [] init(preview: Bool = false) { isPreview = preview guard !preview else { return } UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { _, _ in } startAgentIfNeeded() startPolling() runDiagnostics() setupSleepWakeNotifications() } private func setupSleepWakeNotifications() { NSWorkspace.shared.notificationCenter.addObserver( forName: NSWorkspace.didWakeNotification, object: nil, queue: .main ) { [weak self] _ in Task { @MainActor [weak self] in self?.handleSystemWake() } } NSWorkspace.shared.notificationCenter.addObserver( forName: NSWorkspace.screensDidWakeNotification, object: nil, queue: .main ) { [weak self] _ in Task { @MainActor [weak self] in self?.handleSystemWake() } } } func handleSystemWake() { Task { // 唤醒时只在会话失效时重连;屏幕唤醒等场景不再切断所有活跃连接 await triggerReconnect(force: false) await refreshData() runDiagnostics() } } /// 彻底退出:通知后台引擎结束(包括不是由本 App 拉起的实例) func shutdownAgent() async { if let url = URL(string: "http://127.0.0.1:33080/api/shutdown") { var req = URLRequest(url: url) req.httpMethod = "POST" req.timeoutInterval = 2 _ = try? await URLSession.shared.data(for: req) } if let p = agentProcess, p.isRunning { p.terminate() } } func triggerReconnect(force: Bool = true) async { let endpoint = force ? "http://127.0.0.1:33080/api/reconnect" : "http://127.0.0.1:33080/api/reconnect?mode=check" guard let url = URL(string: endpoint) else { return } var req = URLRequest(url: url) req.httpMethod = "POST" _ = try? await URLSession.shared.data(for: req) } func startAgentIfNeeded() { let url = URL(string: "http://127.0.0.1:33080/api/status")! var req = URLRequest(url: url) req.timeoutInterval = 0.5 URLSession.shared.dataTask(with: req) { [weak self] _, _, err in if err != nil { Task { @MainActor in self?.launchAgentProcess() } } }.resume() } private func launchAgentProcess() { if let p = agentProcess, p.isRunning { return } lastAgentLaunch = Date() let bundleURL = Bundle.main.bundleURL let candidatePaths = [ bundleURL.appendingPathComponent("Contents/Resources/portrelay-agent").path, bundleURL.deletingLastPathComponent().appendingPathComponent("bin/portrelay-agent").path ] var execPath: String? for path in candidatePaths { if FileManager.default.fileExists(atPath: path) { execPath = path break } } guard let agentPath = execPath else { return } let p = Process() p.executableURL = URL(fileURLWithPath: agentPath) p.arguments = ["-no-browser"] try? p.run() self.agentProcess = p } func startPolling() { timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in Task { @MainActor in await self?.refreshData() } } Task { await refreshData() } } func refreshData() async { await fetchStatus() await fetchTunnels() await fetchGroups() await fetchConnections() let onlineCount = tunnels.filter { $0.enabled }.count appDelegate?.updateStatusItemTitle(activeCount: onlineCount) } /// 新的审批申请弹出系统通知 private func notifyNewRequests() { for (title, _, r) in pendingRequests where !notifiedRequests.contains(r.id) { notifiedRequests.insert(r.id) let content = UNMutableNotificationContent() content.title = "访问申请:\(title)" content.body = "\(r.name)(\(r.ip))申请访问,打开 PortRelay 菜单栏或授权管理进行审批" content.sound = .default UNUserNotificationCenter.current().add(UNNotificationRequest(identifier: r.id, content: content, trigger: nil)) } for (title, _, rn) in renewalRequests { guard let r = rn.request, !notifiedRequests.contains(r.id) else { continue } notifiedRequests.insert(r.id) let content = UNMutableNotificationContent() content.title = "延长申请:\(title)" content.body = "\(r.name)(\(r.ip))申请延长访问,在菜单栏或授权管理里选择延长多久即可批准" content.sound = .default UNUserNotificationCenter.current().add(UNNotificationRequest(identifier: r.id, content: content, trigger: nil)) } } /// 菜单内容有变化时才需要重建(避免每秒重建打断已展开的菜单) func menuNeedsRebuild() -> Bool { // 分步拼接:一长串字符串相加会让旧版编译器类型推断超时 let tunnelSig: String = tunnels.map { "\($0.id):\($0.name):\($0.listen_port):\($0.enabled):\($0.group_id ?? "")" }.joined(separator: ",") let groupSig: String = groups.map { "\($0.id):\($0.name)" }.joined(separator: ",") let requestIDs: [String] = pendingRequests.map { $0.request.id } + renewalRequests.compactMap { $0.renewal.request?.id } let pendingSig: String = requestIDs.joined(separator: ",") let sig = [String(isConnected), tunnelSig, groupSig, pendingSig].joined(separator: "|") if sig == lastMenuSignature { return false } lastMenuSignature = sig return true } func fetchStatus() async { guard let url = URL(string: "http://127.0.0.1:33080/api/status") else { return } do { let (data, _) = try await URLSession.shared.data(from: url) if let resp = try? JSONDecoder().decode(ServerStatusResp.self, from: data) { let wasConnected = self.isConnected self.isConnected = resp.connected self.serverAddr = resp.server_addr if let host = resp.public_host, !host.isEmpty { self.publicHost = host } self.inviteBase = resp.invite_base ?? "" self.totalRateHistory = resp.total_rate_history self.lastError = resp.last_error ?? "" self.agentReachable = true self.agentFailures = 0 if wasConnected != resp.connected { runDiagnostics() } } } catch { let wasConnected = self.isConnected self.isConnected = false self.agentReachable = false self.lastError = "本地后台引擎 (portrelay-agent) 未响应" if wasConnected { runDiagnostics() } // 后台引擎崩溃或被结束:连续失败后自动拉起(10 秒内最多一次) agentFailures += 1 if agentFailures >= 3 && Date().timeIntervalSince(lastAgentLaunch) > 10 { launchAgentProcess() } } } func fetchTunnels() async { guard let url = URL(string: "http://127.0.0.1:33080/api/tunnels") else { return } do { let (data, _) = try await URLSession.shared.data(from: url) let items = try JSONDecoder().decode([TunnelItem].self, from: data) self.tunnels = items var aConns = 0 var hConns: Int64 = 0 var rIn: Int64 = 0 var rOut: Int64 = 0 var bytes: Int64 = 0 var blocked: Int64 = 0 for t in items { let histBytes = (t.historical_bytes_in ?? 0) + (t.historical_bytes_out ?? 0) bytes += histBytes if let m = t.metrics { aConns += (m.active_conns ?? 0) hConns += (m.total_conns ?? 0) rIn += (m.rate_in ?? 0) rOut += (m.rate_out ?? 0) bytes += ((m.bytes_in ?? 0) + (m.bytes_out ?? 0)) blocked += (m.blocked_attempts ?? 0) } } self.totalActiveConns = aConns self.totalHistoricalConns = hConns self.totalRateIn = rIn self.totalRateOut = rOut self.totalTransferred = bytes self.totalBlockedAttempts = blocked notifyNewRequests() } catch { // 拿不到最新数据时不能继续展示旧的“运行中”和速率 self.tunnels = self.tunnels.map { var t = $0; t.metrics = nil; return t } self.activeConns = [] self.totalActiveConns = 0 self.totalRateIn = 0 self.totalRateOut = 0 } } func fetchGroups() async { guard let url = URL(string: "http://127.0.0.1:33080/api/groups") else { return } if let (data, _) = try? await URLSession.shared.data(from: url), let items = try? JSONDecoder().decode([GroupItem].self, from: data) { self.groups = items notifyNewRequests() } } /// 修改通道组名称 / 责任人,并把已有通道加入该组(新地址在添加通道表单里创建) func saveGroup(id: String, name: String, owner: String, tunnelIds: [String]) async -> String? { let (_, err) = await apiCall("PUT", "/api/groups/\(id)", body: ["name": name, "owner": owner, "tunnel_ids": tunnelIds]) await refreshData() return err } /// 预览添加通道表单将创建的通道(多个目标时),不保存 func previewTunnels(_ form: TunnelForm) async -> ([PlannedTunnel], String?) { if isPreview { // 预览模式只模拟常见写法:逗号分隔,以及末段范围 "10.0.3.11-13:22" var out: [PlannedTunnel] = [] for item in form.target.split(whereSeparator: { ", ,\n".contains($0) }) { let hp = item.split(separator: ":") guard hp.count == 2 else { continue } let parts = hp[0].split(separator: ".") var hosts = [String(hp[0])] if parts.count == 4, let dash = parts[3].firstIndex(of: "-"), let lo = Int(parts[3][.. String? { let (_, err) = await apiCall("PUT", "/api/tunnels/\(id)", body: ["group_id": groupId]) await refreshData() return err } /// enabled 为 nil 时取反;传入目标状态时幂等(避免重复点击来回翻转) func toggleTunnel(id: String, enabled: Bool? = nil) async { guard let url = URL(string: "http://127.0.0.1:33080/api/tunnels/\(id)/toggle") else { return } var req = URLRequest(url: url) req.httpMethod = "POST" if let enabled = enabled { req.setValue("application/json", forHTTPHeaderField: "Content-Type") req.httpBody = try? JSONSerialization.data(withJSONObject: ["enabled": enabled]) } _ = try? await URLSession.shared.data(for: req) await refreshData() } func deleteTunnel(id: String) async { guard let url = URL(string: "http://127.0.0.1:33080/api/tunnels/\(id)") else { return } var req = URLRequest(url: url) req.httpMethod = "DELETE" _ = try? await URLSession.shared.data(for: req) await refreshData() } /// 保存添加 / 编辑通道表单。新建时可一次创建多个通道并放进通道组,返回创建结果 func saveTunnel(id: String?, form: TunnelForm) async -> (Bool, BatchResp?, String?) { if let id = id, !id.isEmpty { let (_, err) = await apiCall("PUT", "/api/tunnels/\(id)", body: form.body) if let err = err { return (false, nil, err) } await refreshData() return (true, nil, nil) } let (data, err) = await apiCall("POST", "/api/tunnels/batch", body: form.body) guard let data = data else { return (false, nil, err) } await refreshData() return (true, try? JSONDecoder().decode(BatchResp.self, from: data), nil) } /// 测试一个目标的连通性;detail 为延迟或失败原因 func probe(_ target: String) async -> ProbeOutcome { guard let url = URL(string: "http://127.0.0.1:33080/api/probe") else { return ProbeOutcome(ok: false, detail: "无效地址") } var req = URLRequest(url: url) req.httpMethod = "POST" req.setValue("application/json", forHTTPHeaderField: "Content-Type") req.httpBody = try? JSONSerialization.data(withJSONObject: ["target": target]) do { let (data, _) = try await URLSession.shared.data(for: req) guard let res = try? JSONDecoder().decode(ProbeResp.self, from: data) else { return ProbeOutcome(ok: false, detail: "解析失败") } return res.reachable ? ProbeOutcome(ok: true, detail: res.rtt) : ProbeOutcome(ok: false, detail: res.error ?? "超时") } catch { return ProbeOutcome(ok: false, detail: error.localizedDescription) } } /// 同时测试多个目标 func probeAll(_ targets: [String]) async -> [String: ProbeOutcome] { var out: [String: ProbeOutcome] = [:] await withTaskGroup(of: (String, ProbeOutcome).self) { group in for t in targets { group.addTask { (t, await self.probe(t)) } } for await (t, r) in group { out[t] = r } } return out } func recommendPort() async -> Int { if isPreview { return 33662 } guard let url = URL(string: "http://127.0.0.1:33080/api/recommend-port") else { return 33660 } do { let (data, _) = try await URLSession.shared.data(from: url) if let res = try? JSONDecoder().decode(RecommendPortResp.self, from: data) { return res.recommended_port } } catch {} return 33660 } func fetchHistory(scope: AccessScope) async -> [ClientHistoryRecord] { if isPreview { let ids = Set(scopeTunnels(scope).map { $0.id }) return previewHistory.filter { ids.contains($0.tunnel_id) } } guard let url = URL(string: "http://127.0.0.1:33080/api/history?\(scope.historyQuery)") else { return [] } do { let (data, _) = try await URLSession.shared.data(from: url) return (try? JSONDecoder().decode([ClientHistoryRecord].self, from: data)) ?? [] } catch {} return [] } /// 在线连接与安全事件(之前从未拉取,导致“当前在线客户端”始终为空) func fetchConnections() async { guard let url = URL(string: "http://127.0.0.1:33080/api/connections") else { return } do { let (data, _) = try await URLSession.shared.data(from: url) let resp = try JSONDecoder().decode(ConnectionsResp.self, from: data) self.activeConns = resp.active_conns self.securityEvents = resp.security_events } catch { self.activeConns = [] } } private func apiCall(_ method: String, _ path: String, body: [String: Any]? = nil) async -> (Data?, String?) { guard let url = URL(string: "http://127.0.0.1:33080" + path) else { return (nil, "无效地址") } var req = URLRequest(url: url) req.httpMethod = method if let body = body { req.setValue("application/json", forHTTPHeaderField: "Content-Type") req.httpBody = try? JSONSerialization.data(withJSONObject: body) } do { let (data, resp) = try await URLSession.shared.data(for: req) if let httpResp = resp as? HTTPURLResponse, httpResp.statusCode >= 400 { let errJson = try? JSONSerialization.jsonObject(with: data) as? [String: Any] return (nil, (errJson?["error"] as? String) ?? "操作失败 (HTTP \(httpResp.statusCode))") } return (data, nil) } catch { return (nil, error.localizedDescription) } } /// 生成邀请链接(validMinutes / maxUses 为 0 时 maxUses 表示不限设备) func createInvite(scope: AccessScope, note: String, validMinutes: Int, maxUses: Int, leaseDuration: String, requireApproval: Bool) async -> (InviteShareResp?, String?) { if isPreview { let link = inviteBase.isEmpty ? "http://192.168.1.10:33000/i/k7m2xq9fpa" : inviteBase + "k7m2xq9fpa" let ts = scopeTunnels(scope) let host = publicHost.isEmpty ? "192.168.1.10" : publicHost let deadline = Date().addingTimeInterval(Double(validMinutes) * 60) let fmt = DateFormatter(); fmt.dateFormat = "MM-dd HH:mm" var lines: [String] = ["【PortRelay 访问邀请】" + (note.isEmpty ? "" : "(给 \(note))")] if scope.isGroup { lines.append("通道组:\(scopeTitle(scope))(\(ts.count) 个地址)") lines.append("访问地址:") for t in ts { lines.append(" \(host):\(t.listen_port) → \(t.target)") } } else { lines.append("通道:\(ts.first?.name ?? "MySQL 数据库")") lines.append("访问地址:\(host):\(ts.first?.listen_port ?? 33660)") } let action = requireApproval ? "填写姓名提交申请,管理员批准后即可访问" : "填写姓名后即可访问" lines.append("请在 \(fmt.string(from: deadline)) 前用浏览器打开下面的链接,\(action)(访问有效期:\(leaseDurationTitle(leaseDuration))):") lines.append(link) lines.append("此链接仅限 1 台设备使用,请勿转发。") let text = lines.joined(separator: "\n") return (InviteShareResp(invite: InviteItem(code: "k7m2xq9fpa", note: note, created_at: "", expires_at: "", max_uses: maxUses, lease_duration: leaseDuration, require_approval: requireApproval, uses: nil), link: link, share_text: text), nil) } let (data, err) = await apiCall("POST", "\(scope.apiPath)/invites", body: ["note": note, "valid_minutes": validMinutes, "max_uses": maxUses, "lease_duration": leaseDuration, "require_approval": requireApproval]) guard let data = data else { return (nil, err) } let resp = try? JSONDecoder().decode(InviteShareResp.self, from: data) await refreshData() return (resp, resp == nil ? "解析失败" : nil) } func inviteShare(scope: AccessScope, code: String) async -> (InviteShareResp?, String?) { let (data, err) = await apiCall("GET", "\(scope.apiPath)/invites/\(code)/share") guard let data = data else { return (nil, err) } return (try? JSONDecoder().decode(InviteShareResp.self, from: data), nil) } func revokeInvite(scope: AccessScope, code: String) async -> String? { let (_, err) = await apiCall("DELETE", "\(scope.apiPath)/invites/\(code)") try? await Task.sleep(nanoseconds: 1_200_000_000) await refreshData() return err } func decideRequest(scope: AccessScope, requestId: String, approve: Bool) async -> String? { let (_, err) = await apiCall("POST", "\(scope.apiPath)/requests/\(requestId)", body: ["approve": approve]) UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: [requestId]) try? await Task.sleep(nanoseconds: 1_200_000_000) await refreshData() return err } /// 延长某台设备的访问(同时批准它的延长申请);通道已关闭时会自动重新开启。返回错误信息,成功为 nil func extendLease(scope: AccessScope, ip: String, minutes: Int) async -> String? { let requestID = scopeRenewals(scope).first { $0.ip == ip }?.request?.id let (_, err) = await apiCall("POST", "\(scope.apiPath)/leases/extend", body: ["ip": ip, "minutes": minutes]) if err == nil, let rid = requestID { UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: [rid]) } try? await Task.sleep(nanoseconds: 1_200_000_000) await refreshData() return err } /// 撤销临时放通;ip 为 nil 时撤销该通道(或通道组)全部临时放通。返回错误信息,成功为 nil func revokeLease(scope: AccessScope, ip: String?) async -> String? { guard let url = URL(string: "http://127.0.0.1:33080\(scope.apiPath)/leases/revoke") else { return "无效地址" } var req = URLRequest(url: url) req.httpMethod = "POST" req.setValue("application/json", forHTTPHeaderField: "Content-Type") req.httpBody = try? JSONSerialization.data(withJSONObject: ["ip": ip ?? ""]) do { let (data, resp) = try await URLSession.shared.data(for: req) if let httpResp = resp as? HTTPURLResponse, httpResp.statusCode >= 400 { let errJson = try? JSONSerialization.jsonObject(with: data) as? [String: Any] return (errJson?["error"] as? String) ?? "撤销失败" } } catch { return error.localizedDescription } // 服务端下一次指标推送(约 1 秒)后列表才会更新 try? await Task.sleep(nanoseconds: 1_200_000_000) await refreshData() return nil } func killClients(connIds: [String]) async { for id in connIds { guard let url = URL(string: "http://127.0.0.1:33080/api/connections/kill") else { return } var req = URLRequest(url: url) req.httpMethod = "POST" req.setValue("application/json", forHTTPHeaderField: "Content-Type") req.httpBody = try? JSONSerialization.data(withJSONObject: ["conn_id": id]) _ = try? await URLSession.shared.data(for: req) } await refreshData() } func killClient(connId: String) async { guard let url = URL(string: "http://127.0.0.1:33080/api/connections/kill") else { return } var req = URLRequest(url: url) req.httpMethod = "POST" req.setValue("application/json", forHTTPHeaderField: "Content-Type") let body = ["conn_id": connId] req.httpBody = try? JSONSerialization.data(withJSONObject: body) _ = try? await URLSession.shared.data(for: req) await refreshData() } func fetchSettings() async -> (String, String, String) { if isPreview { return ("192.168.1.10:33000", "preview-token", "192.168.1.10") } guard let url = URL(string: "http://127.0.0.1:33080/api/settings") else { return ("", "", "") } do { let (data, _) = try await URLSession.shared.data(from: url) if let s = try? JSONDecoder().decode(SettingsResp.self, from: data) { return (s.server_addr, s.token, s.public_host ?? self.publicHost) } } catch {} return (self.serverAddr, "", self.publicHost) } /// 返回错误信息,成功时为 nil(后端保存后会自动重连) func saveSettings(serverAddr: String, token: String, publicHost: String) async -> String? { guard let url = URL(string: "http://127.0.0.1:33080/api/settings") else { return "无效地址" } var req = URLRequest(url: url) req.httpMethod = "POST" req.setValue("application/json", forHTTPHeaderField: "Content-Type") let body = ["server_addr": serverAddr, "token": token, "public_host": publicHost] req.httpBody = try? JSONSerialization.data(withJSONObject: body) do { let (data, resp) = try await URLSession.shared.data(for: req) if let httpResp = resp as? HTTPURLResponse, httpResp.statusCode >= 400 { let errJson = try? JSONSerialization.jsonObject(with: data) as? [String: Any] return (errJson?["error"] as? String) ?? "保存失败" } } catch { return error.localizedDescription } await refreshData() runDiagnostics() return nil } // Live Diagnostics Check /// 粗略判断服务器地址所在的网络类型,仅用于诊断展示 var serverNetworkKind: String { let host = hostOf(serverAddr) let parts = host.split(separator: ".").compactMap { Int($0) } guard parts.count == 4 else { return host.isEmpty ? "未配置" : "域名" } switch (parts[0], parts[1]) { case (127, _): return "本机" case (10, _), (192, 168), (172, 16...31): return "私有网络" case (100, 64...127): return "虚拟组网" default: return "公网" } } func runDiagnostics() { self.isRunningDiagnostics = true var items: [DiagnosticItem] = [] if self.isConnected { items.append(DiagnosticItem( title: "本地网络访问权限 (Local Network)", desc: "macOS 隐私与安全性授权状态正常,可与本地及内网节点通信。", status: .ok, statusText: "已授权", actionTitle: nil, action: nil )) } else { items.append(DiagnosticItem( title: "本地网络访问权限 (Local Network)", desc: "若曾误点击拒绝,系统会阻止本应用访问局域网内的网关或目标服务。", status: .warning, statusText: "需确认授权", actionTitle: "打开系统设置 > 本地网络", action: { if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_LocalNetwork") { NSWorkspace.shared.open(url) } } )) } if self.isConnected { items.append(DiagnosticItem( title: "中继服务器通信", desc: "已与 \(self.serverAddr) 建立长连接,端口池处于就绪状态。", status: .ok, statusText: "连通正常", actionTitle: nil, action: nil )) } else { items.append(DiagnosticItem( title: "中继服务器通信", desc: "无法与 \(self.serverAddr) 握手。" + (self.lastError.isEmpty ? "请确保中继服务器上的服务正在运行且防火墙已放通控制端口。" : "原因:\(self.lastError)"), status: .error, statusText: "无法连接", actionTitle: "重试连接", action: { Task { @MainActor in await self.refreshData() self.runDiagnostics() } } )) } items.append(DiagnosticItem( title: "网络路径", desc: "经\(serverNetworkKind)连接边缘网关 \(self.serverAddr)。连接方式不限:局域网、VPN、Tailscale / ZeroTier 等组网或公网均可,只要本机同时能访问目标网段即可。", status: .ok, statusText: serverNetworkKind, actionTitle: nil, action: nil )) items.append(DiagnosticItem( title: "后台中继引擎 (PortRelay Core)", desc: self.agentReachable ? "负责与服务器保持连接,并把访问流量转发到内网目标服务。" : "本地 127.0.0.1:33080 无响应,正在尝试自动拉起后台引擎。", status: self.agentReachable ? .ok : .error, statusText: self.agentReachable ? "运行中" : "未运行", actionTitle: nil, action: nil )) self.diagnostics = items self.isRunningDiagnostics = false } } // MARK: - Native Views @MainActor struct ContentView: View { @ObservedObject var vm: PortRelayViewModel /// 添加通道弹窗(通道组卡片上的「添加地址」会带上组) @State private var addSheet: AddSheetTarget? @State private var showingSettingsSheet = false @State private var showingDiagnosticsSheet = false // 用 sheet(item:) 直接把要操作的通道传给弹窗; // 之前 isPresented + selectedTunnel 的写法会读到旧值,导致编辑弹窗偶尔为空或显示别的通道 @State private var editingTunnel: TunnelItem? /// 邀请弹窗:通道 + 是否打开即生成(新建通道后直接出分享卡片) @State private var inviteTarget: InviteSheetTarget? @State private var clientsTarget: ClientsSheetTarget? @State private var groupSheet: GroupSheetTarget? // 通道搜索与状态筛选 @State private var searchText: String = "" @State private var statusFilter: TunnelStatusFilter = .all @FocusState private var searchFocused: Bool init() { self.init(vm: PortRelayViewModel.shared) } init(vm: PortRelayViewModel, initialSearch: String = "", initialFilter: TunnelStatusFilter = .all) { _vm = ObservedObject(wrappedValue: vm) _searchText = State(initialValue: initialSearch) _statusFilter = State(initialValue: initialFilter) } /// 卡片列表:独立通道与通道组按配置顺序混排(组出现在它第一个成员的位置) enum CardEntry: Identifiable { case tunnel(TunnelItem) case group(GroupItem, [TunnelItem]) var id: String { switch self { case .tunnel(let t): return "t:" + t.id case .group(let g, _): return "g:" + g.id } } } var entries: [CardEntry] { var out: [CardEntry] = [] var seen = Set() let query = searchText for t in vm.tunnels { if let g = vm.group(t.group_id) { guard !seen.contains(g.id) else { continue } seen.insert(g.id) // 组名或责任人命中时显示整组成员,否则只显示命中的成员 let groupHit = !query.trimmingCharacters(in: .whitespaces).isEmpty && TunnelSearch.matches(texts: [g.name, g.owner ?? ""], query: query) let shown = vm.members(of: g).filter { statusFilter.accepts($0) && (groupHit || TunnelSearch.matches($0, query: query, publicHost: vm.publicHost)) } if !shown.isEmpty { out.append(.group(g, shown)) } } else if statusFilter.accepts(t) && TunnelSearch.matches(t, query: query, publicHost: vm.publicHost) { out.append(.tunnel(t)) } } // 还没有成员的组 for g in vm.groups where !seen.contains(g.id) && g.tunnel_ids.isEmpty { if statusFilter == .all && TunnelSearch.matches(texts: [g.name, g.owner ?? ""], query: query) { out.append(.group(g, [])) } } return out } var matchedTunnelCount: Int { entries.reduce(0) { n, e in switch e { case .tunnel: return n + 1 case .group(_, let shown): return n + shown.count } } } var isFiltering: Bool { !searchText.trimmingCharacters(in: .whitespaces).isEmpty || statusFilter != .all } var body: some View { VStack(spacing: 0) { // macOS Standard Top Bar topBarView .padding(.horizontal, 18) .padding(.vertical, 12) .background(Color(NSColor.windowBackgroundColor)) Divider() if !vm.isConnected { HStack(spacing: 10) { Image(systemName: "exclamationmark.circle.fill") .foregroundColor(.orange) .font(.system(size: 13)) Text("未连接至中继服务器 (\(vm.serverAddr))" + (vm.lastError.isEmpty ? "" : " · \(vm.lastError)")) .font(.system(size: 12)) .foregroundColor(.primary) .lineLimit(1) .truncationMode(.tail) Spacer() Button("查看授权与诊断") { showingDiagnosticsSheet = true } .buttonStyle(.link) .font(.system(size: 12)) } .padding(.horizontal, 16) .padding(.vertical, 7) .background(Color.orange.opacity(0.08)) Divider() } ScrollView { VStack(spacing: 16) { // Minimalist Metrics Strip (Activity Monitor style) metricsStripView // Tunnel Section Header: 标题 / 数量 ……… 状态筛选 / 搜索 HStack(spacing: 12) { Text("中继通道") .font(.system(size: 13, weight: .semibold)) .foregroundColor(.secondary) Text(verbatim: isFiltering ? "匹配 \(matchedTunnelCount) / 共 \(vm.tunnels.count) 个" : "\(vm.tunnels.count) 个通道" + (vm.groups.isEmpty ? "" : " · \(vm.groups.count) 个组")) .font(.system(size: 12)) .foregroundColor(.secondary) .monospacedDigit() Spacer() if !vm.tunnels.isEmpty { Picker("", selection: $statusFilter) { ForEach(TunnelStatusFilter.allCases, id: \.self) { f in Text(f.rawValue).tag(f) } } .pickerStyle(.segmented) .labelsHidden() .fixedSize() .controlSize(.small) searchField } } .padding(.top, 4) // Tunnel Cards if vm.tunnels.isEmpty && vm.groups.isEmpty { emptyStateView } else if entries.isEmpty { noMatchView } else { VStack(spacing: 10) { ForEach(entries) { entry in switch entry { case .group(let g, let shown): GroupCardView( group: g, members: vm.members(of: g), visible: shown, vm: vm, onEdit: { groupSheet = GroupSheetTarget(group: g) }, onAddMember: { addSheet = AddSheetTarget(groupId: g.id) }, onInvite: { inviteTarget = InviteSheetTarget(scope: .group(g.id), autoGenerate: false) }, onViewClients: { tab in clientsTarget = ClientsSheetTarget(scope: .group(g.id), tab: tab) }, onEditMember: { t in editingTunnel = t }, onInviteMember: { t in inviteTarget = InviteSheetTarget(tunnel: t, autoGenerate: false) }, onMemberClients: { t in clientsTarget = ClientsSheetTarget(tunnel: t, tab: 0) } ) case .tunnel(let t): TunnelCardView( tunnel: t, vm: vm, onEdit: { editingTunnel = t }, onViewClients: { clientsTarget = ClientsSheetTarget(tunnel: t, tab: 0) }, onViewLeases: { clientsTarget = ClientsSheetTarget(tunnel: t, tab: 2) }, onInvite: { inviteTarget = InviteSheetTarget(tunnel: t, autoGenerate: false) } ) } } } } } .padding(18) } } .frame(minWidth: 920, minHeight: 640) .sheet(item: $addSheet) { target in TunnelEditSheet(vm: vm, tunnel: nil, isPresented: Binding( get: { addSheet != nil }, set: { if !$0 { addSheet = nil } } ), groupId: target.groupId, onCreated: { scope in // 新建完成后打开邀请设置(时长、审批在邀请中选择);新建的通道组直接邀请整组 Task { @MainActor in try? await Task.sleep(nanoseconds: 600_000_000) if vm.scopeEnabled(scope) { inviteTarget = InviteSheetTarget(scope: scope, autoGenerate: false) } } }) } .sheet(item: $inviteTarget) { target in InviteSheet(vm: vm, scope: target.scope, autoGenerate: target.autoGenerate, isPresented: Binding( get: { inviteTarget != nil }, set: { if !$0 { inviteTarget = nil } } )) } .sheet(item: $editingTunnel) { t in TunnelEditSheet(vm: vm, tunnel: t, isPresented: Binding( get: { editingTunnel != nil }, set: { if !$0 { editingTunnel = nil } } )) } .sheet(item: $groupSheet) { target in GroupEditSheet(vm: vm, group: target.group, isPresented: Binding( get: { groupSheet != nil }, set: { if !$0 { groupSheet = nil } } )) } .sheet(isPresented: $showingSettingsSheet) { SettingsSheet(vm: vm, isPresented: $showingSettingsSheet) } .sheet(isPresented: $showingDiagnosticsSheet) { DiagnosticsSheet(vm: vm, isPresented: $showingDiagnosticsSheet) } .sheet(item: $clientsTarget) { target in ClientDetailsSheet(vm: vm, scope: target.scope, initialTab: target.tab, isPresented: Binding( get: { clientsTarget != nil }, set: { if !$0 { clientsTarget = nil } } )) } } var topBarView: some View { HStack(spacing: 14) { HStack(spacing: 8) { Image(systemName: "arrow.left.arrow.right.square.fill") .font(.system(size: 20)) .foregroundColor(.accentColor) VStack(alignment: .leading, spacing: 1) { Text("PortRelay") .font(.system(size: 14, weight: .bold)) Text("端口中继控制台") .font(.system(size: 11)) .foregroundColor(.secondary) } } Spacer() HStack(spacing: 6) { Circle() .fill(vm.isConnected ? Color.green : Color.orange) .frame(width: 7, height: 7) Text(vm.isConnected ? "已就绪" : "连接中断") .font(.system(size: 12)) .foregroundColor(.secondary) } .padding(.horizontal, 10) .padding(.vertical, 4) .background(Color(NSColor.controlBackgroundColor)) .cornerRadius(6) Button(action: { vm.runDiagnostics() showingDiagnosticsSheet = true }) { Label("诊断与权限", systemImage: "shield") } .buttonStyle(.bordered) .controlSize(.small) Button(action: { addSheet = AddSheetTarget(groupId: nil) }) { Label("添加通道", systemImage: "plus") } .buttonStyle(.borderedProminent) .controlSize(.small) Button(action: { showingSettingsSheet = true }) { Image(systemName: "gearshape") } .buttonStyle(.bordered) .controlSize(.small) } } var metricsStripView: some View { HStack(spacing: 1) { MetricStripItem( title: "活跃通道", value: "\(vm.tunnels.filter { $0.enabled }.count) / \(vm.tunnels.count)", caption: "端口池 33001-34000" ) Divider().frame(height: 36) HStack(alignment: .center, spacing: 10) { MetricStripItem( title: "实时速率", value: "↑ \(formatRate(vm.totalRateIn)) ↓ \(formatRate(vm.totalRateOut))", caption: "上行 / 下行 · 近 10 分钟" ) .fixedSize() RateSparkline(series: vm.totalRateHistory, height: 34) .frame(minWidth: 60, maxWidth: 140) .padding(.trailing, 10) } .frame(maxWidth: .infinity, alignment: .leading) Divider().frame(height: 36) MetricStripItem( title: "活跃连接", value: "\(vm.totalActiveConns)", caption: "历史累计 \(vm.totalHistoricalConns)" ) Divider().frame(height: 36) MetricStripItem( title: "拦截未授权", value: "\(vm.totalBlockedAttempts)", caption: "白名单保护", highlight: vm.totalBlockedAttempts > 0 ) } .padding(.vertical, 10) .padding(.horizontal, 12) .background(RoundedRectangle(cornerRadius: 10).fill(Color(NSColor.controlBackgroundColor))) .overlay(RoundedRectangle(cornerRadius: 10).strokeBorder(Color.primary.opacity(0.09), lineWidth: 1)) } var searchField: some View { HStack(spacing: 6) { Image(systemName: "magnifyingglass") .font(.system(size: 11)) .foregroundColor(.secondary) TextField("搜索名称、地址、端口、责任人…", text: $searchText) .textFieldStyle(.plain) .font(.system(size: 12)) .focused($searchFocused) .onExitCommand { searchText = "" } if !searchText.isEmpty { Button(action: { searchText = "" }) { Image(systemName: "xmark.circle.fill") .font(.system(size: 11)) .foregroundColor(.secondary) } .buttonStyle(.plain) .help("清除搜索 (Esc)") } } .padding(.horizontal, 8) .padding(.vertical, 5) .frame(width: 250) .background(RoundedRectangle(cornerRadius: 7).fill(Color(NSColor.controlBackgroundColor))) .overlay(RoundedRectangle(cornerRadius: 7).strokeBorder(searchFocused ? Color.accentColor.opacity(0.7) : Color.primary.opacity(0.12), lineWidth: 1)) .help("支持多个关键字(空格分隔),中文可用拼音或首字母,例如 sjk。快捷键 ⌘F") .background( // ⌘F 聚焦搜索框 Button("") { searchFocused = true } .keyboardShortcut("f", modifiers: .command) .opacity(0) ) } var noMatchView: some View { VStack(spacing: 8) { Image(systemName: "magnifyingglass") .font(.system(size: 26)) .foregroundColor(.secondary) Text("没有匹配的通道") .font(.system(size: 13, weight: .medium)) Text(verbatim: searchText.isEmpty ? "当前筛选:\(statusFilter.rawValue)" : "关键字:\(searchText)" + (statusFilter == .all ? "" : " · 筛选:\(statusFilter.rawValue)")) .font(.system(size: 12)) .foregroundColor(.secondary) Button("清除搜索与筛选") { searchText = "" statusFilter = .all } .controlSize(.small) .padding(.top, 2) } .frame(maxWidth: .infinity) .padding(.vertical, 40) .rowCard() } var emptyStateView: some View { VStack(spacing: 8) { Image(systemName: "tray") .font(.system(size: 32)) .foregroundColor(.secondary) Text("无活动通道") .font(.system(size: 13, weight: .medium)) Text("点击右上角「添加通道」创建反向中继端口;目标可以一次填多台主机,会放进同一个通道组") .font(.system(size: 12)) .foregroundColor(.secondary) } .frame(maxWidth: .infinity) .padding(.vertical, 48) .background(Color(NSColor.controlBackgroundColor).opacity(0.4)) .cornerRadius(8) } } struct MetricStripItem: View { let title: String let value: String let caption: String var highlight: Bool = false var body: some View { VStack(alignment: .leading, spacing: 2) { Text(title) .font(.system(size: 11)) .foregroundColor(.secondary) Text(value) .font(.system(size: 13, weight: .semibold, design: .monospaced)) .foregroundColor(highlight ? .red : .primary) Text(caption) .font(.system(size: 10)) .foregroundColor(.secondary.opacity(0.8)) } .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 10) } } // MARK: - Tunnel Card View (With TTL, Owner, Session/History Traffic) struct TunnelCardView: View { let tunnel: TunnelItem @ObservedObject var vm: PortRelayViewModel let onEdit: () -> Void let onViewClients: () -> Void let onViewLeases: () -> Void let onInvite: () -> Void @State private var showDeleteConfirm: Bool = false var isOnline: Bool { tunnel.enabled && (tunnel.metrics?.status == "online") } var statusBadge: (text: String, color: Color) { guard tunnel.enabled else { return ("已暂停", .secondary) } switch tunnel.metrics?.status { case "online": return ("运行中", .green) case "error": return ("异常", .red) case "offline": return ("等待连接服务器", .orange) default: return ("同步中", .secondary) } } @State private var cardWidth: CGFloat = 0 /// 卡片足够宽时在右侧显示流量面板 var showsTraffic: Bool { cardWidth == 0 || cardWidth >= 700 } var body: some View { VStack(alignment: .leading, spacing: 12) { // 主体左右两栏:左侧 名称/地址/时间口径,右侧 速率 + 开关 + 流量曲线(与左侧等高) HStack(alignment: .top, spacing: 20) { VStack(alignment: .leading, spacing: 9) { HStack(alignment: .center, spacing: 8) { Text(verbatim: tunnel.name) .font(.system(size: 14, weight: .semibold)) .lineLimit(1) .truncationMode(.tail) .layoutPriority(1) if let owner = tunnel.owner, !owner.isEmpty { HStack(spacing: 3) { Image(systemName: "person.fill") .font(.system(size: 9)) Text(verbatim: owner) .font(.system(size: 11, weight: .medium)) .lineLimit(1) } .padding(.horizontal, 6) .padding(.vertical, 2) .background(Color.blue.opacity(0.12)) .foregroundColor(.blue) .cornerRadius(4) } Text(verbatim: statusBadge.text) .font(.system(size: 10, weight: .medium)) .padding(.horizontal, 5) .padding(.vertical, 1.5) .background(statusBadge.color.opacity(0.12)) .foregroundColor(statusBadge.color) .cornerRadius(4) .help(tunnel.metrics?.error ?? "") accessTag } if tunnel.enabled, tunnel.metrics?.status == "error", let err = tunnel.metrics?.error, !err.isEmpty { Label { Text(verbatim: "服务器注册失败:\(err)") } icon: { Image(systemName: "exclamationmark.triangle.fill") } .font(.system(size: 11)) .foregroundColor(.red) .lineLimit(2) } routeRow FlowLayout(spacing: 14, lineSpacing: 7) { lifecycleItems badgeItems } } .frame(maxWidth: .infinity, alignment: .leading) if tunnel.enabled && showsTraffic { VStack(alignment: .trailing, spacing: 6) { HStack(spacing: 10) { RateLegend(down: tunnel.metrics?.rate_out ?? 0, up: tunnel.metrics?.rate_in ?? 0) Spacer(minLength: 0) enableToggle } InlineTrafficChart(series: tunnel.rate_history) } .frame(width: 250) } else { // 无曲线时右栏只放开关,固定尺寸,不挤占左侧 enableToggle .fixedSize() } } Divider() // Footer: 连接 / 本次 / 历史 ……… 操作 HStack(spacing: 12) { let m = tunnel.metrics let sessionBytes = (m?.bytes_in ?? 0) + (m?.bytes_out ?? 0) let histBytes = (tunnel.historical_bytes_in ?? 0) + (tunnel.historical_bytes_out ?? 0) + (tunnel.enabled ? sessionBytes : 0) HStack(spacing: 16) { footerStat("连接", "\(m?.active_conns ?? 0)") footerStat("本次", formatBytes(sessionBytes)) footerStat("历史", formatBytes(histBytes)) if !showsTraffic && tunnel.enabled { footerStat("速率", "↑\(formatRate(m?.rate_in ?? 0)) ↓\(formatRate(m?.rate_out ?? 0))") } } .lineLimit(1) Spacer(minLength: 8) HStack(spacing: 6) { Button(action: onInvite) { Label("邀请", systemImage: "square.and.arrow.up") } .buttonStyle(.borderedProminent) .controlSize(.small) .disabled(!tunnel.enabled) .help(tunnel.enabled ? "生成邀请链接,发给同事临时访问" : "请先开启通道") Button("连接与授权", action: onViewClients) .buttonStyle(.bordered) .controlSize(.small) Button("编辑", action: onEdit) .buttonStyle(.bordered) .controlSize(.small) Button("删除", role: .destructive) { showDeleteConfirm = true } .buttonStyle(.bordered) .controlSize(.small) } } } .padding(14) .background(GeometryReader { g in Color.clear.preference(key: CardWidthKey.self, value: g.size.width) }) .onPreferenceChange(CardWidthKey.self) { cardWidth = $0 } .background(RoundedRectangle(cornerRadius: 10).fill(Color(NSColor.controlBackgroundColor))) .overlay(RoundedRectangle(cornerRadius: 10).strokeBorder(Color.primary.opacity(0.09), lineWidth: 1)) .shadow(color: .black.opacity(0.04), radius: 2, y: 1) .opacity(tunnel.enabled ? 1.0 : 0.6) .alert("确认删除通道?", isPresented: $showDeleteConfirm) { Button("取消", role: .cancel) {} Button("确认删除", role: .destructive) { Task { await vm.deleteTunnel(id: tunnel.id) } } } message: { Text(verbatim: "确定要删除通道「\(tunnel.name)」吗?\n\n• 对外访问地址:\(vm.publicHost):\(tunnel.listen_port)\n• 目标服务:\(tunnel.target)\n\n删除后将立即注销中转监听并切断所有活跃连接。此操作不可撤销。") } } // MARK: Card parts var enableToggle: some View { Toggle("", isOn: Binding( get: { tunnel.enabled }, set: { newValue in Task { await vm.toggleTunnel(id: tunnel.id, enabled: newValue) } } )) .toggleStyle(.switch) .controlSize(.mini) .labelsHidden() .help(tunnel.enabled ? "关闭通道" : "开启通道") } var routeRow: some View { HStack(spacing: 8) { Text(verbatim: "\(vm.publicHost):\(tunnel.listen_port)") .font(.system(size: 12, weight: .medium, design: .monospaced)) .foregroundColor(.accentColor) Image(systemName: "arrow.right") .font(.system(size: 10)) .foregroundColor(.secondary) Text(verbatim: tunnel.target) .font(.system(size: 12, weight: .medium, design: .monospaced)) .foregroundColor(.secondary) Menu { ForEach(quickConnectSnippets(), id: \.cmd) { item in Button(action: { copyToPasteboard(item.cmd) }) { Label(item.title, systemImage: item.icon) } } } label: { HStack(spacing: 3) { Image(systemName: "doc.on.doc") .font(.system(size: 10)) Text("一键复制") .font(.system(size: 11)) } .foregroundColor(.secondary) } .menuStyle(.borderlessButton) .fixedSize() .help("一键复制客户端连接命令或访问地址") } .lineLimit(1) } var accessTag: some View { Group { if tunnel.whitelist.isEmpty { capsule("lock.fill", "仅授权链接", .green) } else if tunnel.whitelist.contains("*") { capsule("exclamationmark.triangle.fill", "全部放通 *", .red) .help("任何能访问服务器的设备都可以连接,建议改为“仅授权链接”") } else { capsule("network", tunnel.whitelist.count == 1 ? "\(tunnel.whitelist[0]) + 授权链接" : "\(tunnel.whitelist.count) 个 IP/网段 + 授权链接", .secondary) .help("固定白名单:" + tunnel.whitelist.joined(separator: ", ")) } } } func capsule(_ icon: String, _ text: String, _ color: Color) -> some View { cardCapsule(icon, text, color) } @ViewBuilder var lifecycleItems: some View { if tunnel.enabled { lifecycleItem("play.circle", "开启 \(formatDateTimeShort(tunnel.started_at))") if let first = tunnel.first_connected_at { lifecycleItem("person.wave.2", "首次连接 \(formatDateTimeShort(first))") } else { lifecycleItem("person.wave.2", "尚无连接") } lifecycleItem("stopwatch", "实际连接 \(formatSecondsToDuration(tunnel.activity?.connected_sec ?? tunnel.connected_sec ?? 0))", color: .primary, weight: .medium) .help("本次开启后,至少有一个连接在线的累计时长") if let act = tunnel.activity { if act.in_use { lifecycleItem("circle.fill", "使用中", color: .green, weight: .medium, iconSize: 7) .help("有在线连接、有效授权、待审批申请或可用的邀请链接") } else if let closeAt = act.auto_close_at { let left = max(0, -secondsSince(closeAt)) lifecycleItem("moon.zzz", "已闲置 \(formatSecondsToDuration(secondsSince(act.idle_since))),\(formatSecondsToDuration(left)) 后自动关闭", color: left < 300 ? .orange : .secondary) } else { lifecycleItem("moon.zzz", "已闲置 \(formatSecondsToDuration(secondsSince(act.idle_since)))(不自动关闭)") } } if tunnel.expires_at != nil { let remaining = formatRemainingTime(expiresAt: tunnel.expires_at) lifecycleItem("hourglass", "最长开启至 \(formatDateTimeShort(tunnel.expires_at))", color: remaining.isExpiringSoon ? .orange : .secondary) } } else { if let lastSec = tunnel.last_duration_sec, lastSec > 0 { lifecycleItem("clock.arrow.circlepath", "上次运行 \(formatSecondsToDuration(lastSec)) · 实际连接 \(formatSecondsToDuration(tunnel.last_connected_sec ?? 0))") } if let reason = tunnel.close_reason, !reason.isEmpty { lifecycleItem("power", "关闭原因:\(reason)") } let idle = tunnel.effective_idle_close_minutes ?? 30 lifecycleItem("moon.zzz", "闲置自动关闭:" + (idle > 0 ? idleCloseTitle(idle) : "关")) if let d = tunnel.duration_minutes, d > 0 { lifecycleItem("hourglass", "最长开启 \(formatSecondsToDuration(Int64(d) * 60))") } } } @ViewBuilder var badgeItems: some View { if let mb = tunnel.max_bandwidth_mb, mb > 0 { capsule("gauge.with.needle", "限速 \(Int(mb)) MB/s", .purple) } if let pending = tunnel.metrics?.pending, !pending.isEmpty { Button(action: onViewLeases) { capsule("bell.badge.fill", "待审批 \(pending.count)", .orange) } .buttonStyle(.plain) .help("有同事申请访问,点击审批") } if let asks = tunnel.renewals?.filter({ $0.request != nil }), !asks.isEmpty { Button(action: onViewLeases) { capsule("clock.arrow.circlepath", "申请延长 \(asks.count)", .orange) } .buttonStyle(.plain) .help("有同事申请延长访问,点击选择延长多久") } if let leases = tunnel.metrics?.dynamic_leases, !leases.isEmpty { Button(action: onViewLeases) { HStack(spacing: 2) { capsule("person.crop.circle.badge.checkmark", "已授权 \(leases.count) 台", .green) } } .buttonStyle(.plain) .help("查看与撤销已授权的设备") } } func footerStat(_ label: String, _ value: String) -> some View { cardFooterStat(label, value) } func lifecycleItem(_ icon: String, _ text: String, color: Color = .secondary, weight: Font.Weight = .regular, iconSize: CGFloat = 11) -> some View { cardLifecycleItem(icon, text, color: color, weight: weight, iconSize: iconSize) } func quickConnectSnippets() -> [(title: String, cmd: String, icon: String)] { connectSnippets(tunnel, host: vm.publicHost) } } /// 一键复制菜单:按目标端口 / 名称猜测客户端命令,并总是提供访问地址 func connectSnippets(_ tunnel: TunnelItem, host: String) -> [(title: String, cmd: String, icon: String)] { let p = tunnel.listen_port let targetLower = tunnel.target.lowercased() let nameLower = tunnel.name.lowercased() var list: [(String, String, String)] = [] if targetLower.hasSuffix(":22") || nameLower.contains("ssh") { list.append(("复制 SSH 命令: ssh -p \(p) root@\(host)", "ssh -p \(p) root@\(host)", "terminal")) } else if targetLower.hasSuffix(":3306") || nameLower.contains("mysql") { list.append(("复制 MySQL 命令: mysql -h \(host) -P \(p) -u root -p", "mysql -h \(host) -P \(p) -u root -p", "cylinder")) } else if targetLower.hasSuffix(":6379") || nameLower.contains("redis") { list.append(("复制 Redis 命令: redis-cli -h \(host) -p \(p)", "redis-cli -h \(host) -p \(p)", "server.rack")) } else if targetLower.hasSuffix(":80") || targetLower.hasSuffix(":443") || targetLower.hasSuffix(":8080") || nameLower.contains("web") || nameLower.contains("http") { list.append(("复制 Web URL: http://\(host):\(p)", "http://\(host):\(p)", "globe")) } list.append(("复制 访问地址 (\(host):\(p))", "\(host):\(p)", "network")) return list } // MARK: - Tunnel Group Card /// 通道组卡片:上半部分是整组的状态与流量,下面逐行列出成员(每个成员一个访问地址) struct GroupCardView: View { let group: GroupItem /// 组内全部通道(统计用) let members: [TunnelItem] /// 当前搜索 / 筛选下显示的成员 let visible: [TunnelItem] @ObservedObject var vm: PortRelayViewModel let onEdit: () -> Void /// 在该组里开放新的地址(打开添加通道表单) let onAddMember: () -> Void let onInvite: () -> Void let onViewClients: (Int) -> Void let onEditMember: (TunnelItem) -> Void let onInviteMember: (TunnelItem) -> Void let onMemberClients: (TunnelItem) -> Void @State private var showDeleteDialog = false @State private var memberToDelete: TunnelItem? @State private var cardWidth: CGFloat = 0 var enabledMembers: [TunnelItem] { members.filter { $0.enabled } } var anyEnabled: Bool { !enabledMembers.isEmpty } var showsTraffic: Bool { cardWidth == 0 || cardWidth >= 700 } var rateIn: Int64 { enabledMembers.reduce(0) { $0 + ($1.metrics?.rate_in ?? 0) } } var rateOut: Int64 { enabledMembers.reduce(0) { $0 + ($1.metrics?.rate_out ?? 0) } } var groupLeases: [DynamicLeaseItem] { group.metrics?.dynamic_leases ?? [] } var groupPending: [AccessRequestItem] { group.metrics?.pending ?? [] } var groupRenewalAsks: [RenewalItem] { (group.renewals ?? []).filter { $0.request != nil } } var statusBadge: (text: String, color: Color) { if members.isEmpty { return ("暂无通道", .secondary) } let on = enabledMembers.count if on == 0 { return ("已暂停", .secondary) } if enabledMembers.contains(where: { $0.metrics?.status == "error" }) { return ("部分异常", .red) } if enabledMembers.contains(where: { $0.metrics?.status == "offline" }) { return ("等待连接服务器", .orange) } if on < members.count { return ("\(on)/\(members.count) 运行中", .orange) } return ("运行中", .green) } var portRange: String? { let ports = members.map { $0.listen_port }.sorted() guard let lo = ports.first, let hi = ports.last else { return nil } let contiguous = hi - lo + 1 == ports.count if lo == hi { return "\(lo)" } return contiguous ? "\(lo)–\(hi)" : ports.map(String.init).joined(separator: ", ") } var body: some View { VStack(alignment: .leading, spacing: 12) { HStack(alignment: .top, spacing: 20) { VStack(alignment: .leading, spacing: 9) { HStack(alignment: .center, spacing: 8) { Image(systemName: "square.stack.3d.up.fill") .font(.system(size: 12)) .foregroundColor(.accentColor) Text(verbatim: group.name) .font(.system(size: 14, weight: .semibold)) .lineLimit(1) .truncationMode(.tail) .layoutPriority(1) if let owner = group.owner, !owner.isEmpty { ownerChip(owner) } cardCapsule(nil, statusBadge.text, statusBadge.color) accessTag } HStack(spacing: 8) { Text(verbatim: "\(members.count) 个地址") .font(.system(size: 12, weight: .medium)) .foregroundColor(.secondary) if let range = portRange { Text(verbatim: "\(vm.publicHost):\(range)") .font(.system(size: 12, weight: .medium, design: .monospaced)) .foregroundColor(.accentColor) .lineLimit(1) .truncationMode(.tail) } if !members.isEmpty { Menu { Button(action: { copyToPasteboard(mappingText) }) { Label("复制全部访问地址(含目标)", systemImage: "list.bullet") } Button(action: { copyToPasteboard(members.map { "\(vm.publicHost):\($0.listen_port)" }.joined(separator: "\n")) }) { Label("仅复制访问地址", systemImage: "network") } } label: { HStack(spacing: 3) { Image(systemName: "doc.on.doc") .font(.system(size: 10)) Text("一键复制") .font(.system(size: 11)) } .foregroundColor(.secondary) } .menuStyle(.borderlessButton) .fixedSize() } } FlowLayout(spacing: 14, lineSpacing: 7) { lifecycleItems badgeItems } } .frame(maxWidth: .infinity, alignment: .leading) if anyEnabled && showsTraffic { VStack(alignment: .trailing, spacing: 6) { HStack(spacing: 10) { RateLegend(down: rateOut, up: rateIn) Spacer(minLength: 0) groupToggle } InlineTrafficChart(series: group.rate_history) } .frame(width: 250) } else { groupToggle .fixedSize() } } if !visible.isEmpty { VStack(spacing: 0) { ForEach(visible) { t in Divider() memberRow(t) } } if visible.count < members.count { Text(verbatim: "已按搜索 / 筛选隐藏 \(members.count - visible.count) 个地址") .font(.system(size: 11)) .foregroundColor(.secondary) } } else if members.isEmpty { Text("组内暂无通道") .font(.system(size: 11)) .foregroundColor(.secondary) } Button(action: onAddMember) { Label("添加地址", systemImage: "plus") .font(.system(size: 11)) } .buttonStyle(.borderless) .help("在该组里开放新的主机或端口,组的邀请和授权自动覆盖新地址") Divider() HStack(spacing: 12) { let session = enabledMembers.reduce(Int64(0)) { $0 + ($1.metrics?.bytes_in ?? 0) + ($1.metrics?.bytes_out ?? 0) } let history = members.reduce(Int64(0)) { $0 + ($1.historical_bytes_in ?? 0) + ($1.historical_bytes_out ?? 0) } + session HStack(spacing: 16) { cardFooterStat("连接", "\(enabledMembers.reduce(0) { $0 + ($1.metrics?.active_conns ?? 0) })") cardFooterStat("本次", formatBytes(session)) cardFooterStat("历史", formatBytes(history)) if !showsTraffic && anyEnabled { cardFooterStat("速率", "↑\(formatRate(rateIn)) ↓\(formatRate(rateOut))") } } .lineLimit(1) Spacer(minLength: 8) HStack(spacing: 6) { Button(action: onInvite) { Label("邀请", systemImage: "square.and.arrow.up") } .buttonStyle(.borderedProminent) .controlSize(.small) .disabled(!anyEnabled) .help(anyEnabled ? "生成整组的邀请链接:同事授权一次即可访问组内全部地址" : "请先开启通道组") Button("连接与授权") { onViewClients(0) } .buttonStyle(.bordered) .controlSize(.small) Button("编辑", action: onEdit) .buttonStyle(.bordered) .controlSize(.small) Button("删除", role: .destructive) { showDeleteDialog = true } .buttonStyle(.bordered) .controlSize(.small) } } } .padding(14) .background(GeometryReader { g in Color.clear.preference(key: CardWidthKey.self, value: g.size.width) }) .onPreferenceChange(CardWidthKey.self) { cardWidth = $0 } .background(RoundedRectangle(cornerRadius: 10).fill(Color(NSColor.controlBackgroundColor))) .overlay(RoundedRectangle(cornerRadius: 10).strokeBorder(Color.primary.opacity(0.09), lineWidth: 1)) .shadow(color: .black.opacity(0.04), radius: 2, y: 1) .opacity(anyEnabled || members.isEmpty ? 1.0 : 0.6) .confirmationDialog(Text(verbatim: "删除通道组「\(group.name)」?"), isPresented: $showDeleteDialog, titleVisibility: .visible) { Button("解散组,保留 \(members.count) 个通道") { Task { await vm.deleteGroup(id: group.id, deleteMembers: false) } } Button("删除组和组内全部通道", role: .destructive) { Task { await vm.deleteGroup(id: group.id, deleteMembers: true) } } Button("取消", role: .cancel) {} } message: { Text("组的邀请链接与授权会立即失效。解散后组内通道变回独立通道;删除则一并注销所有端口并断开连接。") } .alert(Text(verbatim: "删除「\(memberToDelete?.name ?? "")」?"), isPresented: Binding( get: { memberToDelete != nil }, set: { if !$0 { memberToDelete = nil } } )) { Button("取消", role: .cancel) {} Button("确认删除", role: .destructive) { if let t = memberToDelete { Task { await vm.deleteTunnel(id: t.id) } } } } message: { Text(verbatim: "将注销 \(vm.publicHost):\(memberToDelete?.listen_port ?? 0) 并断开它的连接,组内其它地址不受影响。") } } // MARK: Parts var mappingText: String { members.map { "\(vm.publicHost):\($0.listen_port) → \($0.target)" }.joined(separator: "\n") } var groupToggle: some View { Toggle("", isOn: Binding( get: { anyEnabled }, set: { on in Task { await vm.toggleGroup(id: group.id, enabled: on) } } )) .toggleStyle(.switch) .controlSize(.mini) .labelsHidden() .disabled(members.isEmpty) .help(anyEnabled ? "关闭整组" : "开启整组") } var accessTag: some View { let lists = Set(members.map { $0.whitelist.sorted().joined(separator: ",") }) return Group { if members.isEmpty { EmptyView() } else if members.contains(where: { $0.whitelist.contains("*") }) { cardCapsule("exclamationmark.triangle.fill", "含全部放通 *", .red) } else if lists.count > 1 { cardCapsule("slider.horizontal.3", "成员访问方式不同", .secondary) } else if members[0].whitelist.isEmpty { cardCapsule("lock.fill", "仅授权链接", .green) } else { cardCapsule("network", "白名单 + 授权链接", .secondary) .help("固定白名单:" + members[0].whitelist.joined(separator: ", ")) } } } @ViewBuilder var lifecycleItems: some View { if anyEnabled { let started = enabledMembers.compactMap { $0.started_at }.min() cardLifecycleItem("play.circle", "开启 \(formatDateTimeShort(started))") if let first = enabledMembers.compactMap({ $0.first_connected_at }).min() { cardLifecycleItem("person.wave.2", "首次连接 \(formatDateTimeShort(first))") } else { cardLifecycleItem("person.wave.2", "尚无连接") } let connected = enabledMembers.map { $0.activity?.connected_sec ?? $0.connected_sec ?? 0 }.max() ?? 0 cardLifecycleItem("stopwatch", "实际连接 \(formatSecondsToDuration(connected))", color: .primary, weight: .medium) .help("组内连接在线时间最长的一个地址的累计时长") let acts = enabledMembers.compactMap { $0.activity } if acts.contains(where: { $0.in_use }) { cardLifecycleItem("circle.fill", "使用中", color: .green, weight: .medium, iconSize: 7) .help("组内有在线连接、有效授权、待审批申请或可用的邀请链接") } else if let act = acts.min(by: { ($0.auto_close_at ?? "~") < ($1.auto_close_at ?? "~") }) { if let closeAt = act.auto_close_at { let left = max(0, -secondsSince(closeAt)) cardLifecycleItem("moon.zzz", "已闲置 \(formatSecondsToDuration(secondsSince(act.idle_since))),\(formatSecondsToDuration(left)) 后整组自动关闭", color: left < 300 ? .orange : .secondary) } else { cardLifecycleItem("moon.zzz", "已闲置 \(formatSecondsToDuration(secondsSince(act.idle_since)))(不自动关闭)") } } if let exp = enabledMembers.compactMap({ $0.expires_at }).min() { let remaining = formatRemainingTime(expiresAt: exp) cardLifecycleItem("hourglass", "最长开启至 \(formatDateTimeShort(exp))", color: remaining.isExpiringSoon ? .orange : .secondary) } } else if let m = members.first { if let reason = m.close_reason, !reason.isEmpty { cardLifecycleItem("power", "关闭原因:\(reason)") } let idle = m.effective_idle_close_minutes ?? 30 cardLifecycleItem("moon.zzz", "闲置自动关闭:" + (idle > 0 ? idleCloseTitle(idle) : "关")) } } @ViewBuilder var badgeItems: some View { if !groupPending.isEmpty { Button(action: { onViewClients(2) }) { cardCapsule("bell.badge.fill", "待审批 \(groupPending.count)", .orange) } .buttonStyle(.plain) .help("有同事申请访问整组,点击审批") } if !groupRenewalAsks.isEmpty { Button(action: { onViewClients(2) }) { cardCapsule("clock.arrow.circlepath", "申请延长 \(groupRenewalAsks.count)", .orange) } .buttonStyle(.plain) .help("有同事申请延长整组的访问,点击选择延长多久") } if !groupLeases.isEmpty { Button(action: { onViewClients(2) }) { cardCapsule("person.crop.circle.badge.checkmark", "已授权 \(groupLeases.count) 台", .green) } .buttonStyle(.plain) .help("查看与撤销整组的授权") } } func memberRow(_ t: TunnelItem) -> some View { let m = t.metrics let dot: Color = !t.enabled ? .secondary.opacity(0.4) : (m?.status == "online" ? .green : (m?.status == "error" ? .red : .orange)) let own = (m?.dynamic_leases?.count ?? 0) + (m?.pending?.count ?? 0) return HStack(spacing: 8) { Circle() .fill(dot) .frame(width: 7, height: 7) .help(t.enabled ? (m?.error ?? "运行中") : "已关闭") Text(verbatim: "\(vm.publicHost):\(t.listen_port)") .font(.system(size: 12, weight: .medium, design: .monospaced)) .foregroundColor(t.enabled ? .accentColor : .secondary) Image(systemName: "arrow.right") .font(.system(size: 9)) .foregroundColor(.secondary) Text(verbatim: t.target) .font(.system(size: 12, design: .monospaced)) .foregroundColor(.secondary) .lineLimit(1) .truncationMode(.middle) Menu { ForEach(connectSnippets(t, host: vm.publicHost), id: \.cmd) { item in Button(action: { copyToPasteboard(item.cmd) }) { Label(item.title, systemImage: item.icon) } } } label: { Image(systemName: "doc.on.doc") .font(.system(size: 10)) .foregroundColor(.secondary) } .menuStyle(.borderlessButton) .menuIndicator(.hidden) .fixedSize() .help("复制连接命令或访问地址") // 批量创建的成员按“组名 (主机)”自动命名,不重复显示;加入组的已有通道保留原名 if !t.name.hasPrefix(group.name + " (") { Text(verbatim: t.name) .font(.system(size: 11)) .foregroundColor(.secondary) .lineLimit(1) .truncationMode(.tail) } if own > 0 { Button(action: { onMemberClients(t) }) { cardCapsule("person.crop.circle.badge.checkmark", "单独授权 \(own)", .green) } .buttonStyle(.plain) .help("该地址另有单独的授权或申请") } Spacer(minLength: 8) if let n = m?.active_conns, n > 0 { Text(verbatim: "\(n) 连接") .font(.system(size: 11)) .foregroundColor(.secondary) .monospacedDigit() } if t.enabled { RateText(up: m?.rate_in ?? 0, down: m?.rate_out ?? 0, size: 10) } Toggle("", isOn: Binding( get: { t.enabled }, set: { on in Task { await vm.toggleTunnel(id: t.id, enabled: on) } } )) .toggleStyle(.switch) .controlSize(.mini) .labelsHidden() .help(t.enabled ? "单独关闭这个地址" : "单独开启这个地址") Menu { Button("单独邀请这个地址…") { onInviteMember(t) } .disabled(!t.enabled) Button("连接与授权") { onMemberClients(t) } Button("编辑…") { onEditMember(t) } Button("移出通道组") { Task { _ = await vm.moveTunnel(id: t.id, toGroup: "") } } Divider() Button("删除…", role: .destructive) { memberToDelete = t } } label: { Image(systemName: "ellipsis.circle") .font(.system(size: 13)) .foregroundColor(.secondary) } .menuStyle(.borderlessButton) .menuIndicator(.hidden) .fixedSize() } .padding(.vertical, 6) .opacity(t.enabled ? 1 : 0.65) } func ownerChip(_ owner: String) -> some View { HStack(spacing: 3) { Image(systemName: "person.fill") .font(.system(size: 9)) Text(verbatim: owner) .font(.system(size: 11, weight: .medium)) .lineLimit(1) } .padding(.horizontal, 6) .padding(.vertical, 2) .background(Color.blue.opacity(0.12)) .foregroundColor(.blue) .cornerRadius(4) } } // 卡片通用小部件(通道卡片与通道组卡片共用) func cardCapsule(_ icon: String?, _ text: String, _ color: Color) -> some View { HStack(spacing: 3) { if let icon = icon { Image(systemName: icon) .font(.system(size: 9)) } Text(verbatim: text) .font(.system(size: 10, weight: .medium)) .lineLimit(1) } .padding(.horizontal, 5) .padding(.vertical, 1.5) .background(color.opacity(0.12)) .foregroundColor(color == .secondary ? .primary.opacity(0.7) : color) .cornerRadius(4) } func cardFooterStat(_ label: String, _ value: String) -> some View { HStack(spacing: 4) { Text(verbatim: label) .foregroundColor(.secondary) Text(verbatim: value) .fontWeight(.medium) .foregroundColor(.primary.opacity(0.85)) } .font(.system(size: 11)) .monospacedDigit() } func cardLifecycleItem(_ icon: String, _ text: String, color: Color = .secondary, weight: Font.Weight = .regular, iconSize: CGFloat = 11) -> some View { HStack(spacing: 4) { Image(systemName: icon) .font(.system(size: iconSize)) Text(verbatim: text) .font(.system(size: 11, weight: weight)) .monospacedDigit() } .foregroundColor(color) } // MARK: - Client Details & Historical Records Sheet /// 客户明细弹窗的目标(通道或通道组 + 初始页签),整体作为 sheet(item:) 的参数 struct ClientsSheetTarget: Identifiable { let scope: AccessScope let tab: Int var id: String { "\(scope.key)#\(tab)" } init(scope: AccessScope, tab: Int) { self.scope = scope self.tab = tab } init(tunnel: TunnelItem, tab: Int) { self.init(scope: .tunnel(tunnel.id), tab: tab) } } struct ClientDetailsSheet: View { @ObservedObject var vm: PortRelayViewModel let scope: AccessScope @Binding var isPresented: Bool @State private var selectedTab: Int @State private var revokeError: String? = nil @State private var expandedIPs: Set = [] @State private var expandedHistoryIPs: Set = [] @State private var isRevoking: Bool = false /// 通道组:在线连接与接入记录按设备(来源 IP)或按组内地址查看 @State private var byAddress: Bool = false @State private var collapsedOnlineAddrs: Set = [] @State private var expandedHistoryAddrs: Set = [] init(vm: PortRelayViewModel, scope: AccessScope, initialTab: Int = 0, byAddress: Bool = false, isPresented: Binding) { _vm = ObservedObject(wrappedValue: vm) self.scope = scope _isPresented = isPresented _selectedTab = State(initialValue: initialTab) _byAddress = State(initialValue: byAddress) } init(vm: PortRelayViewModel, tunnel: TunnelItem, initialTab: Int = 0, isPresented: Binding) { self.init(vm: vm, scope: .tunnel(tunnel.id), initialTab: initialTab, isPresented: isPresented) } // 弹窗打开期间始终读取最新数据(授权列表每秒刷新) var members: [TunnelItem] { vm.scopeTunnels(scope) } var memberIDs: Set { Set(members.map { $0.id }) } var leases: [DynamicLeaseItem] { vm.scopeLeases(scope) } var invites: [InviteItem] { vm.scopeInvites(scope) } var pendingRequests: [AccessRequestItem] { vm.scopePending(scope) } var renewals: [RenewalItem] { vm.scopeRenewals(scope) } /// 申请延长的设备 var renewalAsks: [RenewalItem] { renewals.filter { $0.request != nil } } /// 访问已结束、2 小时内还能延长的设备 var recentlyEnded: [RenewalItem] { renewals.filter { $0.request == nil && $0.ended_at != nil } } var subtitle: String { if scope.isGroup { let ports = members.map { $0.listen_port } guard let lo = ports.min(), let hi = ports.max() else { return "组内暂无通道" } return "\(members.count) 个地址 · \(vm.publicHost):\(lo)" + (hi > lo ? "–\(hi)" : "") } guard let t = members.first else { return "" } return "\(vm.publicHost):\(t.listen_port) → \(t.target)" } /// 通道组:设备访问了组内哪些地址(" · 访问 10.0.1.11、10.0.1.12");组内端口都相同时只写主机 func addressesSummary(_ tunnelIDs: Set) -> String { guard scope.isGroup else { return "" } let used = members.filter { tunnelIDs.contains($0.id) } guard !used.isEmpty else { return "" } let samePort = Set(members.map { portOf($0.target) }).count == 1 let labels: [String] = used.map { samePort ? hostOf($0.target) : $0.target } if labels.count > 3 { return " · 访问 \(labels.count) 个地址" } return " · 访问 " + labels.joined(separator: "、") } /// 有在线连接 / 接入记录的组内地址数 var addressesOnline: Int { Set(activeClientsForTunnel.map { $0.tunnel_id }).intersection(memberIDs).count } var addressesWithHistory: Int { Set(historyRecords.map { $0.tunnel_id }).intersection(memberIDs).count } var dimensionSummary: String { if selectedTab == 0 { return byAddress ? "\(addressesOnline)/\(members.count) 个地址正在使用" : "\(clientGroups.count) 台设备 · \(activeClientsForTunnel.count) 个连接" } return byAddress ? "\(addressesWithHistory)/\(members.count) 个地址有接入记录" : "\(historyGroups.count) 台设备 · \(historyRecords.count) 次连接" } /// 通道组的在线 / 接入记录可以按设备或按地址看 var dimensionBar: some View { HStack(spacing: 8) { Text(verbatim: dimensionSummary) .font(.system(size: 11)) .foregroundColor(.secondary) Spacer() Picker("", selection: $byAddress) { Text("按设备").tag(false) Text("按地址").tag(true) } .pickerStyle(.segmented) .labelsHidden() .fixedSize() } } /// 通道组里每个连接经过的目标("→ 10.0.1.11:22") func targetLabel(_ tunnelID: String) -> String? { guard scope.isGroup, let t = members.first(where: { $0.id == tunnelID }) else { return nil } return "→ \(t.target)" } @State private var showingInviteSheet = false @State private var copiedCode: String? = nil @State private var historyRecords: [ClientHistoryRecord] = [] @State private var isLoadingHistory: Bool = false var activeClientsForTunnel: [ActiveClientInfo] { let ids = memberIDs return vm.activeConns.filter { ids.contains($0.tunnel_id) } } var historyGroups: [HistoryIPGroup] { Dictionary(grouping: historyRecords) { hostOf($0.client_ip) } .map { HistoryIPGroup(ip: $0.key, records: $0.value.sorted { $0.connected_at > $1.connected_at }) } .sorted { ($0.lastSeen ?? "") > ($1.lastSeen ?? "") } } var clientGroups: [ClientIPGroup] { Dictionary(grouping: activeClientsForTunnel) { hostOf($0.client_ip) } .map { ClientIPGroup(ip: $0.key, conns: $0.value.sorted { $0.connected_at < $1.connected_at }) } .sorted { $0.conns.count != $1.conns.count ? $0.conns.count > $1.conns.count : $0.ip < $1.ip } } var body: some View { VStack(alignment: .leading, spacing: 14) { // Header HStack { VStack(alignment: .leading, spacing: 2) { Text(verbatim: "\(vm.scopeTitle(scope)) · 连接与授权") .font(.system(size: 15, weight: .bold)) .lineLimit(1) .truncationMode(.middle) Text(verbatim: subtitle) .font(.system(size: 12, design: .monospaced)) .foregroundColor(.secondary) .lineLimit(1) } Spacer() Button("完成") { isPresented = false } .buttonStyle(.borderedProminent) .controlSize(.small) } // Tab Picker Picker("", selection: $selectedTab) { Text(verbatim: clientGroups.isEmpty ? "当前在线客户端" : "当前在线 (\(clientGroups.count) 台 · \(activeClientsForTunnel.count) 连接)").tag(0) Text(verbatim: historyGroups.isEmpty ? "历史接入台账" : "历史接入台账 (\(historyGroups.count) 台)").tag(1) Text(verbatim: "授权管理 (\(leases.count + pendingRequests.count + renewalAsks.count))").tag(2) } .pickerStyle(.segmented) .padding(.vertical, 4) if scope.isGroup && selectedTab != 2 { dimensionBar } Divider() // Tab Content if selectedTab == 0 { // Live Connected Clients if activeClientsForTunnel.isEmpty { VStack(spacing: 8) { Image(systemName: "person.slash") .font(.system(size: 28)) .foregroundColor(.secondary) Text("当前暂无客户端连接此端口") .font(.system(size: 13)) .foregroundColor(.secondary) } .frame(maxWidth: .infinity, maxHeight: .infinity) } else { ScrollView { VStack(spacing: 8) { if scope.isGroup && byAddress { ForEach(members) { t in onlineAddressRow(t) } } else { ForEach(clientGroups) { g in clientGroupRow(g) } } } .padding(.bottom, 8) } } } else if selectedTab == 1 { // Historical Connections Log Table if isLoadingHistory { ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity) } else if historyRecords.isEmpty { VStack(spacing: 8) { Image(systemName: "doc.text") .font(.system(size: 28)) .foregroundColor(.secondary) Text("暂无已断开的连接历史记录") .font(.system(size: 13)) .foregroundColor(.secondary) } .frame(maxWidth: .infinity, maxHeight: .infinity) } else { ScrollView { VStack(spacing: 8) { if scope.isGroup && byAddress { ForEach(members) { t in historyAddressRow(t) } } else { ForEach(historyGroups) { g in historyGroupRow(g) } } } .padding(.bottom, 8) } } } else { leasesTab } } .padding(20) .frame(minWidth: 720, idealWidth: 760, maxWidth: .infinity, minHeight: 540, idealHeight: 600, maxHeight: .infinity) .onAppear { loadHistory() } .onChange(of: selectedTab) { newTab in if newTab == 1 { loadHistory() } } } /// 只有一个 IP 时默认展开;点击切换展开状态 func isExpanded(_ ip: String, in set: Set, groupCount: Int) -> Bool { (groupCount == 1) != set.contains(ip) } func toggle(_ ip: String, in set: inout Set) { if set.contains(ip) { set.remove(ip) } else { set.insert(ip) } } func chevron(_ expanded: Bool, visible: Bool = true) -> some View { Image(systemName: "chevron.right") .font(.system(size: 10, weight: .semibold)) .foregroundColor(.secondary) .rotationEffect(.degrees(expanded ? 90 : 0)) .frame(width: 12) .opacity(visible ? 1 : 0) } @ViewBuilder func historyGroupRow(_ g: HistoryIPGroup) -> some View { let expanded = isExpanded(g.ip, in: expandedHistoryIPs, groupCount: historyGroups.count) VStack(alignment: .leading, spacing: 0) { HStack(spacing: 8) { chevron(expanded) VStack(alignment: .leading, spacing: 3) { HStack(spacing: 6) { Text(verbatim: g.ip) .font(.system(size: 13, weight: .semibold, design: .monospaced)) .lineLimit(1) .truncationMode(.middle) TagView("\(g.records.count) 次连接") if let n = leaseName(g.ip) { TagView("已授权 · \(n)", color: .green) } } Text(verbatim: "最近 \(formatDateTimeShort(g.lastSeen)) · 首次 \(formatDateTimeShort(g.firstSeen)) · 累计 \(formatSecondsToDuration(g.totalDuration))" + addressesSummary(Set(g.records.map { $0.tunnel_id }))) .font(.system(size: 11)) .foregroundColor(.secondary) .lineLimit(1) } .layoutPriority(1) Spacer(minLength: 8) VStack(alignment: .trailing, spacing: 3) { Text(verbatim: formatBytes(g.bytesIn + g.bytesOut)) .font(.system(size: 13, weight: .semibold, design: .monospaced)) Text(verbatim: "↑ \(formatBytes(g.bytesIn)) ↓ \(formatBytes(g.bytesOut))") .font(.system(size: 10, design: .monospaced)) .foregroundColor(.secondary) } .lineLimit(1) .frame(width: 190, alignment: .trailing) } .padding(.horizontal, 12) .padding(.vertical, 10) .contentShape(Rectangle()) .onTapGesture { toggle(g.ip, in: &expandedHistoryIPs) } if expanded { Divider().padding(.leading, 32).padding(.trailing, 12) VStack(spacing: 0) { ForEach(g.records) { rec in HStack(spacing: 8) { Color.clear.frame(width: 12) Text(verbatim: ":\(portOf(rec.client_ip))") .font(.system(size: 11, design: .monospaced)) .frame(width: 56, alignment: .leading) Text(verbatim: "\(formatDateTimeShort(rec.connected_at)) – \(formatTimeHHmm(rec.disconnected_at))") .font(.system(size: 11, design: .monospaced)) .foregroundColor(.secondary) .frame(width: 180, alignment: .leading) Text(verbatim: formatSecondsToDuration(rec.duration_sec)) .font(.system(size: 11)) .foregroundColor(.secondary) .frame(width: 80, alignment: .leading) if let to = targetLabel(rec.tunnel_id) { Text(verbatim: to) .font(.system(size: 11, design: .monospaced)) .foregroundColor(.secondary) .truncationMode(.middle) } Spacer(minLength: 8) Text(verbatim: "↑ \(formatBytes(rec.bytes_in)) ↓ \(formatBytes(rec.bytes_out))") .font(.system(size: 10, design: .monospaced)) .foregroundColor(.secondary) .frame(width: 190, alignment: .trailing) } .lineLimit(1) .padding(.horizontal, 12) .padding(.vertical, 5) } } .padding(.vertical, 4) } } .rowCard() } // MARK: 按地址(通道组) func addressLabel(_ t: TunnelItem) -> some View { HStack(spacing: 6) { Text(verbatim: "\(vm.publicHost):\(t.listen_port)") .font(.system(size: 13, weight: .semibold, design: .monospaced)) .foregroundColor(.accentColor) Image(systemName: "arrow.right") .font(.system(size: 9)) .foregroundColor(.secondary) Text(verbatim: t.target) .font(.system(size: 12, design: .monospaced)) .foregroundColor(.secondary) } .lineLimit(1) } /// 设备 IP + 授权时填写的姓名 func deviceLabel(_ ip: String) -> some View { HStack(spacing: 6) { Text(verbatim: ip) .font(.system(size: 11, weight: .medium, design: .monospaced)) .lineLimit(1) .truncationMode(.middle) if let n = leaseName(ip) { TagView(n, color: .green) } } } /// 组内一个地址:此刻哪些设备连着它 @ViewBuilder func onlineAddressRow(_ t: TunnelItem) -> some View { let conns: [ActiveClientInfo] = activeClientsForTunnel.filter { $0.tunnel_id == t.id } let devices: [ClientIPGroup] = Dictionary(grouping: conns) { hostOf($0.client_ip) } .map { ClientIPGroup(ip: $0.key, conns: $0.value) } .sorted { $0.ip < $1.ip } let all = ClientIPGroup(ip: t.id, conns: conns) let expanded = !conns.isEmpty && !collapsedOnlineAddrs.contains(t.id) VStack(alignment: .leading, spacing: 0) { HStack(spacing: 8) { chevron(expanded, visible: !conns.isEmpty) VStack(alignment: .leading, spacing: 3) { HStack(spacing: 6) { addressLabel(t) TagView(conns.isEmpty ? "无连接" : "\(devices.count) 台设备 · \(conns.count) 个连接") } Text(verbatim: conns.isEmpty ? (t.enabled ? "暂无设备连接" : "已关闭") : "最早接入 \(formatDateTimeShort(all.firstConnectedAt)) · ↑ \(formatBytes(all.bytesIn)) ↓ \(formatBytes(all.bytesOut))") .font(.system(size: 11)) .foregroundColor(.secondary) .lineLimit(1) } .layoutPriority(1) Spacer(minLength: 8) if !conns.isEmpty { RateText(up: all.rateIn, down: all.rateOut) Button("全部断开", role: .destructive) { Task { await vm.killClients(connIds: conns.map { $0.conn_id }) } } .controlSize(.small) .frame(width: 76, alignment: .trailing) } } .padding(.horizontal, 12) .padding(.vertical, 10) .contentShape(Rectangle()) .onTapGesture { if !conns.isEmpty { toggle(t.id, in: &collapsedOnlineAddrs) } } if expanded { Divider().padding(.leading, 32).padding(.trailing, 12) VStack(spacing: 0) { ForEach(devices) { d in HStack(spacing: 8) { Color.clear.frame(width: 12) deviceLabel(d.ip) .frame(width: 200, alignment: .leading) Text(verbatim: "\(d.conns.count) 个连接 · 接入 \(formatTimeHHmm(d.firstConnectedAt))") .font(.system(size: 11)) .foregroundColor(.secondary) Spacer(minLength: 8) RateText(up: d.rateIn, down: d.rateOut, size: 10) Button("断开") { Task { await vm.killClients(connIds: d.conns.map { $0.conn_id }) } } .buttonStyle(.borderless) .foregroundColor(.red) .font(.system(size: 11)) .frame(width: 76, alignment: .trailing) } .lineLimit(1) .padding(.horizontal, 12) .padding(.vertical, 5) } } .padding(.vertical, 4) } } .rowCard() .opacity(conns.isEmpty ? 0.6 : 1) } /// 组内一个地址:谁、什么时候访问过它 @ViewBuilder func historyAddressRow(_ t: TunnelItem) -> some View { let records: [ClientHistoryRecord] = historyRecords.filter { $0.tunnel_id == t.id }.sorted { $0.connected_at > $1.connected_at } let summary = HistoryIPGroup(ip: t.id, records: records) let devices = Set(records.map { hostOf($0.client_ip) }).count let expanded = !records.isEmpty && isExpanded(t.id, in: expandedHistoryAddrs, groupCount: addressesWithHistory) VStack(alignment: .leading, spacing: 0) { HStack(spacing: 8) { chevron(expanded, visible: !records.isEmpty) VStack(alignment: .leading, spacing: 3) { HStack(spacing: 6) { addressLabel(t) TagView(records.isEmpty ? "暂无记录" : "\(devices) 台设备 · \(records.count) 次连接") } if !records.isEmpty { Text(verbatim: "最近 \(formatDateTimeShort(summary.lastSeen)) · 首次 \(formatDateTimeShort(summary.firstSeen)) · 累计 \(formatSecondsToDuration(summary.totalDuration))") .font(.system(size: 11)) .foregroundColor(.secondary) .lineLimit(1) } } .layoutPriority(1) Spacer(minLength: 8) if !records.isEmpty { VStack(alignment: .trailing, spacing: 3) { Text(verbatim: formatBytes(summary.bytesIn + summary.bytesOut)) .font(.system(size: 13, weight: .semibold, design: .monospaced)) Text(verbatim: "↑ \(formatBytes(summary.bytesIn)) ↓ \(formatBytes(summary.bytesOut))") .font(.system(size: 10, design: .monospaced)) .foregroundColor(.secondary) } .lineLimit(1) .frame(width: 190, alignment: .trailing) } } .padding(.horizontal, 12) .padding(.vertical, 10) .contentShape(Rectangle()) .onTapGesture { if !records.isEmpty { toggle(t.id, in: &expandedHistoryAddrs) } } if expanded { Divider().padding(.leading, 32).padding(.trailing, 12) VStack(spacing: 0) { ForEach(records) { rec in HStack(spacing: 8) { Color.clear.frame(width: 12) deviceLabel(hostOf(rec.client_ip)) .frame(width: 200, alignment: .leading) Text(verbatim: "\(formatDateTimeShort(rec.connected_at)) – \(formatTimeHHmm(rec.disconnected_at))") .font(.system(size: 11, design: .monospaced)) .foregroundColor(.secondary) .frame(width: 180, alignment: .leading) Text(verbatim: formatSecondsToDuration(rec.duration_sec)) .font(.system(size: 11)) .foregroundColor(.secondary) Spacer(minLength: 8) Text(verbatim: "↑ \(formatBytes(rec.bytes_in)) ↓ \(formatBytes(rec.bytes_out))") .font(.system(size: 10, design: .monospaced)) .foregroundColor(.secondary) .frame(width: 190, alignment: .trailing) } .lineLimit(1) .padding(.horizontal, 12) .padding(.vertical, 5) } } .padding(.vertical, 4) } } .rowCard() .opacity(records.isEmpty ? 0.6 : 1) } func isLeased(_ ip: String) -> Bool { leases.contains { $0.ip == ip } } func leaseName(_ ip: String) -> String? { guard let n = leases.first(where: { $0.ip == ip })?.name, !n.isEmpty else { return nil } return n } @ViewBuilder func clientGroupRow(_ g: ClientIPGroup) -> some View { let multi = g.conns.count > 1 let expanded = multi && isExpanded(g.ip, in: expandedIPs, groupCount: clientGroups.count) VStack(alignment: .leading, spacing: 0) { HStack(spacing: 8) { chevron(expanded, visible: multi) VStack(alignment: .leading, spacing: 3) { HStack(spacing: 6) { Text(verbatim: g.ip) .font(.system(size: 13, weight: .semibold, design: .monospaced)) .foregroundColor(.accentColor) .lineLimit(1) .truncationMode(.middle) TagView("\(g.conns.count) 个连接") if isLeased(g.ip) { TagView(leaseName(g.ip).map { "已授权 · \($0)" } ?? "已授权", color: .green) } } Text(verbatim: "最早接入 \(formatDateTimeShort(g.firstConnectedAt)) · ↑ \(formatBytes(g.bytesIn)) ↓ \(formatBytes(g.bytesOut))" + addressesSummary(Set(g.conns.map { $0.tunnel_id }))) .font(.system(size: 11)) .foregroundColor(.secondary) .lineLimit(1) } .layoutPriority(1) Spacer(minLength: 8) RateText(up: g.rateIn, down: g.rateOut) Button(multi ? "全部断开" : "断开", role: .destructive) { Task { await vm.killClients(connIds: g.conns.map { $0.conn_id }) } } .controlSize(.small) .frame(width: 76, alignment: .trailing) } .padding(.horizontal, 12) .padding(.vertical, 10) .contentShape(Rectangle()) .onTapGesture { if multi { toggle(g.ip, in: &expandedIPs) } } if expanded { Divider().padding(.leading, 32).padding(.trailing, 12) VStack(spacing: 0) { ForEach(g.conns) { c in HStack(spacing: 8) { Color.clear.frame(width: 12) // 通道组里更关心连到了哪个目标,来源端口让位给目标地址 if !scope.isGroup { Text(verbatim: ":\(portOf(c.client_ip))") .font(.system(size: 11, design: .monospaced)) .frame(width: 56, alignment: .leading) } Text(verbatim: "接入 \(formatTimeHHmm(c.connected_at))") .font(.system(size: 11, design: .monospaced)) .foregroundColor(.secondary) .frame(width: 104, alignment: .leading) Text(verbatim: "↑ \(formatBytes(c.bytes_in)) ↓ \(formatBytes(c.bytes_out))") .font(.system(size: 11, design: .monospaced)) .foregroundColor(.secondary) .fixedSize() if let to = targetLabel(c.tunnel_id) { Text(verbatim: to) .font(.system(size: 11, design: .monospaced)) .foregroundColor(.secondary) .truncationMode(.middle) } Spacer(minLength: 8) RateText(up: c.rate_in, down: c.rate_out, size: 10) Button("断开") { Task { await vm.killClient(connId: c.conn_id) } } .buttonStyle(.borderless) .foregroundColor(.red) .font(.system(size: 11)) .frame(width: 76, alignment: .trailing) } .lineLimit(1) .padding(.horizontal, 12) .padding(.vertical, 5) } } .padding(.vertical, 4) } } .rowCard() } func sectionHeader(_ title: String, _ count: Int) -> some View { Text(verbatim: "\(title) · \(count)") .font(.system(size: 12, weight: .semibold)) .foregroundColor(.secondary) .padding(.top, 6) } func emptyHint(_ text: String) -> some View { Text(verbatim: text) .font(.system(size: 11)) .foregroundColor(.secondary) .padding(.vertical, 2) } var leasesTab: some View { VStack(alignment: .leading, spacing: 10) { HStack(alignment: .center, spacing: 8) { Label { Text(verbatim: scope.isGroup ? "组内所有地址共用邀请与授权:同事授权一次即可访问整组,计时从首次连上任一地址开始" : "同事打开邀请链接并填写姓名后获得访问权限;可访问时长与是否需要审批在每个邀请中设置") } icon: { Image(systemName: "link") } .font(.system(size: 11)) .foregroundColor(.secondary) .lineLimit(1) Spacer() if !leases.isEmpty { Button("全部撤销", role: .destructive) { revoke(ip: nil) } .controlSize(.small) .disabled(isRevoking) } Button { showingInviteSheet = true } label: { Label("新建邀请", systemImage: "plus") } .buttonStyle(.borderedProminent) .controlSize(.small) .disabled(!vm.scopeEnabled(scope)) .help(vm.scopeEnabled(scope) ? "生成新的邀请链接" : "请先开启通道") } if let err = revokeError { Label(err, systemImage: "exclamationmark.triangle.fill") .font(.system(size: 11)) .foregroundColor(.red) } ScrollView { VStack(alignment: .leading, spacing: 8) { if !renewalAsks.isEmpty { sectionHeader("申请延长", renewalAsks.count) ForEach(renewalAsks) { rn in renewalRow(rn) } } if !pendingRequests.isEmpty { sectionHeader("待审批", pendingRequests.count) ForEach(pendingRequests) { r in HStack(spacing: 10) { Image(systemName: "bell.badge.fill") .foregroundColor(.orange) VStack(alignment: .leading, spacing: 3) { HStack(spacing: 6) { Text(verbatim: r.name) .font(.system(size: 13, weight: .semibold)) .lineLimit(1) Text(verbatim: r.ip) .font(.system(size: 12, design: .monospaced)) .foregroundColor(.accentColor) } Text(verbatim: "申请于 \(formatDateTimeShort(r.at)) · 批准后可访问 \(leaseDurationTitle(invites.first { $0.code == r.invite }?.lease_duration))") .font(.system(size: 11)) .foregroundColor(.secondary) .lineLimit(1) } .layoutPriority(1) Spacer(minLength: 8) Button("拒绝") { decide(r, approve: false) } .controlSize(.small) .disabled(isRevoking) Button("批准") { decide(r, approve: true) } .buttonStyle(.borderedProminent) .controlSize(.small) .disabled(isRevoking) } .padding(.horizontal, 12) .padding(.vertical, 10) .rowCard(tint: .orange) } } sectionHeader("已授权设备", leases.count) if leases.isEmpty { emptyHint("暂无。点击“新建邀请”把链接发给同事,对方打开并填写姓名后即可访问。") } ForEach(leases) { lease in let remaining = formatRemainingTime(expiresAt: lease.expires_at) let online = vm.activeConns.filter { memberIDs.contains($0.tunnel_id) && hostOf($0.client_ip) == lease.ip }.count HStack(spacing: 10) { Circle() .fill(online > 0 ? Color.green : Color.secondary.opacity(0.35)) .frame(width: 7, height: 7) .help(online > 0 ? "在线" : "当前没有连接") VStack(alignment: .leading, spacing: 3) { HStack(spacing: 6) { Text(verbatim: lease.name?.isEmpty == false ? lease.name! : "未署名") .font(.system(size: 13, weight: .semibold)) .lineLimit(1) .truncationMode(.tail) Text(verbatim: lease.ip) .font(.system(size: 12, design: .monospaced)) .foregroundColor(.accentColor) .lineLimit(1) if online > 0 { TagView("在线 \(online) 个连接", color: .green) } } if lease.awaiting_connect == true { Text(verbatim: "已授权,尚未连接 · 首次连接后可访问 \(leaseDurationTitle(lease.duration)) · 请在 \(formatDateTimeShort(lease.expires_at)) 前连接") .font(.system(size: 11)) .foregroundColor(.secondary) .lineLimit(1) } else { Text(verbatim: (lease.started_at.map { "首次连接 \(formatDateTimeShort($0)) · " } ?? "") + "有效期至 \(formatDateTimeShort(lease.expires_at)) · \(remaining.text)") .font(.system(size: 11)) .foregroundColor(remaining.isExpiringSoon ? .orange : .secondary) .lineLimit(1) } } .layoutPriority(1) Spacer(minLength: 8) if lease.awaiting_connect != true { ExtendMenu(vm: vm, scope: scope, ip: lease.ip) } Button("撤销", role: .destructive) { revoke(ip: lease.ip) } .controlSize(.small) .disabled(isRevoking) .help("立即收回该设备的访问权限并断开连接") } .padding(.horizontal, 12) .padding(.vertical, 10) .rowCard() } if !recentlyEnded.isEmpty { sectionHeader("最近结束 · 2 小时内可延长", recentlyEnded.count) ForEach(recentlyEnded) { rn in renewalRow(rn) } } sectionHeader("有效的邀请链接", invites.count) if invites.isEmpty { emptyHint("暂无有效的邀请链接") } ForEach(invites) { inv in let used = inv.uses ?? [] let full = inv.max_uses > 0 && used.count >= inv.max_uses HStack(spacing: 10) { Image(systemName: "link") .foregroundColor(.secondary) VStack(alignment: .leading, spacing: 3) { HStack(spacing: 6) { Text(verbatim: inv.note?.isEmpty == false ? "给 \(inv.note!)" : "未备注的邀请") .font(.system(size: 13, weight: .semibold)) .lineLimit(1) TagView("已用 \(used.count)/\(inv.max_uses > 0 ? "\(inv.max_uses)" : "不限")", color: full ? .orange : .secondary) TagView("授权后可访问 \(leaseDurationTitle(inv.lease_duration))", color: .blue) if inv.require_approval == true { TagView("需审批", color: .orange) } } Text(verbatim: "链接有效至 \(formatDateTimeShort(inv.expires_at))" + (used.isEmpty ? " · 尚未使用" : " · \(used.map { $0.name }.joined(separator: "、"))")) .font(.system(size: 11)) .foregroundColor(.secondary) .lineLimit(1) .truncationMode(.tail) } .layoutPriority(1) Spacer(minLength: 8) Button(copiedCode == inv.code ? "已复制 ✓" : "复制分享文案") { Task { let (resp, _) = await vm.inviteShare(scope: scope, code: inv.code) copyToPasteboard(resp?.share_text ?? (vm.inviteBase + inv.code)) copiedCode = inv.code } } .controlSize(.small) .disabled(full) .help(full ? "该链接已用满,请新建邀请" : "复制可直接发给同事的文案") Button("作废", role: .destructive) { isRevoking = true Task { revokeError = await vm.revokeInvite(scope: scope, code: inv.code) isRevoking = false } } .controlSize(.small) .disabled(isRevoking) .help("作废后链接失效,通过它获得的访问权限也会被收回") } .padding(.horizontal, 12) .padding(.vertical, 10) .rowCard() } } .padding(.bottom, 8) } } .sheet(isPresented: $showingInviteSheet) { InviteSheet(vm: vm, scope: scope, autoGenerate: false, isPresented: $showingInviteSheet) } } /// 申请延长 / 最近结束的设备:选时长即延长(同时批准申请) func renewalRow(_ rn: RenewalItem) -> some View { let asking = rn.request != nil return HStack(spacing: 10) { Image(systemName: asking ? "clock.arrow.circlepath" : "clock") .foregroundColor(asking ? .orange : .secondary) VStack(alignment: .leading, spacing: 3) { HStack(spacing: 6) { Text(verbatim: rn.name?.isEmpty == false ? rn.name! : "未署名") .font(.system(size: 13, weight: .semibold)) .lineLimit(1) Text(verbatim: rn.ip) .font(.system(size: 12, design: .monospaced)) .foregroundColor(.accentColor) if !vm.scopeEnabled(scope) { TagView("通道已关闭,批准后自动开启") } } Text(verbatim: renewalDetail(rn)) .font(.system(size: 11)) .foregroundColor(.secondary) .lineLimit(1) } .layoutPriority(1) Spacer(minLength: 8) if let r = rn.request { Button("拒绝") { decide(r, approve: false) } .controlSize(.small) .disabled(isRevoking) } ExtendMenu(vm: vm, scope: scope, ip: rn.ip, title: asking ? "批准延长" : "延长", prominent: asking) } .padding(.horizontal, 12) .padding(.vertical, 10) .rowCard(tint: asking ? .orange : nil) } func renewalDetail(_ rn: RenewalItem) -> String { var parts: [String] = [] if let r = rn.request { parts.append("申请于 \(formatClock(r.at))") } if let end = rn.ended_at { parts.append("访问已于 \(formatClock(end)) 结束") if let before = rn.renew_before { parts.append("\(formatClock(before)) 前可延长") } } else if let lease = leases.first(where: { $0.ip == rn.ip }) { parts.append("当前有效至 \(formatClock(lease.expires_at)),批准后在此基础上顺延") } return parts.joined(separator: " · ") } func decide(_ r: AccessRequestItem, approve: Bool) { isRevoking = true Task { revokeError = await vm.decideRequest(scope: scope, requestId: r.id, approve: approve) isRevoking = false } } func revoke(ip: String?) { isRevoking = true revokeError = nil Task { revokeError = await vm.revokeLease(scope: scope, ip: ip) isRevoking = false } } func loadHistory() { isLoadingHistory = true Task { let list = await vm.fetchHistory(scope: scope) historyRecords = list isLoadingHistory = false } } } // MARK: - Diagnostics & Permission Status Sheet (Live Check) struct DiagnosticsSheet: View { @ObservedObject var vm: PortRelayViewModel @Binding var isPresented: Bool var body: some View { VStack(alignment: .leading, spacing: 16) { // Header HStack { VStack(alignment: .leading, spacing: 2) { Text("系统权限与网络环境诊断") .font(.system(size: 15, weight: .bold)) Text("检测本机网络授权、中继服务器通信与后台引擎状态") .font(.system(size: 12)) .foregroundColor(.secondary) } Spacer() Button(action: { vm.runDiagnostics() }) { Label("重新检测", systemImage: "arrow.clockwise") } .buttonStyle(.bordered) .controlSize(.small) Button("完成") { isPresented = false } .buttonStyle(.borderedProminent) .controlSize(.small) } Divider() // Diagnostic Items List VStack(spacing: 10) { ForEach(vm.diagnostics) { item in HStack(alignment: .top, spacing: 12) { Group { switch item.status { case .ok: Image(systemName: "checkmark.circle.fill") .foregroundColor(.green) case .warning: Image(systemName: "exclamationmark.triangle.fill") .foregroundColor(.orange) case .error: Image(systemName: "xmark.circle.fill") .foregroundColor(.red) case .checking: ProgressView().controlSize(.small) } } .font(.system(size: 16)) .frame(width: 20) VStack(alignment: .leading, spacing: 4) { HStack { Text(item.title) .font(.system(size: 13, weight: .semibold)) Spacer() Text(item.statusText) .font(.system(size: 11, weight: .medium)) .foregroundColor(item.status == .ok ? .green : (item.status == .error ? .red : .orange)) .padding(.horizontal, 6) .padding(.vertical, 1.5) .background((item.status == .ok ? Color.green : (item.status == .error ? Color.red : Color.orange)).opacity(0.12)) .cornerRadius(4) } Text(item.desc) .font(.system(size: 12)) .foregroundColor(.secondary) .lineSpacing(2) if let actionTitle = item.actionTitle, let action = item.action { Button(actionTitle, action: action) .buttonStyle(.link) .font(.system(size: 12)) .padding(.top, 2) } } } .padding(12) .rowCard(tint: item.status == .error ? .red : (item.status == .warning ? .orange : nil)) } } Spacer() Text("说明:若 macOS 弹出「PortRelay 想要查找并连接本地网络上的设备」提示,请选择「允许」。") .font(.system(size: 11)) .foregroundColor(.secondary) } .padding(20) .frame(width: 580, height: 460) } } // MARK: - Edit / Create Sheet (With Owner & Duration TTL) /// 添加通道表单里的一行目标:一台主机 + 一个或多个端口("22, 3306"、"8080-8082")。 /// 主机格也接受逗号分隔的几台、末段范围(10.0.1.11-13)或自带端口的 主机:端口。 struct TargetRow: Identifiable, Equatable { let id = UUID() var host: String = "" var ports: String = "" static let separators: Set = [" ", ",", ",", "、", ";", ";", "\n", "\r", "\t"] static func items(_ s: String) -> [String] { s.split(whereSeparator: { separators.contains($0) }).map(String.init) } /// "10.0.1.11:22" / "[fd00::1]:22" → (主机, 端口);不带端口(含裸 IPv6)返回 nil static func splitHostPort(_ item: String) -> (String, String)? { if item.hasPrefix("["), let close = item.firstIndex(of: "]") { let rest = item[item.index(after: close)...] guard rest.hasPrefix(":"), rest.count > 1 else { return nil } return (String(item[item.index(after: item.startIndex).. [TargetRow] { var out: [TargetRow] = [] for item in items(text) { let (host, port) = splitHostPort(item) ?? (item, "") if let i = out.firstIndex(where: { $0.host == host }) { if !port.isEmpty && !items(out[i].ports).contains(port) { out[i].ports = out[i].ports.isEmpty ? port : out[i].ports + ", " + port } } else { out.append(TargetRow(host: host, ports: port.isEmpty ? defaultPorts : port)) } } return out } /// 拼成 "主机:端口, …" 交给服务端展开;还没填完整的行先跳过 static func compose(_ rows: [TargetRow]) -> String { var out: [String] = [] for r in rows { let ports = items(r.ports) for h in items(r.host) { if splitHostPort(h) != nil { out.append(h) continue } guard !ports.isEmpty else { continue } let host = h.contains(":") && !h.hasPrefix("[") ? "[\(h)]" : h for p in ports { out.append("\(host):\(p)") } } } return out.joined(separator: ", ") } } struct TunnelEditSheet: View { @ObservedObject var vm: PortRelayViewModel let tunnel: TunnelItem? /// 组卡片「添加地址」打开时固定加入的通道组 let fixedGroupId: String? @Binding var isPresented: Bool @State private var name: String @State private var owner: String @State private var rows: [TargetRow] @State private var portString: String @State private var durationMinutes: Int @State private var idleCloseMinutes: Int @State private var maxBandwidthMB: Double // 0 为不限速 @State private var whitelistString: String @State private var accessMode: AccessMode @State private var groupChoice: GroupChoice @State private var newGroupName: String = "" /// 将创建的通道(服务端预览,含自动分配的访问端口) @State private var plan: [PlannedTunnel] = [] @State private var planError: String? = nil /// 连通性测试结果,按目标 @State private var probes: [String: ProbeOutcome] = [:] @State private var isProbing: Bool = false @State private var isSaving: Bool = false @State private var errorMessage: String? = nil @FocusState private var focusedRow: UUID? /// 新建完成后打开邀请:独立通道,或新建的通道组。加入已有组时不需要——组的授权自动覆盖新地址 var onCreated: ((AccessScope) -> Void)? = nil enum AccessMode: String, CaseIterable { case linkOnly, specific, all } /// 通道组。新建时不用选:一个地址不分组,多个地址自动新建组(以名称为组名), /// 从组卡片「添加地址」进来则固定加入该组;只有编辑已有通道时才能选择移入、新建或移出 enum GroupChoice: Hashable { case none case new case existing(String) } var isEdit: Bool { tunnel != nil } /// 表单字段在创建时就用通道数据填好,不依赖 onAppear(避免第一帧显示空表单)。 /// groupId:新建时固定加入的通道组(通道组卡片上的「添加地址」) init(vm: PortRelayViewModel, tunnel: TunnelItem?, isPresented: Binding, groupId: String? = nil, draftName: String = "", draftRows: [(String, String)] = [], onCreated: ((AccessScope) -> Void)? = nil) { _vm = ObservedObject(wrappedValue: vm) self.tunnel = tunnel self.fixedGroupId = tunnel == nil ? groupId : nil self.onCreated = onCreated _isPresented = isPresented let wl = tunnel?.whitelist ?? [] let mode: AccessMode = wl.isEmpty ? .linkOnly : (wl.contains("*") ? .all : .specific) _accessMode = State(initialValue: mode) _name = State(initialValue: tunnel?.name ?? draftName) _owner = State(initialValue: tunnel.map { $0.owner ?? "" } ?? NSUserName()) var initialRows = draftRows.map { TargetRow(host: $0.0, ports: $0.1) } if let t = tunnel { let (h, p) = TargetRow.splitHostPort(t.target) ?? (t.target, "") initialRows = [TargetRow(host: h, ports: p)] } _rows = State(initialValue: initialRows.isEmpty ? [TargetRow()] : initialRows) _portString = State(initialValue: tunnel.map { "\($0.listen_port)" } ?? "") // 最长开启时长:新建默认不限制(主要依靠闲置自动关闭);duration_minutes 为 0 时 JSON 中会省略 _durationMinutes = State(initialValue: tunnel.map { $0.duration_minutes ?? 0 } ?? 0) _idleCloseMinutes = State(initialValue: tunnel?.effective_idle_close_minutes ?? 30) _maxBandwidthMB = State(initialValue: tunnel?.max_bandwidth_mb ?? 0) _whitelistString = State(initialValue: mode == .specific ? wl.joined(separator: ", ") : "") let gid = tunnel?.group_id ?? "" _groupChoice = State(initialValue: gid.isEmpty ? .none : .existing(gid)) } // MARK: Derived state var composedTarget: String { TargetRow.compose(rows) } /// 会创建多个通道(自动放进通道组) var isBatch: Bool { !isEdit && plan.count > 1 } var trimmedName: String { name.trimmingCharacters(in: .whitespaces) } /// 新建通道组的名称:新建时就是名称;编辑时可另填,没填时与通道名称相同 var resolvedGroupName: String { let n = newGroupName.trimmingCharacters(in: .whitespaces) return isEdit && !n.isEmpty ? n : trimmedName } /// 实际生效的通道组:编辑时按选择;新建时由入口和地址数决定 var effectiveChoice: GroupChoice { if isEdit { return groupChoice } if let gid = fixedGroupId { return .existing(gid) } return isBatch ? .new : .none } /// 「添加地址」固定加入的通道组名称 var fixedGroupName: String? { fixedGroupId.map { vm.group($0)?.name ?? "通道组" } } var nameTitle: String { if fixedGroupName != nil { return "通道名称(可选)" } return isBatch ? "通道组名称" : "通道名称" } var namePlaceholder: String { if let g = fixedGroupName { return "留空则命名为「\(g) (主机)」" } return isBatch ? "如:订单服务集群" : "如:订单数据库" } var canSave: Bool { if isSaving { return false } if isEdit { return !trimmedName.isEmpty && !composedTarget.isEmpty && (groupChoice != .new || !resolvedGroupName.isEmpty) } if plan.isEmpty || planError != nil { return false } return !trimmedName.isEmpty || fixedGroupId != nil } var saveTitle: String { if isSaving { return "保存中…" } if fixedGroupId != nil { return isBatch ? "添加 \(plan.count) 个地址" : "添加" } return isBatch ? "创建 \(plan.count) 个通道" : "保存" } func makeForm(port: Int, whitelist: [String]) -> TunnelForm { var groupId = "", newGroup = "" switch effectiveChoice { case .none: break case .new: newGroup = resolvedGroupName case .existing(let id): groupId = id } return TunnelForm( name: trimmedName, owner: owner.trimmingCharacters(in: .whitespaces), target: composedTarget, port: port, whitelist: whitelist, durationMinutes: durationMinutes, idleCloseMinutes: idleCloseMinutes, maxBandwidthMB: maxBandwidthMB, groupId: groupId, newGroup: newGroup) } // MARK: Body var body: some View { VStack(alignment: .leading, spacing: 14) { Text(verbatim: isEdit ? "编辑通道" : (fixedGroupName.map { "添加地址到「\($0)」" } ?? "添加通道")) .font(.system(size: 15, weight: .bold)) .lineLimit(1) HStack(spacing: 12) { field(nameTitle) { TextField(namePlaceholder, text: $name) .textFieldStyle(.roundedBorder) } field("关联责任人") { TextField("负责人或团队,如:数据组", text: $owner) .textFieldStyle(.roundedBorder) } } targetSection if isEdit { field("对外监听端口 (范围 33001-34000)") { HStack { TextField("33660", text: $portString) .textFieldStyle(.roundedBorder) Button("自动分配") { Task { portString = "\(await vm.recommendPort())" } } .controlSize(.small) } } } if isEdit { groupSection } closeSection bandwidthSection accessSection if let err = errorMessage { Text(verbatim: err) .font(.system(size: 12)) .foregroundColor(.red) } HStack { Spacer() Button("取消") { isPresented = false } .controlSize(.small) Button(saveTitle) { save() } .buttonStyle(.borderedProminent) .controlSize(.small) .disabled(!canSave) } .padding(.top, 4) } .padding(20) .frame(width: 520) .task(id: "\(composedTarget)|\(trimmedName)") { await refreshPlan() } .onChange(of: composedTarget) { _ in probes = [:] } } // MARK: Target list var targetSection: some View { VStack(alignment: .leading, spacing: 6) { Text(isEdit ? "目标服务 (内网主机与端口)" : "目标服务(每行一台主机,多个端口用逗号隔开)") .font(.system(size: 12)) .foregroundColor(.secondary) if rows.count > 1 || !isEdit { HStack(spacing: 8) { Text("主机").frame(maxWidth: .infinity, alignment: .leading) Text("端口").frame(width: 150, alignment: .leading) Color.clear.frame(width: 18, height: 1) } .font(.system(size: 11)) .foregroundColor(.secondary) } if isEdit, let row = rows.first { HStack(spacing: 8) { targetRow(row) probeButton } } else { ForEach(rows) { row in targetRow(row) } HStack(spacing: 10) { Button { addRow() } label: { Label("添加主机", systemImage: "plus") .font(.system(size: 12)) } .buttonStyle(.borderless) .help("也可以直接粘贴多个地址(每行一个或逗号分隔,可带 :端口),会自动拆成多行") Spacer() probeButton } } if isEdit { if let r = probes[composedTarget] { Text(verbatim: r.ok ? "连接成功 (\(r.detail))" : "连接失败: \(r.detail)") .font(.system(size: 11)) .foregroundColor(r.ok ? .green : .red) } } else if let err = planError { Text(verbatim: err) .font(.system(size: 11)) .foregroundColor(.red) } else if !plan.isEmpty { planList } if !isEdit, let hint = addGroupHint { Label(hint, systemImage: "square.stack.3d.up") .font(.system(size: 11)) .foregroundColor(.secondary) } } } var probeButton: some View { Button(isProbing ? "测试中" : "连通性测试") { probe() } .disabled(composedTarget.isEmpty || isProbing || (!isEdit && (plan.isEmpty || planError != nil))) .controlSize(.small) } func targetRow(_ row: TargetRow) -> some View { HStack(spacing: 8) { TextField(isEdit ? "IP 或域名" : "IP 或域名,支持粘贴多行", text: hostBinding(row.id)) .textFieldStyle(.roundedBorder) .focused($focusedRow, equals: row.id) TextField(isEdit ? "如 3306" : "如 22, 3306", text: portsBinding(row.id)) .textFieldStyle(.roundedBorder) .frame(width: 150) if !isEdit { Button { removeRow(row.id) } label: { Image(systemName: "minus.circle") .foregroundColor(.secondary) } .buttonStyle(.borderless) .frame(width: 18) .disabled(rows.count == 1) .opacity(rows.count == 1 ? 0.3 : 1) .help("删除这一行") } } } func hostBinding(_ id: UUID) -> Binding { Binding( get: { rows.first(where: { $0.id == id })?.host ?? "" }, set: { setHost(id, $0) } ) } func portsBinding(_ id: UUID) -> Binding { Binding( get: { rows.first(where: { $0.id == id })?.ports ?? "" }, set: { v in if let i = rows.firstIndex(where: { $0.id == id }) { rows[i].ports = v } } ) } /// 一次粘贴进多个主机(或 主机:端口)时拆成多行,其余情况照常输入 func setHost(_ id: UUID, _ value: String) { guard let i = rows.firstIndex(where: { $0.id == id }) else { return } let pasted = value.count - rows[i].host.count > 1 if !isEdit && pasted && (TargetRow.items(value).count > 1 || TargetRow.splitHostPort(value) != nil) { let parsed = TargetRow.parse(value, defaultPorts: rows[i].ports) if !parsed.isEmpty { rows.replaceSubrange(i...i, with: parsed) return } } rows[i].host = value } /// 新的一行沿用上一行的端口(同一组机器通常开同样的端口) func addRow() { let row = TargetRow(host: "", ports: rows.last?.ports ?? "") rows.append(row) focusedRow = row.id } func removeRow(_ id: UUID) { guard rows.count > 1 else { return } rows.removeAll { $0.id == id } } /// 每个地址分到的访问端口,以及连通性测试结果 var planList: some View { let shown = plan.prefix(8) let failed = plan.filter { probes[$0.target]?.ok == false }.count let tested = plan.filter { probes[$0.target] != nil }.count return VStack(alignment: .leading, spacing: 3) { ForEach(Array(shown)) { m in HStack(spacing: 6) { Text(verbatim: "\(vm.publicHost):\(m.listen_port)") .font(.system(size: 11, weight: .medium, design: .monospaced)) .foregroundColor(.accentColor) Image(systemName: "arrow.right") .font(.system(size: 8)) .foregroundColor(.secondary) Text(verbatim: m.target) .font(.system(size: 11, design: .monospaced)) .foregroundColor(.secondary) Spacer(minLength: 6) if let r = probes[m.target] { Label(r.ok ? r.detail : "不通", systemImage: r.ok ? "checkmark.circle.fill" : "xmark.circle.fill") .font(.system(size: 10)) .foregroundColor(r.ok ? .green : .red) .help(r.ok ? "连接成功" : "连接失败:\(r.detail)") } } } Text(verbatim: planCaption(failed: failed, tested: tested, hidden: plan.count - shown.count)) .font(.system(size: 11)) .foregroundColor(failed > 0 ? .red : .secondary) } .padding(8) .frame(maxWidth: .infinity, alignment: .leading) .background(RoundedRectangle(cornerRadius: 6).fill(Color.primary.opacity(0.04))) } func planCaption(failed: Int, tested: Int, hidden: Int) -> String { var parts: [String] = [] if hidden > 0 { parts.append("… 共 \(plan.count) 个地址") } if tested == plan.count && failed == 0 { parts.append(plan.count == 1 ? "目标连接成功" : "全部 \(plan.count) 个目标连接成功") } else if failed > 0 { parts.append("\(failed) 个目标连不上,鼠标停在 ✗ 上可查看原因") } else { parts.append(plan.count == 1 ? "访问端口自动分配" : "每个地址一个通道,访问端口自动连续分配") } return parts.joined(separator: ",") } // MARK: Sections /// 新建时的通道组说明(不需要选择) var addGroupHint: String? { if let g = fixedGroupName { return "新地址加入通道组「\(g)」,自动沿用组的邀请和授权,已授权的同事无需重新授权" } guard isBatch else { return nil } let n = trimmedName.isEmpty ? "(以上面填写的名称命名)" : "「\(trimmedName)」" return "\(plan.count) 个地址将放进通道组\(n):共用邀请和授权,同事授权一次即可访问全部地址" } var groupSection: some View { VStack(alignment: .leading, spacing: 4) { Text("通道组") .font(.system(size: 12)) .foregroundColor(.secondary) HStack(spacing: 8) { Picker("", selection: $groupChoice) { Text("不分组").tag(GroupChoice.none) Text("新建通道组").tag(GroupChoice.new) if !vm.groups.isEmpty { Divider() ForEach(vm.groups) { g in Text(verbatim: g.name).tag(GroupChoice.existing(g.id)) } } } .labelsHidden() .fixedSize() if groupChoice == .new { TextField(trimmedName.isEmpty ? "通道组名称" : "通道组名称,留空则用「\(trimmedName)」", text: $newGroupName) .textFieldStyle(.roundedBorder) } } if let hint = groupHint { Text(verbatim: hint) .font(.system(size: 11)) .foregroundColor(.secondary) } } } var groupHint: String? { switch groupChoice { case .none: return nil case .new: return "新建通道组并移入;之后可在组卡片的「编辑」里把其它通道加入" case .existing: return "组内通道共用邀请和授权,同事授权一次即可访问组内全部地址" } } /// 自动关闭:闲置关闭(主要)+ 可选的最长开启时长 var closeSection: some View { VStack(alignment: .leading, spacing: 6) { Text("闲置自动关闭(没有连接、有效授权、待审批和可用邀请时开始计时)") .font(.system(size: 12)) .foregroundColor(.secondary) HStack(spacing: 6) { ForEach(idleCloseOptions, id: \.0) { opt in DurationChip(title: opt.1, minutes: opt.0, selectedMinutes: $idleCloseMinutes) } } Text("最长开启时长(可选,到时无论是否在用都会关闭)") .font(.system(size: 12)) .foregroundColor(.secondary) .padding(.top, 2) HStack(spacing: 6) { DurationChip(title: "不限制", minutes: 0, selectedMinutes: $durationMinutes) DurationChip(title: "30 分钟", minutes: 30, selectedMinutes: $durationMinutes) DurationChip(title: "2 小时", minutes: 120, selectedMinutes: $durationMinutes) DurationChip(title: "8 小时", minutes: 480, selectedMinutes: $durationMinutes) DurationChip(title: "1 天", minutes: 1440, selectedMinutes: $durationMinutes) DurationChip(title: "7 天", minutes: 10080, selectedMinutes: $durationMinutes) CustomDurationChip(isCustom: ![0, 30, 120, 480, 1440, 10080].contains(durationMinutes), currentMinutes: durationMinutes) { durationMinutes = $0 } } } } var bandwidthSection: some View { field("单通道带宽限速 (QoS 防挤占)") { HStack(spacing: 6) { BandwidthChip(title: "不限速", mb: 0, selectedMB: $maxBandwidthMB) BandwidthChip(title: "2 MB/s", mb: 2, selectedMB: $maxBandwidthMB) BandwidthChip(title: "5 MB/s", mb: 5, selectedMB: $maxBandwidthMB) BandwidthChip(title: "10 MB/s", mb: 10, selectedMB: $maxBandwidthMB) BandwidthChip(title: "20 MB/s", mb: 20, selectedMB: $maxBandwidthMB) } } } var accessSection: some View { VStack(alignment: .leading, spacing: 6) { Text("访问控制") .font(.system(size: 12)) .foregroundColor(.secondary) Picker("", selection: $accessMode) { Text("仅授权链接(推荐)").tag(AccessMode.linkOnly) Text("固定 IP / 网段").tag(AccessMode.specific) Text("全部放通 *").tag(AccessMode.all) } .pickerStyle(.segmented) .labelsHidden() switch accessMode { case .linkOnly: Text("只有通过邀请链接授权的设备可以访问,保存后可以直接设置并生成邀请链接。") .font(.system(size: 11)) .foregroundColor(.secondary) case .specific: TextField("IP 或 CIDR 网段,逗号分隔,如 192.168.1.100, 10.0.0.0/24", text: $whitelistString) .textFieldStyle(.roundedBorder) Text("名单内的设备始终可以访问;其他人仍可通过邀请链接临时授权。") .font(.system(size: 11)) .foregroundColor(.secondary) case .all: Label("任何能访问服务器的设备都可以连接,不建议用于数据库等敏感服务。", systemImage: "exclamationmark.triangle.fill") .font(.system(size: 11)) .foregroundColor(.red) } } } func field(_ title: String, @ViewBuilder _ content: () -> Content) -> some View { VStack(alignment: .leading, spacing: 4) { Text(title) .font(.system(size: 12)) .foregroundColor(.secondary) content() } } // MARK: Actions func refreshPlan() async { guard !isEdit, !composedTarget.isEmpty else { plan = [] planError = nil return } // 输入停顿后再预览,避免每敲一个字就请求一次 try? await Task.sleep(nanoseconds: 300_000_000) guard !Task.isCancelled else { return } let (items, err) = await vm.previewTunnels(makeForm(port: 0, whitelist: [])) guard !Task.isCancelled else { return } plan = items planError = err } func probe() { let targets = isEdit ? [composedTarget] : plan.map { $0.target } isProbing = true Task { probes = await vm.probeAll(targets) isProbing = false } } /// 访问控制对应的白名单;填写有误时提示并返回 nil func whitelist() -> [String]? { switch accessMode { case .linkOnly: return [] case .all: return ["*"] case .specific: let wl = whitelistString.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty } if wl.isEmpty { errorMessage = "请填写允许访问的 IP 或网段,或选择“仅授权链接”" return nil } if wl.contains("*") { errorMessage = "如需放通全部,请选择“全部放通 *”" return nil } return wl } } func save() { var port = 0 if isEdit { guard let p = Int(portString), p > 33000 && p <= 34000 else { errorMessage = "端口须在 33001-34000 范围内" return } guard !composedTarget.contains(",") else { errorMessage = "一个通道只对应一个目标端口;要开放更多端口,请用「添加通道」" return } port = p } guard let wl = whitelist() else { return } let form = makeForm(port: port, whitelist: wl) // 加入已有组时组的授权自动覆盖新地址,不需要再发邀请 let joinsExisting = fixedGroupId != nil isSaving = true errorMessage = nil Task { let (ok, created, err) = await vm.saveTunnel(id: tunnel?.id, form: form) isSaving = false guard ok else { errorMessage = err ?? "保存失败" return } isPresented = false guard !isEdit, !joinsExisting, accessMode != .all, let created = created else { return } if let g = created.group { onCreated?(.group(g.id)) } else if let t = created.tunnels.first { onCreated?(.tunnel(t.id)) } } } } // MARK: - Tunnel Group Edit Sheet /// 添加通道弹窗;groupId 非空时默认加入该通道组 struct AddSheetTarget: Identifiable { let groupId: String? var id: String { groupId ?? "new" } } struct GroupSheetTarget: Identifiable { let group: GroupItem var id: String { group.id } } /// 编辑通道组:名称、责任人,以及把已有通道加入该组。新地址在「添加通道」里创建 struct GroupEditSheet: View { @ObservedObject var vm: PortRelayViewModel let group: GroupItem @Binding var isPresented: Bool @State private var name: String @State private var owner: String @State private var moveIDs: Set = [] @State private var errorMessage: String? = nil @State private var isSaving = false init(vm: PortRelayViewModel, group: GroupItem, isPresented: Binding) { _vm = ObservedObject(wrappedValue: vm) self.group = group _isPresented = isPresented _name = State(initialValue: group.name) _owner = State(initialValue: group.owner ?? "") } /// 可以加入该组的已有通道(不属于任何组) var candidates: [TunnelItem] { vm.tunnels.filter { ($0.group_id ?? "").isEmpty } } var body: some View { VStack(alignment: .leading, spacing: 14) { VStack(alignment: .leading, spacing: 2) { Text("编辑通道组") .font(.system(size: 15, weight: .bold)) Text("组内通道共用邀请与授权:同事授权一次即可访问整组,组内任一地址在用时整组都不会闲置关闭") .font(.system(size: 11)) .foregroundColor(.secondary) } HStack(spacing: 12) { VStack(alignment: .leading, spacing: 4) { Text("通道组名称") .font(.system(size: 12)) .foregroundColor(.secondary) TextField("如:订单服务集群", text: $name) .textFieldStyle(.roundedBorder) } VStack(alignment: .leading, spacing: 4) { Text("关联责任人") .font(.system(size: 12)) .foregroundColor(.secondary) TextField("负责人或团队,如:运维组", text: $owner) .textFieldStyle(.roundedBorder) } } if !candidates.isEmpty { VStack(alignment: .leading, spacing: 6) { Text("把已有通道加入该组(可选)") .font(.system(size: 12)) .foregroundColor(.secondary) ScrollView { VStack(alignment: .leading, spacing: 4) { ForEach(candidates) { t in Toggle(isOn: Binding( get: { moveIDs.contains(t.id) }, set: { on in if on { moveIDs.insert(t.id) } else { moveIDs.remove(t.id) } } )) { HStack(spacing: 6) { Text(verbatim: t.name) .font(.system(size: 12)) .lineLimit(1) Text(verbatim: ":\(t.listen_port) → \(t.target)") .font(.system(size: 11, design: .monospaced)) .foregroundColor(.secondary) .lineLimit(1) } } .toggleStyle(.checkbox) } } .frame(maxWidth: .infinity, alignment: .leading) } .frame(maxHeight: min(CGFloat(candidates.count) * 22 + 4, 132)) } } Text("要开放新的地址,请在通道组卡片上点「添加地址」") .font(.system(size: 11)) .foregroundColor(.secondary) if let err = errorMessage { Text(verbatim: err) .font(.system(size: 12)) .foregroundColor(.red) } HStack { Spacer() Button("取消") { isPresented = false } .controlSize(.small) Button(isSaving ? "保存中…" : "保存") { save() } .buttonStyle(.borderedProminent) .controlSize(.small) .disabled(name.trimmingCharacters(in: .whitespaces).isEmpty || isSaving) } .padding(.top, 2) } .padding(20) .frame(width: 520) } func save() { isSaving = true errorMessage = nil Task { let err = await vm.saveGroup(id: group.id, name: name.trimmingCharacters(in: .whitespaces), owner: owner.trimmingCharacters(in: .whitespaces), tunnelIds: Array(moveIDs)) isSaving = false if let err = err { errorMessage = err return } isPresented = false } } } struct InviteSheetTarget: Identifiable { let scope: AccessScope let autoGenerate: Bool var id: String { scope.key } init(scope: AccessScope, autoGenerate: Bool) { self.scope = scope self.autoGenerate = autoGenerate } init(tunnel: TunnelItem, autoGenerate: Bool) { self.init(scope: .tunnel(tunnel.id), autoGenerate: autoGenerate) } } /// 生成邀请链接并展示分享卡片(二维码 + 可直接发送的文案) struct InviteSheet: View { @ObservedObject var vm: PortRelayViewModel let scope: AccessScope let autoGenerate: Bool @Binding var isPresented: Bool init(vm: PortRelayViewModel, scope: AccessScope, autoGenerate: Bool, isPresented: Binding) { _vm = ObservedObject(wrappedValue: vm) self.scope = scope self.autoGenerate = autoGenerate _isPresented = isPresented } init(vm: PortRelayViewModel, tunnel: TunnelItem, autoGenerate: Bool, isPresented: Binding) { self.init(vm: vm, scope: .tunnel(tunnel.id), autoGenerate: autoGenerate, isPresented: isPresented) } var title: String { let name = vm.scopeTitle(scope) return scope.isGroup ? "邀请访问「\(name)」(\(vm.scopeTunnels(scope).filter { $0.enabled }.count) 个地址)" : "邀请访问「\(name)」" } @State private var note: String = "" @State private var validMinutes: Int = 24 * 60 @State private var maxUses: Int = 1 @State private var leaseDuration: String = "2h" @State private var requireApproval: Bool = false @State private var result: InviteShareResp? = nil @State private var errorMessage: String? = nil @State private var isWorking = false @State private var copied: String? = nil let validOptions: [(Int, String)] = [(60, "1 小时"), (24 * 60, "24 小时"), (7 * 24 * 60, "7 天")] let useOptions: [(Int, String)] = [(1, "1 台"), (3, "3 台"), (0, "不限")] var body: some View { VStack(alignment: .leading, spacing: 14) { HStack { VStack(alignment: .leading, spacing: 2) { Text(verbatim: title) .font(.system(size: 15, weight: .bold)) .lineLimit(1) .truncationMode(.middle) Text(verbatim: result == nil ? (scope.isGroup ? "同事授权一次即可访问组内全部地址" : "同事打开链接并填写姓名后获得访问权限") : "授权后可访问 \(leaseDurationTitle(result?.invite.lease_duration))" + (result?.invite.require_approval == true ? ",需你审批" : ",无需审批")) .font(.system(size: 12)) .foregroundColor(.secondary) } Spacer() Button("完成") { isPresented = false } .buttonStyle(.borderedProminent) .controlSize(.small) } if let r = result { HStack(alignment: .top, spacing: 16) { if let qr = makeQRCode(r.link) { VStack(spacing: 4) { Image(nsImage: qr) .interpolation(.none) .resizable() .frame(width: 150, height: 150) .background(Color.white) .cornerRadius(6) Text("手机扫码打开") .font(.system(size: 10)) .foregroundColor(.secondary) } } VStack(alignment: .leading, spacing: 8) { ScrollView { Text(verbatim: r.share_text) .font(.system(size: 12)) .textSelection(.enabled) .frame(maxWidth: .infinity, alignment: .leading) } .frame(height: scope.isGroup ? 170 : 130) .padding(8) .background(Color(NSColor.textBackgroundColor)) .cornerRadius(6) .overlay(RoundedRectangle(cornerRadius: 6).strokeBorder(Color(NSColor.separatorColor), lineWidth: 1)) HStack { Button(copied == "text" ? "已复制" : "复制分享文案") { copyToPasteboard(r.share_text) copied = "text" } .buttonStyle(.borderedProminent) .controlSize(.small) Button(copied == "link" ? "已复制" : "仅复制链接") { copyToPasteboard(r.link) copied = "link" } .controlSize(.small) Spacer() Button("再生成一个") { result = nil copied = nil } .buttonStyle(.link) .font(.system(size: 11)) } } } } else { VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: 4) { Text("备注(发给谁,可选)") .font(.system(size: 12)) .foregroundColor(.secondary) TextField("如:张三", text: $note) .textFieldStyle(.roundedBorder) } HStack(spacing: 6) { Text("链接有效期") .font(.system(size: 12)) .foregroundColor(.secondary) .frame(width: 84, alignment: .leading) ForEach(validOptions, id: \.0) { opt in DurationChip(title: opt.1, minutes: opt.0, selectedMinutes: $validMinutes) } } HStack(spacing: 6) { Text("可用设备数") .font(.system(size: 12)) .foregroundColor(.secondary) .frame(width: 84, alignment: .leading) ForEach(useOptions, id: \.0) { opt in DurationChip(title: opt.1, minutes: opt.0, selectedMinutes: $maxUses) } } HStack(spacing: 6) { Text("授权后可访问") .font(.system(size: 12)) .foregroundColor(.secondary) .frame(width: 84, alignment: .leading) ForEach(leaseDurationOptions, id: \.value) { opt in StringChip(title: opt.title, value: opt.value, selection: $leaseDuration) } CustomDurationChip(isCustom: !leaseDurationOptions.contains { $0.value == leaseDuration }, currentMinutes: customLeaseMinutes(leaseDuration)) { leaseDuration = "\($0)m" } } HStack(spacing: 6) { Text("审批") .font(.system(size: 12)) .foregroundColor(.secondary) .frame(width: 84, alignment: .leading) Toggle("同事打开链接后需要我批准才放通", isOn: $requireApproval) .font(.system(size: 12)) } Text("“链接有效期”是同事必须在多久内打开链接;“授权后可访问”是打开并授权后能使用多久。链接用满设备数后自动失效,也可随时在“授权管理”中作废。") .fixedSize(horizontal: false, vertical: true) .font(.system(size: 11)) .foregroundColor(.secondary) HStack { Spacer() Button(isWorking ? "生成中…" : "生成邀请链接") { generate() } .buttonStyle(.borderedProminent) .controlSize(.small) .disabled(isWorking) } } } if let err = errorMessage { Text(err) .font(.system(size: 12)) .foregroundColor(.red) } } .padding(20) .frame(width: 600) .onAppear { if autoGenerate { generate() } } } func generate() { isWorking = true errorMessage = nil Task { let (resp, err) = await vm.createInvite(scope: scope, note: note.trimmingCharacters(in: .whitespaces), validMinutes: validMinutes, maxUses: maxUses, leaseDuration: leaseDuration, requireApproval: requireApproval) result = resp errorMessage = err isWorking = false } } } struct DurationChip: View { let title: String let minutes: Int @Binding var selectedMinutes: Int var isSelected: Bool { selectedMinutes == minutes } var body: some View { Button(action: { selectedMinutes = minutes }) { ChipLabel(title: title, selected: isSelected) } .buttonStyle(.borderless) } } struct BandwidthChip: View { let title: String let mb: Double @Binding var selectedMB: Double var isSelected: Bool { abs(selectedMB - mb) < 0.01 } var body: some View { Button(action: { selectedMB = mb }) { ChipLabel(title: title, selected: isSelected, tint: .purple) } .buttonStyle(.borderless) } } // MARK: - Settings Sheet struct SettingsSheet: View { @ObservedObject var vm: PortRelayViewModel @Binding var isPresented: Bool @State private var serverAddr: String = "" @State private var token: String = "" @State private var publicHost: String = "" @State private var errorMessage: String? = nil var body: some View { VStack(alignment: .leading, spacing: 14) { Text("设置") .font(.system(size: 15, weight: .bold)) VStack(alignment: .leading, spacing: 4) { Text("服务器控制地址 (地址:端口)") .font(.system(size: 12)) .foregroundColor(.secondary) TextField("地址:端口,如 gw.example.com:33000", text: $serverAddr) .textFieldStyle(.roundedBorder) } VStack(alignment: .leading, spacing: 4) { Text("安全令牌") .font(.system(size: 12)) .foregroundColor(.secondary) SecureField("与服务器一致的令牌", text: $token) .textFieldStyle(.roundedBorder) } VStack(alignment: .leading, spacing: 4) { Text("对外访问地址 (访问者连接通道时使用)") .font(.system(size: 12)) .foregroundColor(.secondary) TextField("留空则使用服务器地址", text: $publicHost) .textFieldStyle(.roundedBorder) } if let err = errorMessage { Text(err) .font(.system(size: 12)) .foregroundColor(.red) } HStack { Spacer() Button("取消") { isPresented = false } .controlSize(.small) Button("保存并重连") { Task { if let err = await vm.saveSettings(serverAddr: serverAddr, token: token, publicHost: publicHost) { errorMessage = err } else { isPresented = false } } } .buttonStyle(.borderedProminent) .controlSize(.small) } .padding(.top, 6) } .padding(20) .frame(width: 400) .onAppear { Task { let (addr, t, host) = await vm.fetchSettings() serverAddr = addr token = t publicHost = host } } } } // MARK: - App Delegate & Menu Bar Extra @MainActor class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { var statusItem: NSStatusItem? private var mainWindow: NSWindow? func applicationDidFinishLaunching(_ notification: Notification) { PortRelayViewModel.shared.appDelegate = self setupStatusItem() // Hook window close button so it hides to menu bar instead of quitting DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { [weak self] in if let window = NSApp.windows.first(where: { $0.title == "PortRelay" || $0.canBecomeMain }) { self?.mainWindow = window window.delegate = self } } } /// 关闭窗口:隐藏到菜单栏,同时从 Dock 移除图标 func windowShouldClose(_ sender: NSWindow) -> Bool { sender.orderOut(nil) NSApp.setActivationPolicy(.accessory) return false } /// 彻底退出:先结束后台引擎再退出 App func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { Task { @MainActor in await PortRelayViewModel.shared.shutdownAgent() sender.reply(toApplicationShouldTerminate: true) } return .terminateLater } func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { if !flag { showMainWindow() } return true } private func setupStatusItem() { statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) if let button = statusItem?.button { button.image = NSImage(systemSymbolName: "arrow.left.arrow.right.circle", accessibilityDescription: "PortRelay") button.imagePosition = .imageLeading button.title = " 0" } rebuildMenu() } func updateStatusItemTitle(activeCount: Int) { guard let button = self.statusItem?.button else { return } let isConnected = PortRelayViewModel.shared.isConnected let pendingCount = PortRelayViewModel.shared.attentionCount var iconName = isConnected ? (activeCount > 0 ? "arrow.left.arrow.right.circle.fill" : "arrow.left.arrow.right.circle") : "exclamationmark.triangle" if pendingCount > 0 { iconName = "bell.badge.fill" } button.image = NSImage(systemSymbolName: iconName, accessibilityDescription: "PortRelay") button.title = pendingCount > 0 ? " 待审批 \(pendingCount)" : " \(activeCount)" if PortRelayViewModel.shared.menuNeedsRebuild() { self.rebuildMenu() } } func rebuildMenu() { let menu = NSMenu() let vm = PortRelayViewModel.shared // Status Title let header = NSMenuItem(title: "PortRelay 端口中继", action: nil, keyEquivalent: "") header.isEnabled = false menu.addItem(header) let connStatus = vm.isConnected ? "🟢 服务器连接正常" : "🔴 服务器未连接 / 重试中" let statusItem = NSMenuItem(title: connStatus, action: nil, keyEquivalent: "") statusItem.isEnabled = false menu.addItem(statusItem) menu.addItem(NSMenuItem.separator()) // Renewal requests: pick how long to extend let renewals = vm.renewalRequests if !renewals.isEmpty { let header = NSMenuItem(title: "申请延长 (\(renewals.count)):", action: nil, keyEquivalent: "") header.isEnabled = false menu.addItem(header) for (title, scope, rn) in renewals { guard let r = rn.request else { continue } let item = NSMenuItem(title: "\(r.name)(\(r.ip))→ \(title)", action: nil, keyEquivalent: "") let sub = NSMenu() for opt in extendOptions { let ext = NSMenuItem(title: "批准延长 \(opt.1)", action: #selector(extendAction(_:)), keyEquivalent: "") ext.representedObject = [scope.key, r.ip, String(opt.0)] ext.target = self sub.addItem(ext) } sub.addItem(NSMenuItem.separator()) let reject = NSMenuItem(title: "拒绝", action: #selector(decideAction(_:)), keyEquivalent: "") reject.representedObject = [scope.key, r.id, "0"] reject.target = self sub.addItem(reject) item.submenu = sub menu.addItem(item) } menu.addItem(NSMenuItem.separator()) } // Pending approvals let pending = vm.pendingRequests if !pending.isEmpty { let title = NSMenuItem(title: "待审批的访问申请 (\(pending.count)):", action: nil, keyEquivalent: "") title.isEnabled = false menu.addItem(title) for (title, scope, r) in pending { let item = NSMenuItem(title: "\(r.name)(\(r.ip))→ \(title)", action: nil, keyEquivalent: "") let sub = NSMenu() let approve = NSMenuItem(title: "批准", action: #selector(decideAction(_:)), keyEquivalent: "") approve.representedObject = [scope.key, r.id, "1"] approve.target = self let reject = NSMenuItem(title: "拒绝", action: #selector(decideAction(_:)), keyEquivalent: "") reject.representedObject = [scope.key, r.id, "0"] reject.target = self sub.addItem(approve) sub.addItem(reject) item.submenu = sub menu.addItem(item) } menu.addItem(NSMenuItem.separator()) } // Tunnels section if vm.tunnels.isEmpty { let emptyItem = NSMenuItem(title: "暂无通道", action: nil, keyEquivalent: "") emptyItem.isEnabled = false menu.addItem(emptyItem) } else { let tunnelSection = NSMenuItem(title: "中继通道列表 (\(vm.tunnels.filter { $0.enabled }.count)/\(vm.tunnels.count) 开启):", action: nil, keyEquivalent: "") tunnelSection.isEnabled = false menu.addItem(tunnelSection) // 通道组占一行(出现在它第一个成员的位置),子菜单里整组开关、逐个地址开关 var seenGroups = Set() for t in vm.tunnels { if let g = vm.group(t.group_id) { if seenGroups.insert(g.id).inserted { menu.addItem(groupMenuItem(g, members: vm.members(of: g))) } } else { menu.addItem(tunnelMenuItem(t, title: "\(t.name) (:\(t.listen_port))")) } } } menu.addItem(NSMenuItem.separator()) // Quick Actions let showItem = NSMenuItem(title: "打开主控制台", action: #selector(showMainWindowAction), keyEquivalent: "o") showItem.target = self menu.addItem(showItem) let reconnectItem = NSMenuItem(title: "立即重连网关", action: #selector(reconnectAction), keyEquivalent: "r") reconnectItem.target = self menu.addItem(reconnectItem) menu.addItem(NSMenuItem.separator()) let quitItem = NSMenuItem(title: "彻底退出 PortRelay (含后台引擎)", action: #selector(quitAction), keyEquivalent: "q") quitItem.target = self menu.addItem(quitItem) self.statusItem?.menu = menu } func tunnelMenuItem(_ t: TunnelItem, title: String) -> NSMenuItem { let item = NSMenuItem(title: (t.enabled ? "✓ " : " ") + title, action: #selector(toggleTunnelAction(_:)), keyEquivalent: "") item.representedObject = t.id item.target = self return item } func groupMenuItem(_ g: GroupItem, members: [TunnelItem]) -> NSMenuItem { let on = members.filter { $0.enabled }.count let state = on > 0 && on < members.count ? ",\(on) 个开启" : "" let item = NSMenuItem(title: (on > 0 ? "✓ " : " ") + "\(g.name)(\(members.count) 个地址\(state))", action: nil, keyEquivalent: "") let sub = NSMenu() let toggle = NSMenuItem(title: on > 0 ? "关闭整组" : "开启整组", action: #selector(toggleGroupAction(_:)), keyEquivalent: "") toggle.representedObject = [g.id, on > 0 ? "off" : "on"] toggle.target = self sub.addItem(toggle) sub.addItem(NSMenuItem.separator()) for t in members { sub.addItem(tunnelMenuItem(t, title: ":\(t.listen_port) → \(t.target)")) } item.submenu = sub return item } @objc func toggleGroupAction(_ sender: NSMenuItem) { guard let v = sender.representedObject as? [String], v.count == 2 else { return } Task { await PortRelayViewModel.shared.toggleGroup(id: v[0], enabled: v[1] == "on") } } @objc func toggleTunnelAction(_ sender: NSMenuItem) { guard let id = sender.representedObject as? String else { return } Task { await PortRelayViewModel.shared.toggleTunnel(id: id) } } @objc func extendAction(_ sender: NSMenuItem) { guard let v = sender.representedObject as? [String], v.count == 3, let minutes = Int(v[2]) else { return } let id = String(v[0].dropFirst(2)) let scope: AccessScope = v[0].hasPrefix("g:") ? .group(id) : .tunnel(id) Task { if let err = await PortRelayViewModel.shared.extendLease(scope: scope, ip: v[1], minutes: minutes) { let content = UNMutableNotificationContent() content.title = "延长失败" content.body = err try? await UNUserNotificationCenter.current().add(UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)) } } } @objc func decideAction(_ sender: NSMenuItem) { guard let v = sender.representedObject as? [String], v.count == 3 else { return } let id = String(v[0].dropFirst(2)) let scope: AccessScope = v[0].hasPrefix("g:") ? .group(id) : .tunnel(id) Task { _ = await PortRelayViewModel.shared.decideRequest(scope: scope, requestId: v[1], approve: v[2] == "1") } } @objc func showMainWindowAction() { showMainWindow() } @objc func reconnectAction() { Task { await PortRelayViewModel.shared.triggerReconnect(force: true) } } @objc func quitAction() { NSApp.terminate(nil) } func showMainWindow() { // 打开主窗口时恢复 Dock 图标 NSApp.setActivationPolicy(.regular) NSApp.activate(ignoringOtherApps: true) if let window = mainWindow { window.makeKeyAndOrderFront(nil) } else if let window = NSApp.windows.first(where: { $0.title == "PortRelay" || $0.canBecomeMain }) { mainWindow = window window.delegate = self window.makeKeyAndOrderFront(nil) } } } // MARK: - App Main @main struct PortRelayApp: App { @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate var body: some Scene { WindowGroup("PortRelay") { ContentView() } .windowStyle(.titleBar) .defaultSize(width: 920, height: 640) } }