From 9a675ba96ede04de2dbc9239dd7d191a4af77c91 Mon Sep 17 00:00:00 2001 From: DeepFal <41166795+DeepFal@users.noreply.github.com> Date: Tue, 13 Oct 2026 21:31:22 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=BB=BA=E6=97=B6=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E9=80=89=E6=8B=A9=E9=80=9A=E9=81=93=E7=BB=84=EF=BC=8C?= =?UTF-8?q?=E3=80=8C=E6=B7=BB=E5=8A=A0=E5=9C=B0=E5=9D=80=E3=80=8D=E5=9B=BA?= =?UTF-8?q?=E5=AE=9A=E5=8A=A0=E5=85=A5=E6=89=80=E5=9C=A8=E7=BB=84=EF=BC=9B?= =?UTF-8?q?=E5=8D=A0=E4=BD=8D=E6=8F=90=E7=A4=BA=E7=BB=9F=E4=B8=80=E6=8E=AA?= =?UTF-8?q?=E8=BE=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 「添加通道」去掉通道组下拉:一个地址为独立通道,多个地址自动放进以名称命名的新组, 只在目标列表下方说明结果 - 组卡片「添加地址」标题为「添加地址到「组名」」,固定加入该组,名称可选(留空按「组名 (主机)」命名), 按钮为「添加 / 添加 N 个地址」,不再自动弹出邀请 - 通道组选择只保留在编辑已有通道时(不分组 / 新建通道组 / 已有组) - 占位提示统一为「如:…」并说明格式:IP 或域名、IP 或 CIDR 网段、负责人或团队等 - App 与网页面板一致 --- cmd/portrelay-agent/web/index.html | 98 ++++++++++++++----------- mac-native/PortRelayApp.swift | 114 +++++++++++++++++------------ 2 files changed, 123 insertions(+), 89 deletions(-) diff --git a/cmd/portrelay-agent/web/index.html b/cmd/portrelay-agent/web/index.html index 71d5f5f..6e226f0 100644 --- a/cmd/portrelay-agent/web/index.html +++ b/cmd/portrelay-agent/web/index.html @@ -452,12 +452,12 @@
- - + +
- +
@@ -465,11 +465,12 @@
主机端口
- +
+
@@ -478,7 +479,7 @@
-
+
@@ -523,7 +524,7 @@ - +
@@ -545,7 +546,7 @@
- +
@@ -607,11 +608,11 @@
组内地址共用邀请与授权:同事授权一次即可访问整组;组内任一地址在用时,整组都不会闲置关闭。
- +
- +
@@ -652,7 +653,7 @@
- +
边缘网关的地址与控制端口。局域网、VPN、虚拟组网或公网地址均可,只要本机能连通
@@ -1076,13 +1077,18 @@ } // 新建代理通道;groupId 非空时默认加入该通道组(通道组卡片上的「添加地址」) + // 组卡片「添加地址」打开时固定加入的通道组;新建时不需要选择通道组 + let fixedGroupId = null; + function openCreateModal(groupId) { - document.getElementById('modalTitle').innerText = '新建代理通道'; + fixedGroupId = groupId || null; + const g = currentGroups.find(x => x.id === fixedGroupId); + document.getElementById('modalTitle').innerText = fixedGroupId ? `添加地址到「${g ? g.name : '通道组'}」` : '新建代理通道'; document.getElementById('tunnelId').value = ''; document.getElementById('tunnelName').value = ''; document.getElementById('tunnelOwner').value = ''; document.getElementById('tunnelTargetLabel').innerText = '目标服务(每行一台主机,多个端口用逗号隔开)'; - fillTunnelGroupSelect(groupId || ''); + fillTunnelGroupSelect(''); setAccessForm([]); setLifecycleForm(30, 0, 0); resetPlan(); @@ -1094,6 +1100,7 @@ function editTunnel(id) { const t = currentTunnels.find(item => item.id === id); if (!t) return; + fixedGroupId = null; document.getElementById('modalTitle').innerText = '编辑代理通道'; document.getElementById('tunnelId').value = t.id; document.getElementById('tunnelName').value = t.name; @@ -1172,8 +1179,8 @@ const edit = isEditing(); document.getElementById('targetRows').innerHTML = rows.map(r => `
- - + + ${edit ? '' : ``}
`).join(''); document.getElementById('addRowBtn').style.display = edit ? 'none' : ''; @@ -1262,11 +1269,15 @@ // 通道组选项:'' 不分组,'__new__' 新建,其余为已有组 ID function groupFields() { + const name = document.getElementById('tunnelName').value.trim(); + if (!isEditing()) { + // 新建:「添加地址」固定加入该组;多个地址自动新建以名称命名的组;一个地址不分组 + if (fixedGroupId) return { group_id: fixedGroupId, new_group: '' }; + return { group_id: '', new_group: isBatch() ? name : '' }; + } const sel = document.getElementById('tunnelGroup').value; if (sel !== '__new__') return { group_id: sel, new_group: '' }; - const name = document.getElementById('tunnelName').value.trim(); - const custom = isEditing() ? document.getElementById('tunnelNewGroup').value.trim() : ''; - return { group_id: '', new_group: custom || name }; + return { group_id: '', new_group: document.getElementById('tunnelNewGroup').value.trim() || name }; } function tunnelPayload(port, whitelist) { @@ -1312,16 +1323,11 @@ function renderPlan() { const batch = isBatch(), edit = isEditing(); document.getElementById('tunnelPortWrap').style.display = edit ? 'block' : 'none'; - // 多个地址必须放进通道组;新建时只有一个地址就不需要新建组 - const sel = document.getElementById('tunnelGroup'); - const none = sel.querySelector('option[value=""]'), fresh = sel.querySelector('option[value="__new__"]'); - if (none) none.disabled = none.hidden = batch; - if (fresh) fresh.disabled = fresh.hidden = !batch && !edit; - if (batch && sel.value === '') sel.value = '__new__'; - if (!batch && !edit && sel.value === '__new__') sel.value = ''; + document.getElementById('tunnelGroupWrap').style.display = edit ? 'block' : 'none'; updateGroupHint(); - document.getElementById('tunnelSaveBtn').innerText = batch ? `创建 ${tunnelPlan.length} 个通道` : '保存通道'; - document.getElementById('tunnelNameLabel').innerText = batch && sel.value === '__new__' ? '通道组名称' : '通道名称 / 备注'; + const n = tunnelPlan.length; + document.getElementById('tunnelSaveBtn').innerText = fixedGroupId ? (batch ? `添加 ${n} 个地址` : '添加') + : (batch ? `创建 ${n} 个通道` : '保存通道'); const box = document.getElementById('tunnelPlan'); if (tunnelPlanError) { @@ -1358,22 +1364,29 @@ } function updateGroupHint() { - const sel = document.getElementById('tunnelGroup').value; const name = document.getElementById('tunnelName').value.trim(); - const newInput = document.getElementById('tunnelNewGroup'); - newInput.style.display = sel === '__new__' && isEditing() ? 'block' : 'none'; - newInput.placeholder = name ? `组名,默认「${name}」` : '通道组名称'; - let hint = ''; - if (sel === '__new__' && isBatch()) { - hint = `${tunnelPlan.length} 个地址会放进通道组${name ? `「${name}」` : '(填写上面的名称)'}:共用邀请和授权,同事授权一次即可访问全部地址`; - } else if (sel) { - hint = '组内通道共用邀请和授权,同事授权一次即可访问组内全部地址'; + const nameInput = document.getElementById('tunnelName'); + const label = document.getElementById('tunnelNameLabel'); + if (!isEditing()) { + // 新建:不需要选择通道组,只说明结果 + const g = fixedGroupId ? ((currentGroups.find(x => x.id === fixedGroupId) || {}).name || '通道组') : null; + let hint = ''; + if (g) hint = `新地址加入通道组「${g}」,自动沿用组的邀请和授权,已授权的同事无需重新授权`; + else if (isBatch()) hint = `${tunnelPlan.length} 个地址将放进通道组${name ? `「${name}」` : '(以上面填写的名称命名)'}:共用邀请和授权,同事授权一次即可访问全部地址`; + document.getElementById('tunnelAddHint').innerText = hint; + label.innerText = g ? '通道名称(可选)' : (isBatch() ? '通道组名称' : '通道名称'); + nameInput.placeholder = g ? `留空则命名为「${g} (主机)」` : (isBatch() ? '如:订单服务集群' : '如:订单数据库'); + return; } - document.getElementById('tunnelGroupHint').innerText = hint; - // 加入已有组时名称可以不填,按组名命名 - const g = (currentGroups.find(x => x.id === sel) || {}).name; - document.getElementById('tunnelName').placeholder = !isEditing() && g ? `可不填,默认「${g} (主机)」` - : (isBatch() ? '例如:订单服务' : '例如:MySQL 实习库 或 财务系统 SSH'); + document.getElementById('tunnelAddHint').innerText = ''; + label.innerText = '通道名称'; + nameInput.placeholder = '如:订单数据库'; + const sel = document.getElementById('tunnelGroup').value; + const newInput = document.getElementById('tunnelNewGroup'); + newInput.style.display = sel === '__new__' ? 'block' : 'none'; + newInput.placeholder = name ? `通道组名称,留空则用「${name}」` : '通道组名称'; + document.getElementById('tunnelGroupHint').innerText = sel === '__new__' ? '新建通道组并移入;之后可在组卡片的「编辑」里把其它通道加入' + : (sel ? '组内通道共用邀请和授权,同事授权一次即可访问组内全部地址' : ''); } function onTunnelGroupChange() { @@ -1414,7 +1427,8 @@ const id = document.getElementById('tunnelId').value; const batch = isBatch(); const sel = document.getElementById('tunnelGroup').value; - const joinsExisting = sel !== '' && sel !== '__new__'; + // 加入已有组时组的授权自动覆盖新地址,不需要再发邀请 + const joinsExisting = !id && !!fixedGroupId; const name = document.getElementById('tunnelName').value.trim(); const target = composeTarget(readTargetRows()); let port = 0; @@ -1428,7 +1442,7 @@ if (!tunnelPlan.length) { alert('请填写目标主机和端口'); return; } } if (!name && (id || !joinsExisting)) { alert(batch ? '请填写通道组名称' : '请填写通道名称'); return; } - if (sel === '__new__' && !groupFields().new_group) { alert('请填写通道组名称'); return; } + if (id && sel === '__new__' && !groupFields().new_group) { alert('请填写通道组名称'); return; } const mode = document.getElementById('tunnelAccessMode').value; let whitelist = []; if (mode === 'all') whitelist = ['*']; diff --git a/mac-native/PortRelayApp.swift b/mac-native/PortRelayApp.swift index 6bef60c..30ae3da 100644 --- a/mac-native/PortRelayApp.swift +++ b/mac-native/PortRelayApp.swift @@ -3773,6 +3773,8 @@ struct TargetRow: Identifiable, Equatable { struct TunnelEditSheet: View { @ObservedObject var vm: PortRelayViewModel let tunnel: TunnelItem? + /// 组卡片「添加地址」打开时固定加入的通道组 + let fixedGroupId: String? @Binding var isPresented: Bool @State private var name: String @@ -3803,7 +3805,8 @@ struct TunnelEditSheet: View { case linkOnly, specific, all } - /// 通道组选项。新建时多个地址自动新建组(以名称为组名);编辑时可以新建组并移入 + /// 通道组。新建时不用选:一个地址不分组,多个地址自动新建组(以名称为组名), + /// 从组卡片「添加地址」进来则固定加入该组;只有编辑已有通道时才能选择移入、新建或移出 enum GroupChoice: Hashable { case none case new @@ -3813,11 +3816,12 @@ struct TunnelEditSheet: View { var isEdit: Bool { tunnel != nil } /// 表单字段在创建时就用通道数据填好,不依赖 onAppear(避免第一帧显示空表单)。 - /// groupId:新建时默认加入的通道组(通道组卡片上的「添加地址」) + /// 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 ?? [] @@ -3837,7 +3841,7 @@ struct TunnelEditSheet: View { _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 ?? groupId ?? "" + let gid = tunnel?.group_id ?? "" _groupChoice = State(initialValue: gid.isEmpty ? .none : .existing(gid)) } @@ -3856,20 +3860,26 @@ struct TunnelEditSheet: View { return isEdit && !n.isEmpty ? n : trimmedName } - var existingGroupName: String? { - if case .existing(let id) = groupChoice { return vm.group(id)?.name } - return nil + /// 实际生效的通道组:编辑时按选择;新建时由入口和地址数决定 + 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 { - isBatch && groupChoice == .new ? "通道组名称" : "通道名称" + if fixedGroupName != nil { return "通道名称(可选)" } + return isBatch ? "通道组名称" : "通道名称" } var namePlaceholder: String { - if !isEdit, let g = existingGroupName { - return "可不填,默认「\(g) (主机)」" - } - return isBatch ? "例如:订单服务" : "例如:MySQL 实习库" + if let g = fixedGroupName { return "留空则命名为「\(g) (主机)」" } + return isBatch ? "如:订单服务集群" : "如:订单数据库" } var canSave: Bool { @@ -3878,12 +3888,18 @@ struct TunnelEditSheet: View { return !trimmedName.isEmpty && !composedTarget.isEmpty && (groupChoice != .new || !resolvedGroupName.isEmpty) } if plan.isEmpty || planError != nil { return false } - return !trimmedName.isEmpty || existingGroupName != nil + 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 groupChoice { + switch effectiveChoice { case .none: break case .new: newGroup = resolvedGroupName case .existing(let id): groupId = id @@ -3899,8 +3915,9 @@ struct TunnelEditSheet: View { var body: some View { VStack(alignment: .leading, spacing: 14) { - Text(isEdit ? "编辑通道" : "添加通道") + Text(verbatim: isEdit ? "编辑通道" : (fixedGroupName.map { "添加地址到「\($0)」" } ?? "添加通道")) .font(.system(size: 15, weight: .bold)) + .lineLimit(1) HStack(spacing: 12) { field(nameTitle) { @@ -3908,7 +3925,7 @@ struct TunnelEditSheet: View { .textFieldStyle(.roundedBorder) } field("关联责任人") { - TextField("例如:张三 / 数据组", text: $owner) + TextField("负责人或团队,如:数据组", text: $owner) .textFieldStyle(.roundedBorder) } } @@ -3928,7 +3945,9 @@ struct TunnelEditSheet: View { } } - groupSection + if isEdit { + groupSection + } closeSection bandwidthSection accessSection @@ -3943,7 +3962,7 @@ struct TunnelEditSheet: View { Spacer() Button("取消") { isPresented = false } .controlSize(.small) - Button(isSaving ? "保存中…" : (isBatch ? "创建 \(plan.count) 个通道" : "保存")) { save() } + Button(saveTitle) { save() } .buttonStyle(.borderedProminent) .controlSize(.small) .disabled(!canSave) @@ -3952,13 +3971,8 @@ struct TunnelEditSheet: View { } .padding(20) .frame(width: 520) - .task(id: "\(composedTarget)|\(trimmedName)|\(groupChoice)") { await refreshPlan() } + .task(id: "\(composedTarget)|\(trimmedName)") { await refreshPlan() } .onChange(of: composedTarget) { _ in probes = [:] } - .onChange(of: isBatch) { batch in - // 多个地址放进同一个通道组;只剩一个地址时回到不分组 - if batch && groupChoice == .none { groupChoice = .new } - if !batch && !isEdit && groupChoice == .new { groupChoice = .none } - } } // MARK: Target list @@ -3994,7 +4008,7 @@ struct TunnelEditSheet: View { .font(.system(size: 12)) } .buttonStyle(.borderless) - .help("可以一次粘贴多个 IP(每行一个,或用逗号分隔),会自动拆成多行") + .help("也可以直接粘贴多个地址(每行一个或逗号分隔,可带 :端口),会自动拆成多行") Spacer() probeButton } @@ -4012,6 +4026,11 @@ struct TunnelEditSheet: View { } else if !plan.isEmpty { planList } + if !isEdit, let hint = addGroupHint { + Label(hint, systemImage: "square.stack.3d.up") + .font(.system(size: 11)) + .foregroundColor(.secondary) + } } } @@ -4023,10 +4042,10 @@ struct TunnelEditSheet: View { func targetRow(_ row: TargetRow) -> some View { HStack(spacing: 8) { - TextField(isEdit ? "例如 10.0.0.10" : "例如 10.0.1.11,可粘贴多个", text: hostBinding(row.id)) + TextField(isEdit ? "IP 或域名" : "IP 或域名,支持粘贴多行", text: hostBinding(row.id)) .textFieldStyle(.roundedBorder) .focused($focusedRow, equals: row.id) - TextField(isEdit ? "3306" : "22, 3306", text: portsBinding(row.id)) + TextField(isEdit ? "如 3306" : "如 22, 3306", text: portsBinding(row.id)) .textFieldStyle(.roundedBorder) .frame(width: 150) if !isEdit { @@ -4137,6 +4156,14 @@ struct TunnelEditSheet: View { // 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("通道组") @@ -4144,12 +4171,8 @@ struct TunnelEditSheet: View { .foregroundColor(.secondary) HStack(spacing: 8) { Picker("", selection: $groupChoice) { - if !isBatch { - Text("不分组").tag(GroupChoice.none) - } - if isBatch || isEdit { - Text("新建通道组").tag(GroupChoice.new) - } + Text("不分组").tag(GroupChoice.none) + Text("新建通道组").tag(GroupChoice.new) if !vm.groups.isEmpty { Divider() ForEach(vm.groups) { g in @@ -4159,8 +4182,8 @@ struct TunnelEditSheet: View { } .labelsHidden() .fixedSize() - if isEdit && groupChoice == .new { - TextField(trimmedName.isEmpty ? "通道组名称" : "组名,默认「\(trimmedName)」", text: $newGroupName) + if groupChoice == .new { + TextField(trimmedName.isEmpty ? "通道组名称" : "通道组名称,留空则用「\(trimmedName)」", text: $newGroupName) .textFieldStyle(.roundedBorder) } } @@ -4174,13 +4197,9 @@ struct TunnelEditSheet: View { var groupHint: String? { switch groupChoice { - case .none: - return nil - case .new where isBatch: - let n = trimmedName.isEmpty ? "(填写上面的名称)" : "「\(trimmedName)」" - return "\(plan.count) 个地址会放进通道组\(n):共用邀请和授权,同事授权一次即可访问全部地址" - default: - return "组内通道共用邀请和授权,同事授权一次即可访问组内全部地址" + case .none: return nil + case .new: return "新建通道组并移入;之后可在组卡片的「编辑」里把其它通道加入" + case .existing: return "组内通道共用邀请和授权,同事授权一次即可访问组内全部地址" } } @@ -4244,7 +4263,7 @@ struct TunnelEditSheet: View { .font(.system(size: 11)) .foregroundColor(.secondary) case .specific: - TextField("例如 192.168.1.100, 192.168.1.0/24(逗号分隔)", text: $whitelistString) + TextField("IP 或 CIDR 网段,逗号分隔,如 192.168.1.100, 10.0.0.0/24", text: $whitelistString) .textFieldStyle(.roundedBorder) Text("名单内的设备始终可以访问;其他人仍可通过邀请链接临时授权。") .font(.system(size: 11)) @@ -4328,7 +4347,8 @@ struct TunnelEditSheet: View { } guard let wl = whitelist() else { return } let form = makeForm(port: port, whitelist: wl) - let joinsExisting = existingGroupName != nil + // 加入已有组时组的授权自动覆盖新地址,不需要再发邀请 + let joinsExisting = fixedGroupId != nil isSaving = true errorMessage = nil Task { @@ -4402,14 +4422,14 @@ struct GroupEditSheet: View { Text("通道组名称") .font(.system(size: 12)) .foregroundColor(.secondary) - TextField("例如:订单服务集群", text: $name) + TextField("如:订单服务集群", text: $name) .textFieldStyle(.roundedBorder) } VStack(alignment: .leading, spacing: 4) { Text("关联责任人") .font(.system(size: 12)) .foregroundColor(.secondary) - TextField("例如:张三 / 运维组", text: $owner) + TextField("负责人或团队,如:运维组", text: $owner) .textFieldStyle(.roundedBorder) } } @@ -4612,7 +4632,7 @@ struct InviteSheet: View { Text("备注(发给谁,可选)") .font(.system(size: 12)) .foregroundColor(.secondary) - TextField("例如:张三", text: $note) + TextField("如:张三", text: $note) .textFieldStyle(.roundedBorder) } HStack(spacing: 6) { @@ -4741,7 +4761,7 @@ struct SettingsSheet: View { Text("服务器控制地址 (地址:端口)") .font(.system(size: 12)) .foregroundColor(.secondary) - TextField("例如 gw.example.com:33000", text: $serverAddr) + TextField("地址:端口,如 gw.example.com:33000", text: $serverAddr) .textFieldStyle(.roundedBorder) }