feat: 授权延长——同事一键申请,管理员批准时决定时长;时长支持 30 分钟与自定义
同事端 - 用过邀请链接的设备再次打开链接时显示自己的授权状态:有效时可申请延长, 访问结束(到期或通道关闭)后 2 小时内也可以;超过 2 小时或被撤销则提示重新获取邀请 - 「申请延长」只有一个按钮,提交后复用审批状态页,批准后显示新的到期时间 - 用过链接的设备不再自动重新放通,统一走申请延长 网关 - 授权结束后保留 2 小时的记录(通道关闭后仍保留,随状态文件持久化);撤销的不能延长, 删除通道 / 解散组时一并清除(unregister 带 deleted 标记) - 新增 extend_lease:有效授权从原到期时间顺延,已结束的从现在起算,不受通道最长开启时长截断, 并批准该设备的延长申请;指标里带上全部可延长设备与申请(包括已关闭的通道) - 授权时长支持分钟数("30m"、"90m",5 分钟到 30 天) Agent / App / 网页面板 - POST .../leases/extend:通道已关闭时先自动开启(组则全组开启),并让通道至少开到授权结束 - 授权管理新增「申请延长」「最近结束 · 2 小时内可延长」,已授权设备可直接「延长」; 卡片、菜单栏和系统通知提示延长申请,菜单栏可直接选时长批准 - 邀请的可访问时长、通道最长开启时长增加 30 分钟与自定义;网页面板弹窗层级高于侧边抽屉 - 邀请分享文案提示「授权结束后 2 小时内可重新打开此链接申请延长」
This commit is contained in:
1 parent
9a675ba96e
commit
dceb59c0ca
16 files changed
+1707
-75
No files matched your search
+368
-10
@@ -34,6 +34,8 @@ struct TunnelItem: Identifiable, Codable {
|
||||
var rate_history: RateSeriesItem?
|
||||
/// 所属通道组
|
||||
var group_id: String?
|
||||
/// 可延长的设备与延长申请(通道关闭后 2 小时内仍有)
|
||||
var renewals: [RenewalItem]?
|
||||
}
|
||||
|
||||
/// 近 10 分钟流量曲线:固定 120 个点(5 秒一个),最后一点为当前
|
||||
@@ -113,6 +115,24 @@ struct AccessRequestItem: Identifiable, Codable {
|
||||
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?
|
||||
}
|
||||
|
||||
/// 通道组:组内通道共用邀请、授权与审批,闲置计时联动
|
||||
@@ -125,6 +145,7 @@ struct GroupItem: Identifiable, Codable {
|
||||
var tunnel_ids: [String]
|
||||
var metrics: GroupMetricsItem?
|
||||
var rate_history: RateSeriesItem?
|
||||
var renewals: [RenewalItem]?
|
||||
}
|
||||
|
||||
struct GroupMetricsItem: Codable {
|
||||
@@ -400,11 +421,34 @@ func makeQRCode(_ text: String, size: CGFloat = 180) -> NSImage? {
|
||||
}
|
||||
|
||||
let leaseDurationOptions: [(value: String, title: String)] = [
|
||||
("1h", "1 小时"), ("2h", "2 小时"), ("8h", "8 小时"), ("today", "当天有效"), ("tunnel", "通道开启期间")
|
||||
("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 {
|
||||
leaseDurationOptions.first { $0.value == (v ?? "2h") }?.title ?? "2 小时"
|
||||
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) {
|
||||
@@ -661,6 +705,136 @@ struct StringChip: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// 自定义时长输入:数字 + 分钟 / 小时
|
||||
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
|
||||
|
||||
/// 通道搜索:多个关键字(空格分隔)需全部命中;不区分大小写;中文支持全拼与拼音首字母
|
||||
@@ -779,6 +953,14 @@ func formatDateTimeShort(_ str: String?) -> String {
|
||||
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
|
||||
@@ -830,6 +1012,21 @@ class PortRelayViewModel: ObservableObject {
|
||||
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] {
|
||||
@@ -874,6 +1071,13 @@ class PortRelayViewModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
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 ?? []
|
||||
@@ -1046,6 +1250,15 @@ class PortRelayViewModel: ObservableObject {
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
/// 菜单内容有变化时才需要重建(避免每秒重建打断已展开的菜单)
|
||||
@@ -1053,7 +1266,8 @@ class PortRelayViewModel: ObservableObject {
|
||||
// 分步拼接:一长串字符串相加会让旧版编译器类型推断超时
|
||||
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 pendingSig: String = pendingRequests.map { $0.request.id }.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
|
||||
@@ -1376,6 +1590,18 @@ class PortRelayViewModel: ObservableObject {
|
||||
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 "无效地址" }
|
||||
@@ -2267,6 +2493,13 @@ struct TunnelCardView: View {
|
||||
.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) {
|
||||
@@ -2342,6 +2575,7 @@ struct GroupCardView: View {
|
||||
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) }
|
||||
@@ -2617,6 +2851,13 @@ struct GroupCardView: View {
|
||||
.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)
|
||||
@@ -2825,6 +3066,11 @@ struct ClientDetailsSheet: View {
|
||||
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 {
|
||||
@@ -2929,7 +3175,7 @@ struct ClientDetailsSheet: View {
|
||||
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))").tag(2)
|
||||
Text(verbatim: "授权管理 (\(leases.count + pendingRequests.count + renewalAsks.count))").tag(2)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.padding(.vertical, 4)
|
||||
@@ -3434,6 +3680,13 @@ struct ClientDetailsSheet: View {
|
||||
|
||||
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
|
||||
@@ -3510,6 +3763,9 @@ struct ClientDetailsSheet: View {
|
||||
}
|
||||
.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)
|
||||
@@ -3520,6 +3776,13 @@ struct ClientDetailsSheet: View {
|
||||
.rowCard()
|
||||
}
|
||||
|
||||
if !recentlyEnded.isEmpty {
|
||||
sectionHeader("最近结束 · 2 小时内可延长", recentlyEnded.count)
|
||||
ForEach(recentlyEnded) { rn in
|
||||
renewalRow(rn)
|
||||
}
|
||||
}
|
||||
|
||||
sectionHeader("有效的邀请链接", invites.count)
|
||||
if invites.isEmpty {
|
||||
emptyHint("暂无有效的邀请链接")
|
||||
@@ -3583,6 +3846,59 @@ struct ClientDetailsSheet: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// 申请延长 / 最近结束的设备:选时长即延长(同时批准申请)
|
||||
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 {
|
||||
@@ -4220,14 +4536,13 @@ struct TunnelEditSheet: View {
|
||||
.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)
|
||||
// 兼容旧配置中的其他时长
|
||||
if ![0, 120, 480, 1440, 10080].contains(durationMinutes) {
|
||||
DurationChip(title: formatSecondsToDuration(Int64(durationMinutes) * 60), minutes: durationMinutes, selectedMinutes: $durationMinutes)
|
||||
}
|
||||
CustomDurationChip(isCustom: ![0, 30, 120, 480, 1440, 10080].contains(durationMinutes),
|
||||
currentMinutes: durationMinutes) { durationMinutes = $0 }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4661,6 +4976,8 @@ struct InviteSheet: View {
|
||||
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("审批")
|
||||
@@ -4691,7 +5008,7 @@ struct InviteSheet: View {
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
.frame(width: 560)
|
||||
.frame(width: 600)
|
||||
.onAppear {
|
||||
if autoGenerate { generate() }
|
||||
}
|
||||
@@ -4874,7 +5191,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
|
||||
func updateStatusItemTitle(activeCount: Int) {
|
||||
guard let button = self.statusItem?.button else { return }
|
||||
let isConnected = PortRelayViewModel.shared.isConnected
|
||||
let pendingCount = PortRelayViewModel.shared.pendingRequests.count
|
||||
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")
|
||||
@@ -4900,6 +5217,33 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
|
||||
|
||||
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 {
|
||||
@@ -5004,6 +5348,20 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
@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))
|
||||
|
||||
Reference in new issue
Block a user