-
-
探测测试
+
目标服务(每行一台主机,多个端口用逗号隔开)
+
主机 端口
+
+
+ + 添加主机
+ 探测测试
@@ -1069,16 +1081,14 @@
document.getElementById('tunnelId').value = '';
document.getElementById('tunnelName').value = '';
document.getElementById('tunnelOwner').value = '';
- document.getElementById('tunnelTarget').value = '';
- document.getElementById('tunnelTargetLabel').innerText = '目标地址(中转机 Mac 可直连的内网目标,可填多个)';
- document.getElementById('tunnelTarget').placeholder = '例如:10.0.0.10:3306;多台主机写 10.0.1.11-13:22';
+ document.getElementById('tunnelTargetLabel').innerText = '目标服务(每行一台主机,多个端口用逗号隔开)';
+ fillTunnelGroupSelect(groupId || '');
setAccessForm([]);
setLifecycleForm(30, 0, 0);
- fillTunnelGroupSelect(groupId || '');
- document.getElementById('probeResult').innerText = '';
resetPlan();
- autoRecommendPort();
+ setTargetRows([{ host: '', ports: '' }]);
document.getElementById('tunnelModal').classList.add('open');
+ document.querySelector('#targetRows .t-host').focus();
}
function editTunnel(id) {
@@ -1088,15 +1098,14 @@
document.getElementById('tunnelId').value = t.id;
document.getElementById('tunnelName').value = t.name;
document.getElementById('tunnelOwner').value = t.owner || '';
- document.getElementById('tunnelTarget').value = t.target;
- document.getElementById('tunnelTargetLabel').innerText = '目标地址(中转机 Mac 可直连的内网目标)';
- document.getElementById('tunnelTarget').placeholder = '例如:10.0.0.10:3306';
+ document.getElementById('tunnelTargetLabel').innerText = '目标服务(内网主机与端口)';
+ const hp = splitHostPort(t.target) || [t.target, ''];
document.getElementById('tunnelPort').value = t.listen_port;
setAccessForm(t.whitelist || []);
setLifecycleForm(t.effective_idle_close_minutes ?? 30, t.duration_minutes || 0, t.max_bandwidth_mb || 0);
fillTunnelGroupSelect(t.group_id || '');
- document.getElementById('probeResult').innerText = '';
resetPlan();
+ setTargetRows([{ host: hp[0], ports: hp[1] }]);
document.getElementById('tunnelModal').classList.add('open');
}
@@ -1114,6 +1123,96 @@
}
}
+ // ---- 目标列表:每行一台主机 + 一个或多个端口 ----
+ const isEditing = () => !!document.getElementById('tunnelId').value;
+ const listItems = s => s.split(/[\s,,、;;]+/).filter(Boolean);
+
+ // "10.0.1.11:22" / "[fd00::1]:22" → [主机, 端口];不带端口(含裸 IPv6)返回 null
+ function splitHostPort(item) {
+ const m = /^\[(.+)\]:(.+)$/.exec(item);
+ if (m) return [m[1], m[2]];
+ const parts = item.split(':');
+ return parts.length === 2 && parts[0] && parts[1] ? parts : null;
+ }
+
+ // 粘贴进来的一串目标拆成多行:每项是 "主机" 或 "主机:端口",同一主机的端口并到一行
+ function parseTargets(text, defaultPorts) {
+ const out = [];
+ for (const item of listItems(text)) {
+ const [host, port] = splitHostPort(item) || [item, ''];
+ const row = out.find(r => r.host === host);
+ if (!row) out.push({ host, ports: port || defaultPorts });
+ else if (port && !listItems(row.ports).includes(port)) row.ports = row.ports ? `${row.ports}, ${port}` : port;
+ }
+ return out;
+ }
+
+ // 拼成 "主机:端口, …" 交给服务端展开;还没填完整的行先跳过
+ function composeTarget(rows) {
+ const out = [];
+ for (const r of rows) {
+ const ports = listItems(r.ports);
+ for (const h of listItems(r.host)) {
+ if (splitHostPort(h)) { out.push(h); continue; }
+ if (!ports.length) continue;
+ const host = h.includes(':') && !h.startsWith('[') ? `[${h}]` : h;
+ ports.forEach(p => out.push(`${host}:${p}`));
+ }
+ }
+ return out.join(', ');
+ }
+
+ function readTargetRows() {
+ return [...document.querySelectorAll('#targetRows .target-row')].map(r => ({
+ host: r.querySelector('.t-host').value.trim(), ports: r.querySelector('.t-ports').value.trim()
+ }));
+ }
+
+ function setTargetRows(rows) {
+ const edit = isEditing();
+ document.getElementById('targetRows').innerHTML = rows.map(r => `
+
+
+
+ ${edit ? '' : `⊖ `}
+
`).join('');
+ document.getElementById('addRowBtn').style.display = edit ? 'none' : '';
+ document.getElementById('targetHead').style.display = edit ? 'none' : 'flex';
+ schedulePlan();
+ }
+
+ // 新的一行沿用上一行的端口(同一组机器通常开同样的端口)
+ function addTargetRow() {
+ const rows = readTargetRows();
+ rows.push({ host: '', ports: rows.length ? rows[rows.length - 1].ports : '' });
+ setTargetRows(rows);
+ const hosts = document.querySelectorAll('#targetRows .t-host');
+ hosts[hosts.length - 1].focus();
+ }
+
+ function removeTargetRow(btn) {
+ const all = [...document.querySelectorAll('#targetRows .target-row')];
+ const rows = readTargetRows();
+ rows.splice(all.indexOf(btn.closest('.target-row')), 1);
+ setTargetRows(rows.length ? rows : [{ host: '', ports: '' }]);
+ }
+
+ // 一次粘贴多个主机(或 主机:端口)时拆成多行
+ function onHostPaste(e) {
+ if (isEditing()) return;
+ const text = (e.clipboardData || window.clipboardData).getData('text');
+ if (listItems(text).length < 2 && !splitHostPort(text.trim())) return;
+ e.preventDefault();
+ const all = [...document.querySelectorAll('#targetRows .target-row')];
+ const idx = all.indexOf(e.target.closest('.target-row'));
+ const rows = readTargetRows();
+ const parsed = parseTargets(text, rows[idx].ports);
+ // 粘贴到空行时替换这一行,否则插在它后面
+ if (rows[idx].host) rows.splice(idx + 1, 0, ...parsed);
+ else rows.splice(idx, 1, ...parsed);
+ setTargetRows(rows);
+ }
+
async function probeOne(target) {
const res = await fetch('/api/probe', {
method: 'POST',
@@ -1123,71 +1222,58 @@
return res.json();
}
+ let probeResults = {};
+
async function probeTarget() {
- const target = document.getElementById('tunnelTarget').value.trim();
- if (!target) {
- alert('请先输入目标地址');
- return;
- }
- const targets = isBatch() ? tunnelPlan.map(t => t.target) : [target];
+ const target = composeTarget(readTargetRows());
+ if (!target) { alert('请先填写目标主机和端口'); return; }
+ const targets = isEditing() ? [target] : tunnelPlan.map(t => t.target);
+ if (!targets.length) return;
const hint = document.getElementById('probeResult');
hint.innerText = '探测中...';
hint.style.color = 'var(--text-muted)';
- try {
- const results = await Promise.all(targets.map(t => probeOne(t).catch(() => ({ reachable: false, error: '探测请求失败' }))));
- const failed = results.map((r, i) => [targets[i], r]).filter(([, r]) => !r.reachable);
- if (targets.length === 1) {
- const data = results[0];
- hint.innerText = data.reachable ? `✓ 连通性测试通过!延迟: ${data.rtt}` : `✗ 连通失败: ${data.error || '连接超时'}`;
- } else if (!failed.length) {
- hint.innerText = `✓ 全部 ${targets.length} 个目标连通`;
- } else {
- const [t, r] = failed[0];
- hint.innerText = `✗ ${targets.length - failed.length}/${targets.length} 连通;${t} 连通失败: ${r.error || '连接超时'}` + (failed.length > 1 ? `(共 ${failed.length} 个失败)` : '');
- }
- hint.style.color = failed.length ? 'var(--danger)' : 'var(--success)';
- } catch (e) {
- hint.innerText = '探测请求失败';
- hint.style.color = 'var(--danger)';
+ const results = await Promise.all(targets.map(t => probeOne(t).catch(() => ({ reachable: false, error: '探测请求失败' }))));
+ probeResults = {};
+ targets.forEach((t, i) => { probeResults[t] = results[i]; });
+ if (isEditing()) {
+ const r = results[0];
+ hint.innerText = r.reachable ? `✓ 连通性测试通过!延迟: ${r.rtt}` : `✗ 连通失败: ${r.error || '连接超时'}`;
+ hint.style.color = r.reachable ? 'var(--success)' : 'var(--danger)';
+ return;
}
+ hint.innerText = '';
+ renderPlan();
}
- // ---- 多个目标:预览将创建的通道,并放进通道组 ----
- let tunnelPlan = [], tunnelPlanError = null, planTimer = null;
+ // ---- 预览将创建的通道(含自动分配的访问端口);多个地址自动放进通道组 ----
+ let tunnelPlan = [], tunnelPlanError = null, planTimer = null, plannedTarget = '';
function resetPlan() {
tunnelPlan = [];
tunnelPlanError = null;
- clearTimeout(planTimer);
+ probeResults = {};
+ document.getElementById('probeResult').innerText = '';
renderPlan();
}
- // 可能填了多个目标(逗号 / 空格分隔,或含范围 "10.0.1.11-13:22"),需要服务端展开
- function mayBeMulti() {
- if (document.getElementById('tunnelId').value) return false;
- const target = document.getElementById('tunnelTarget').value.trim();
- return target.split(/[\s,,、;;]+/).filter(Boolean).length > 1 || target.includes('-');
- }
-
function isBatch() {
- return mayBeMulti() && tunnelPlan.length > 1;
+ return !isEditing() && tunnelPlan.length > 1;
}
// 通道组选项:'' 不分组,'__new__' 新建,其余为已有组 ID
function groupFields() {
const sel = document.getElementById('tunnelGroup').value;
- if (sel === '__new__') {
- const name = document.getElementById('tunnelNewGroup').value.trim() || document.getElementById('tunnelName').value.trim();
- return { group_id: '', new_group: name };
- }
- return { group_id: sel, new_group: '' };
+ 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 };
}
function tunnelPayload(port, whitelist) {
return {
name: document.getElementById('tunnelName').value.trim(),
owner: document.getElementById('tunnelOwner').value.trim(),
- target: document.getElementById('tunnelTarget').value.trim(),
+ target: composeTarget(readTargetRows()),
listen_port: port,
whitelist,
idle_close_minutes: parseInt(document.getElementById('tunnelIdleClose').value, 10),
@@ -1199,7 +1285,9 @@
function schedulePlan() {
clearTimeout(planTimer);
- if (!mayBeMulti()) { tunnelPlan = []; tunnelPlanError = null; renderPlan(); return; }
+ const target = composeTarget(readTargetRows());
+ if (target !== plannedTarget) { probeResults = {}; plannedTarget = target; }
+ if (isEditing() || !target) { tunnelPlan = []; tunnelPlanError = null; renderPlan(); return; }
planTimer = setTimeout(async () => {
try {
const res = await fetch('/api/tunnels/batch', {
@@ -1222,28 +1310,42 @@
}
function renderPlan() {
- const batch = isBatch();
- const box = document.getElementById('tunnelPlan');
- document.getElementById('tunnelPortWrap').style.display = batch ? 'none' : 'block';
- // 多个目标必须放进同一个通道组
+ const batch = isBatch(), edit = isEditing();
+ document.getElementById('tunnelPortWrap').style.display = edit ? 'block' : 'none';
+ // 多个地址必须放进通道组;新建时只有一个地址就不需要新建组
const sel = document.getElementById('tunnelGroup');
- const none = sel.querySelector('option[value=""]');
- if (none) none.disabled = batch;
- if (batch && sel.value === '') { sel.value = '__new__'; onTunnelGroupChange(true); }
+ 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 = '';
updateGroupHint();
document.getElementById('tunnelSaveBtn').innerText = batch ? `创建 ${tunnelPlan.length} 个通道` : '保存通道';
- if (mayBeMulti() && tunnelPlanError) {
+ document.getElementById('tunnelNameLabel').innerText = batch && sel.value === '__new__' ? '通道组名称' : '通道名称 / 备注';
+
+ const box = document.getElementById('tunnelPlan');
+ if (tunnelPlanError) {
box.style.display = 'block';
box.style.color = 'var(--danger)';
box.innerText = tunnelPlanError;
return;
}
- if (!batch) { box.style.display = 'none'; box.innerHTML = ''; return; }
- const shown = tunnelPlan.slice(0, 6);
+ if (!tunnelPlan.length) { box.style.display = 'none'; box.innerHTML = ''; return; }
+ const shown = tunnelPlan.slice(0, 8);
+ const tested = tunnelPlan.filter(m => probeResults[m.target]);
+ const failed = tested.filter(m => !probeResults[m.target].reachable);
+ const status = m => {
+ const r = probeResults[m.target];
+ if (!r) return '';
+ return r.reachable ? `
✓ ${escapeHtml(r.rtt)} ` : `
✗ 不通 `;
+ };
+ let caption = tunnelPlan.length === 1 ? '访问端口自动分配' : '每个地址一个通道,访问端口自动连续分配';
+ if (tested.length === tunnelPlan.length && !failed.length) caption = tunnelPlan.length === 1 ? '目标连接成功' : `全部 ${tunnelPlan.length} 个目标连接成功`;
+ if (failed.length) caption = `
${failed.length} 个目标连不上,鼠标停在 ✗ 上可查看原因 `;
box.style.display = 'block';
box.style.color = '';
- box.innerHTML = shown.map(m => `
${escapeHtml(publicHost)}:${m.listen_port} ➔ ${escapeHtml(m.target)}
`).join('')
- + `
${tunnelPlan.length > shown.length ? '… ' : ''}共 ${tunnelPlan.length} 个地址,每个地址一个通道,访问端口自动连续分配
`;
+ box.innerHTML = shown.map(m => `
${escapeHtml(publicHost)}:${m.listen_port} ➔ ${escapeHtml(m.target)} ${status(m)}
`).join('')
+ + `
${tunnelPlan.length > shown.length ? `… 共 ${tunnelPlan.length} 个地址,` : ''}${caption}
`;
}
function fillTunnelGroupSelect(selected) {
@@ -1252,28 +1354,31 @@
(currentGroups.length ? '
────────── ' : '') +
currentGroups.map(g => `
${escapeHtml(g.name)} `).join('');
document.getElementById('tunnelNewGroup').value = '';
- onTunnelGroupChange(true);
+ updateGroupHint();
}
function updateGroupHint() {
const sel = document.getElementById('tunnelGroup').value;
- const isNew = sel === '__new__';
const name = document.getElementById('tunnelName').value.trim();
const newInput = document.getElementById('tunnelNewGroup');
- newInput.style.display = isNew ? 'block' : 'none';
+ newInput.style.display = sel === '__new__' && isEditing() ? 'block' : 'none';
newInput.placeholder = name ? `组名,默认「${name}」` : '通道组名称';
- document.getElementById('tunnelGroupHint').innerText = isBatch()
- ? '多个地址会放进同一个通道组:共用邀请和授权,同事授权一次即可访问全部地址'
- : (sel ? '组内通道共用邀请和授权,同事授权一次即可访问组内全部地址' : '');
- // 放进通道组时名称可以不填,按组名命名
- const g = isNew ? newInput.value.trim() : (currentGroups.find(x => x.id === sel) || {}).name;
- document.getElementById('tunnelName').placeholder = (!document.getElementById('tunnelId').value && g)
- ? `可不填,默认「${g} (主机)」` : '例如:MySQL 实习库 或 财务系统 SSH';
+ let hint = '';
+ if (sel === '__new__' && isBatch()) {
+ hint = `${tunnelPlan.length} 个地址会放进通道组${name ? `「${name}」` : '(填写上面的名称)'}:共用邀请和授权,同事授权一次即可访问全部地址`;
+ } else if (sel) {
+ hint = '组内通道共用邀请和授权,同事授权一次即可访问组内全部地址';
+ }
+ document.getElementById('tunnelGroupHint').innerText = hint;
+ // 加入已有组时名称可以不填,按组名命名
+ const g = (currentGroups.find(x => x.id === sel) || {}).name;
+ document.getElementById('tunnelName').placeholder = !isEditing() && g ? `可不填,默认「${g} (主机)」`
+ : (isBatch() ? '例如:订单服务' : '例如:MySQL 实习库 或 财务系统 SSH');
}
- function onTunnelGroupChange(skipPlan) {
+ function onTunnelGroupChange() {
updateGroupHint();
- if (!skipPlan) schedulePlan();
+ schedulePlan();
}
// 闲置自动关闭 / 最长开启时长;旧配置中不在选项里的值临时加入下拉框
@@ -1309,10 +1414,20 @@
const id = document.getElementById('tunnelId').value;
const batch = isBatch();
const sel = document.getElementById('tunnelGroup').value;
- const port = parseInt(document.getElementById('tunnelPort').value, 10);
- if (!batch && !(port > 33000 && port <= 34000)) { alert('端口须在 33001-34000 范围内'); return; }
- if (mayBeMulti() && tunnelPlanError) { alert(tunnelPlanError); return; }
- if (!document.getElementById('tunnelName').value.trim() && (id || !sel)) { alert('请填写通道名称'); return; }
+ const joinsExisting = sel !== '' && sel !== '__new__';
+ const name = document.getElementById('tunnelName').value.trim();
+ const target = composeTarget(readTargetRows());
+ let port = 0;
+ if (id) {
+ port = parseInt(document.getElementById('tunnelPort').value, 10);
+ if (!(port > 33000 && port <= 34000)) { alert('端口须在 33001-34000 范围内'); return; }
+ if (!target) { alert('请填写目标主机和端口'); return; }
+ if (target.includes(',')) { alert('一个通道只对应一个目标端口;要开放更多端口,请用「新建代理通道」'); return; }
+ } else {
+ if (tunnelPlanError) { alert(tunnelPlanError); return; }
+ if (!tunnelPlan.length) { alert('请填写目标主机和端口'); return; }
+ }
+ if (!name && (id || !joinsExisting)) { alert(batch ? '请填写通道组名称' : '请填写通道名称'); return; }
if (sel === '__new__' && !groupFields().new_group) { alert('请填写通道组名称'); return; }
const mode = document.getElementById('tunnelAccessMode').value;
let whitelist = [];
@@ -1322,10 +1437,10 @@
if (whitelist.length === 0) { alert('请填写允许访问的 IP 或网段,或选择“仅授权链接”'); return; }
if (whitelist.includes('*')) { alert('如需放通全部,请选择“全部放通 *”'); return; }
}
- const payload = tunnelPayload(batch ? 0 : port, whitelist);
+ const payload = tunnelPayload(port, whitelist);
try {
- // 新建统一走 /api/tunnels/batch:单个目标、多个目标、新建或加入通道组都在这一个表单里
+ // 新建统一走 /api/tunnels/batch:一个或多个地址、新建或加入通道组都在这一个表单里,访问端口自动分配
const res = await fetch(id ? `/api/tunnels/${encodeURIComponent(id)}` : '/api/tunnels/batch', {
method: id ? 'PUT' : 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -1339,7 +1454,6 @@
closeTunnelModal();
await fetchTunnels();
// 新建后直接生成邀请:新建的通道组邀请整组;加入已有组时组的授权自动覆盖新地址,不用再邀请
- const joinsExisting = sel !== '' && sel !== '__new__';
if (!id && mode !== 'all' && !joinsExisting) {
const key = data.group ? 'g:' + data.group.id : 't:' + data.tunnels[0].id;
setTimeout(() => openInviteModal(key, false), 800);
@@ -1598,6 +1712,14 @@
if (!res.ok) alert('撤销失败: ' + await apiError(res));
}
+ // 通道组的在线连接可以按设备(来源 IP)或按组内地址查看
+ let drawerByAddress = false;
+ function setDrawerDimension(v) {
+ drawerByAddress = v;
+ renderDrawerClients();
+ }
+ const hostOfAddr = a => { const m = /^\[(.+)\]:\d+$/.exec(a) || /^([^:]+):\d+$/.exec(a); return m ? m[1] : a; };
+
function renderDrawerClients() {
if (!drawerScope) return;
const info = scopeInfo(drawerScope);
@@ -1616,15 +1738,45 @@
// 同一 IP 的连接合并展示
const groups = {};
list.forEach(c => {
- const m = /^\[(.+)\]:\d+$/.exec(c.client_ip) || /^([^:]+):\d+$/.exec(c.client_ip);
- const ip = m ? m[1] : c.client_ip;
+ const ip = hostOfAddr(c.client_ip);
(groups[ip] = groups[ip] || []).push(c);
});
const leaseNames = new Map(info.leases.map(l => [l.ip, l.name || '']));
const sum = (arr, k) => arr.reduce((a, c) => a + (c[k] || 0), 0);
const ips = Object.keys(groups).sort((x, y) => groups[y].length - groups[x].length || x.localeCompare(y));
+ const byAddress = info.group && drawerByAddress;
+ const inUse = new Set(list.map(c => c.tunnel_id)).size;
+ const toggle = info.group ? `
按设备 按地址 ` : '';
+ const header = `
在线连接 (${byAddress ? `${inUse}/${info.members.length} 个地址在用` : `${ips.length} 台 · ${list.length} 连接`})${toggle} `;
- container.innerHTML = parts.pending + `
在线连接 (${ips.length} 台 · ${list.length} 连接) ` + ips.map(ip => {
+ if (byAddress) {
+ container.innerHTML = parts.pending + header + info.members.map(t => {
+ const conns = list.filter(c => c.tunnel_id === t.id);
+ const byIp = {};
+ conns.forEach(c => { const ip = hostOfAddr(c.client_ip); (byIp[ip] = byIp[ip] || []).push(c); });
+ const devs = Object.keys(byIp).sort();
+ const kill = cs => escapeHtml(JSON.stringify(cs.map(c => c.conn_id)));
+ return `
+
+
+
+ ${escapeHtml(publicHost)}:${t.listen_port} ➔ ${escapeHtml(t.target)}
+ ${conns.length ? `${devs.length} 台设备 · ${conns.length} 个连接` : (t.enabled ? '无连接' : '已关闭')}
+
+ ${conns.length ? `
全部断开 ` : ''}
+
+ ${devs.map(ip => `
+
+ ${escapeHtml(ip)}${leaseNames.get(ip) ? ` ${escapeHtml(leaseNames.get(ip))} ` : ''}
+ · ${byIp[ip].length} 个连接 · ↑ ${formatRate(sum(byIp[ip], 'rate_in'))} / ↓ ${formatRate(sum(byIp[ip], 'rate_out'))}
+ 断开
+
`).join('')}
+
`;
+ }).join('') + parts.auth;
+ return;
+ }
+
+ container.innerHTML = parts.pending + header + ips.map(ip => {
const conns = groups[ip];
const first = conns.map(c => new Date(c.connected_at)).sort((x, y) => x - y)[0];
const ids = conns.map(c => c.conn_id);
diff --git a/docs/images/app-edit.png b/docs/images/app-edit.png
index eeebb51..79268f7 100644
Binary files a/docs/images/app-edit.png and b/docs/images/app-edit.png differ
diff --git a/mac-native/PortRelayApp.swift b/mac-native/PortRelayApp.swift
index 7e880c2..6bef60c 100644
--- a/mac-native/PortRelayApp.swift
+++ b/mac-native/PortRelayApp.swift
@@ -268,6 +268,13 @@ struct ServerStatusResp: Codable {
var total_rate_history: RateSeriesItem?
}
+/// 连通性测试结果
+struct ProbeOutcome {
+ let ok: Bool
+ /// 延迟,或失败原因
+ let detail: String
+}
+
struct ProbeResp: Codable {
var target: String
var reachable: Bool
@@ -1044,7 +1051,7 @@ class PortRelayViewModel: ObservableObject {
/// 菜单内容有变化时才需要重建(避免每秒重建打断已展开的菜单)
func menuNeedsRebuild() -> Bool {
// 分步拼接:一长串字符串相加会让旧版编译器类型推断超时
- let tunnelSig: String = tunnels.map { "\($0.id):\($0.name):\($0.listen_port):\($0.enabled)" }.joined(separator: ",")
+ 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 sig = [String(isConnected), tunnelSig, groupSig, pendingSig].joined(separator: "|")
@@ -1225,51 +1232,38 @@ class PortRelayViewModel: ObservableObject {
return (true, try? JSONDecoder().decode(BatchResp.self, from: data), nil)
}
- /// 同时测试多个目标的连通性,全部连通才算成功
- func probeTargets(_ targets: [String]) async -> (Bool, String) {
- if targets.count == 1 { return await probeTarget(target: targets[0]) }
- var failed: [String: String] = [:]
- await withTaskGroup(of: (String, Bool, String).self) { group in
- for t in targets {
- group.addTask {
- let (ok, msg) = await self.probeTarget(target: t)
- return (t, ok, msg)
- }
- }
- for await (t, ok, msg) in group where !ok {
- failed[t] = msg
- }
- }
- guard let first = targets.first(where: { failed[$0] != nil }) else {
- return (true, "全部 \(targets.count) 个目标连接成功")
- }
- let more = failed.count > 1 ? "(共 \(failed.count) 个失败)" : ""
- return (false, "\(targets.count - failed.count)/\(targets.count) 连接成功;\(first) \(failed[first] ?? "")\(more)")
- }
-
- func probeTarget(target: String) async -> (Bool, String) {
- guard let url = URL(string: "http://127.0.0.1:33080/api/probe") else { return (false, "无效地址") }
+ /// 测试一个目标的连通性;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")
- let body = ["target": target]
- req.httpBody = try? JSONSerialization.data(withJSONObject: body)
-
+ req.httpBody = try? JSONSerialization.data(withJSONObject: ["target": target])
do {
let (data, _) = try await URLSession.shared.data(for: req)
- if let res = try? JSONDecoder().decode(ProbeResp.self, from: data) {
- if res.reachable {
- return (true, "连接成功 (\(res.rtt))")
- } else {
- return (false, "连接失败: \(res.error ?? "超时")")
- }
+ guard let res = try? JSONDecoder().decode(ProbeResp.self, from: data) else {
+ return ProbeOutcome(ok: false, detail: "解析失败")
}
- return (false, "解析失败")
+ return res.reachable ? ProbeOutcome(ok: true, detail: res.rtt) : ProbeOutcome(ok: false, detail: res.error ?? "超时")
} catch {
- return (false, error.localizedDescription)
+ 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 }
@@ -2808,12 +2802,17 @@ struct ClientDetailsSheet: View {
@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, isPresented: Binding) {
+ 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) {
@@ -2837,12 +2836,45 @@ struct ClientDetailsSheet: View {
return "\(vm.publicHost):\(t.listen_port) → \(t.target)"
}
- /// 通道组:这台设备连到了组内哪些地址
- func groupTargetsSummary(_ conns: [ActiveClientInfo]) -> String {
+ /// 通道组:设备访问了组内哪些地址(" · 访问 10.0.1.11、10.0.1.12");组内端口都相同时只写主机
+ func addressesSummary(_ tunnelIDs: Set) -> String {
guard scope.isGroup else { return "" }
- let targets = Set(conns.compactMap { targetLabel($0.tunnel_id) })
- if targets.count == 1, let only = targets.first { return " · \(only)" }
- return " · 连到 \(targets.count) 个地址"
+ 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")
@@ -2902,6 +2934,10 @@ struct ClientDetailsSheet: View {
.pickerStyle(.segmented)
.padding(.vertical, 4)
+ if scope.isGroup && selectedTab != 2 {
+ dimensionBar
+ }
+
Divider()
// Tab Content
@@ -2920,8 +2956,14 @@ struct ClientDetailsSheet: View {
} else {
ScrollView {
VStack(spacing: 8) {
- ForEach(clientGroups) { g in
- clientGroupRow(g)
+ if scope.isGroup && byAddress {
+ ForEach(members) { t in
+ onlineAddressRow(t)
+ }
+ } else {
+ ForEach(clientGroups) { g in
+ clientGroupRow(g)
+ }
}
}
.padding(.bottom, 8)
@@ -2944,8 +2986,14 @@ struct ClientDetailsSheet: View {
} else {
ScrollView {
VStack(spacing: 8) {
- ForEach(historyGroups) { g in
- historyGroupRow(g)
+ if scope.isGroup && byAddress {
+ ForEach(members) { t in
+ historyAddressRow(t)
+ }
+ } else {
+ ForEach(historyGroups) { g in
+ historyGroupRow(g)
+ }
}
}
.padding(.bottom, 8)
@@ -2996,8 +3044,12 @@ struct ClientDetailsSheet: View {
.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))")
+ 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)
@@ -3059,6 +3111,182 @@ struct ClientDetailsSheet: View {
.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 }
}
@@ -3088,7 +3316,7 @@ struct ClientDetailsSheet: View {
}
}
Text(verbatim: "最早接入 \(formatDateTimeShort(g.firstConnectedAt)) · ↑ \(formatBytes(g.bytesIn)) ↓ \(formatBytes(g.bytesOut))"
- + groupTargetsSummary(g.conns))
+ + addressesSummary(Set(g.conns.map { $0.tunnel_id })))
.font(.system(size: 11))
.foregroundColor(.secondary)
.lineLimit(1)
@@ -3480,6 +3708,68 @@ struct DiagnosticsSheet: View {
// 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?
@@ -3487,7 +3777,7 @@ struct TunnelEditSheet: View {
@State private var name: String
@State private var owner: String
- @State private var target: String
+ @State private var rows: [TargetRow]
@State private var portString: String
@State private var durationMinutes: Int
@State private var idleCloseMinutes: Int
@@ -3496,14 +3786,15 @@ struct TunnelEditSheet: View {
@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 probeMessage: String = ""
- @State private var probeOK: Bool = false
+ /// 连通性测试结果,按目标
+ @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
@@ -3512,7 +3803,7 @@ struct TunnelEditSheet: View {
case linkOnly, specific, all
}
- /// 通道组选项:不分组、新建、或加入已有组
+ /// 通道组选项。新建时多个地址自动新建组(以名称为组名);编辑时可以新建组并移入
enum GroupChoice: Hashable {
case none
case new
@@ -3524,7 +3815,7 @@ struct TunnelEditSheet: View {
/// 表单字段在创建时就用通道数据填好,不依赖 onAppear(避免第一帧显示空表单)。
/// groupId:新建时默认加入的通道组(通道组卡片上的「添加地址」)
init(vm: PortRelayViewModel, tunnel: TunnelItem?, isPresented: Binding, groupId: String? = nil,
- draftName: String = "", draftTarget: String = "", onCreated: ((AccessScope) -> Void)? = nil) {
+ draftName: String = "", draftRows: [(String, String)] = [], onCreated: ((AccessScope) -> Void)? = nil) {
_vm = ObservedObject(wrappedValue: vm)
self.tunnel = tunnel
self.onCreated = onCreated
@@ -3534,7 +3825,12 @@ struct TunnelEditSheet: View {
_accessMode = State(initialValue: mode)
_name = State(initialValue: tunnel?.name ?? draftName)
_owner = State(initialValue: tunnel.map { $0.owner ?? "" } ?? NSUserName())
- _target = State(initialValue: tunnel?.target ?? draftTarget)
+ 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)
@@ -3547,47 +3843,42 @@ struct TunnelEditSheet: View {
// MARK: Derived state
- /// 可能填了多个目标(逗号 / 空格分隔,或含范围 "10.0.1.11-13:22"),需要服务端展开
- var mayBeMulti: Bool {
- guard !isEdit else { return false }
- let items = target.split(whereSeparator: { " ,,、;;\n\t".contains($0) })
- return items.count > 1 || target.contains("-")
- }
+ var composedTarget: String { TargetRow.compose(rows) }
- /// 确实会创建多个通道
- var isBatch: Bool { mayBeMulti && plan.count > 1 }
+ /// 会创建多个通道(自动放进通道组)
+ var isBatch: Bool { !isEdit && plan.count > 1 }
- /// 新建通道组的名称:没填时与通道名称相同
+ var trimmedName: String { name.trimmingCharacters(in: .whitespaces) }
+
+ /// 新建通道组的名称:新建时就是名称;编辑时可另填,没填时与通道名称相同
var resolvedGroupName: String {
let n = newGroupName.trimmingCharacters(in: .whitespaces)
- return n.isEmpty ? name.trimmingCharacters(in: .whitespaces) : n
+ return isEdit && !n.isEmpty ? n : trimmedName
}
- /// 选中的通道组名称(不分组为 nil)
- var chosenGroupName: String? {
- switch groupChoice {
- case .none: return nil
- case .new: return newGroupName.trimmingCharacters(in: .whitespaces)
- case .existing(let id): return vm.group(id)?.name
- }
+ var existingGroupName: String? {
+ if case .existing(let id) = groupChoice { return vm.group(id)?.name }
+ return nil
+ }
+
+ var nameTitle: String {
+ isBatch && groupChoice == .new ? "通道组名称" : "通道名称"
}
var namePlaceholder: String {
- if !isEdit, let g = chosenGroupName, !g.isEmpty {
+ if !isEdit, let g = existingGroupName {
return "可不填,默认「\(g) (主机)」"
}
return isBatch ? "例如:订单服务" : "例如:MySQL 实习库"
}
var canSave: Bool {
- if isSaving || target.trimmingCharacters(in: .whitespaces).isEmpty { return false }
- if mayBeMulti && planError != nil { return false }
- let hasName = !name.trimmingCharacters(in: .whitespaces).isEmpty
- switch groupChoice {
- case .none: return hasName
- case .new: return !resolvedGroupName.isEmpty && (hasName || !isEdit)
- case .existing: return hasName || !isEdit
+ 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 || existingGroupName != nil
}
func makeForm(port: Int, whitelist: [String]) -> TunnelForm {
@@ -3598,8 +3889,8 @@ struct TunnelEditSheet: View {
case .existing(let id): groupId = id
}
return TunnelForm(
- name: name.trimmingCharacters(in: .whitespaces), owner: owner.trimmingCharacters(in: .whitespaces),
- target: target.trimmingCharacters(in: .whitespacesAndNewlines), port: port, whitelist: whitelist,
+ name: trimmedName, owner: owner.trimmingCharacters(in: .whitespaces),
+ target: composedTarget, port: port, whitelist: whitelist,
durationMinutes: durationMinutes, idleCloseMinutes: idleCloseMinutes, maxBandwidthMB: maxBandwidthMB,
groupId: groupId, newGroup: newGroup)
}
@@ -3612,7 +3903,7 @@ struct TunnelEditSheet: View {
.font(.system(size: 15, weight: .bold))
HStack(spacing: 12) {
- field("通道名称") {
+ field(nameTitle) {
TextField(namePlaceholder, text: $name)
.textFieldStyle(.roundedBorder)
}
@@ -3624,7 +3915,7 @@ struct TunnelEditSheet: View {
targetSection
- if !isBatch {
+ if isEdit {
field("对外监听端口 (范围 33001-34000)") {
HStack {
TextField("33660", text: $portString)
@@ -3660,54 +3951,147 @@ struct TunnelEditSheet: View {
.padding(.top, 4)
}
.padding(20)
- .frame(width: 490)
- .onAppear {
- if tunnel == nil {
- Task {
- let p = await vm.recommendPort()
- if portString.isEmpty { portString = "\(p)" }
- }
- }
- }
- .task(id: "\(target)|\(name)|\(newGroupName)|\(groupChoice)") { await refreshPlan() }
+ .frame(width: 520)
+ .task(id: "\(composedTarget)|\(trimmedName)|\(groupChoice)") { 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: Sections
+ // MARK: Target list
var targetSection: some View {
- VStack(alignment: .leading, spacing: 4) {
- Text(isEdit ? "目标服务 (内网主机 IP:端口)" : "目标服务 (内网主机 IP:端口,可填多个)")
+ VStack(alignment: .leading, spacing: 6) {
+ Text(isEdit ? "目标服务 (内网主机与端口)" : "目标服务(每行一台主机,多个端口用逗号隔开)")
.font(.system(size: 12))
.foregroundColor(.secondary)
- HStack {
- TextField(isEdit ? "例如 10.0.0.10:3306" : "例如 10.0.0.10:3306;多台主机写 10.0.1.11-13:22", text: $target)
- .textFieldStyle(.roundedBorder)
- Button(isProbing ? "测试中" : "连通性测试") { probe() }
- .disabled(target.trimmingCharacters(in: .whitespaces).isEmpty || isProbing || (mayBeMulti && planError != nil))
- .controlSize(.small)
+ 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 !probeMessage.isEmpty {
- Text(verbatim: probeMessage)
- .font(.system(size: 11))
- .foregroundColor(probeOK ? .green : .red)
+ 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("可以一次粘贴多个 IP(每行一个,或用逗号分隔),会自动拆成多行")
+ Spacer()
+ probeButton
+ }
}
- if mayBeMulti, let err = planError {
+ 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 isBatch {
+ } else if !plan.isEmpty {
planList
}
}
}
- /// 多个目标时:每个地址分到的访问端口
+ 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 ? "例如 10.0.0.10" : "例如 10.0.1.11,可粘贴多个", 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(6)
+ 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) {
@@ -3720,17 +4104,39 @@ struct TunnelEditSheet: View {
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: (plan.count > shown.count ? "… " : "") + "共 \(plan.count) 个地址,每个地址一个通道,访问端口自动连续分配")
+ Text(verbatim: planCaption(failed: failed, tested: tested, hidden: plan.count - shown.count))
.font(.system(size: 11))
- .foregroundColor(.secondary)
+ .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 groupSection: some View {
VStack(alignment: .leading, spacing: 4) {
Text("通道组")
@@ -3741,7 +4147,9 @@ struct TunnelEditSheet: View {
if !isBatch {
Text("不分组").tag(GroupChoice.none)
}
- Text("新建通道组").tag(GroupChoice.new)
+ if isBatch || isEdit {
+ Text("新建通道组").tag(GroupChoice.new)
+ }
if !vm.groups.isEmpty {
Divider()
ForEach(vm.groups) { g in
@@ -3751,23 +4159,31 @@ struct TunnelEditSheet: View {
}
.labelsHidden()
.fixedSize()
- if groupChoice == .new {
- TextField(name.trimmingCharacters(in: .whitespaces).isEmpty ? "通道组名称" : "组名,默认「\(name.trimmingCharacters(in: .whitespaces))」", text: $newGroupName)
+ if isEdit && groupChoice == .new {
+ TextField(trimmedName.isEmpty ? "通道组名称" : "组名,默认「\(trimmedName)」", text: $newGroupName)
.textFieldStyle(.roundedBorder)
}
}
- if isBatch {
- Text("多个地址会放进同一个通道组:共用邀请和授权,同事授权一次即可访问全部地址")
- .font(.system(size: 11))
- .foregroundColor(.secondary)
- } else if groupChoice != .none {
- Text("组内通道共用邀请和授权,同事授权一次即可访问组内全部地址")
+ if let hint = groupHint {
+ Text(verbatim: hint)
.font(.system(size: 11))
.foregroundColor(.secondary)
}
}
}
+ var groupHint: String? {
+ switch groupChoice {
+ case .none:
+ return nil
+ case .new where isBatch:
+ let n = trimmedName.isEmpty ? "(填写上面的名称)" : "「\(trimmedName)」"
+ return "\(plan.count) 个地址会放进通道组\(n):共用邀请和授权,同事授权一次即可访问全部地址"
+ default:
+ return "组内通道共用邀请和授权,同事授权一次即可访问组内全部地址"
+ }
+ }
+
/// 自动关闭:闲置关闭(主要)+ 可选的最长开启时长
var closeSection: some View {
VStack(alignment: .leading, spacing: 6) {
@@ -3853,7 +4269,7 @@ struct TunnelEditSheet: View {
// MARK: Actions
func refreshPlan() async {
- guard mayBeMulti else {
+ guard !isEdit, !composedTarget.isEmpty else {
plan = []
planError = nil
return
@@ -3868,12 +4284,10 @@ struct TunnelEditSheet: View {
}
func probe() {
- let targets = isBatch ? plan.map { $0.target } : [target.trimmingCharacters(in: .whitespaces)]
+ let targets = isEdit ? [composedTarget] : plan.map { $0.target }
isProbing = true
Task {
- let (ok, msg) = await vm.probeTargets(targets)
- probeOK = ok
- probeMessage = msg
+ probes = await vm.probeAll(targets)
isProbing = false
}
}
@@ -3901,17 +4315,20 @@ struct TunnelEditSheet: View {
func save() {
var port = 0
- if !isBatch {
+ 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)
- var joinsExisting = false
- if case .existing = groupChoice { joinsExisting = true }
+ let joinsExisting = existingGroupName != nil
isSaving = true
errorMessage = nil
Task {
@@ -4496,13 +4913,16 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
tunnelSection.isEnabled = false
menu.addItem(tunnelSection)
+ // 通道组占一行(出现在它第一个成员的位置),子菜单里整组开关、逐个地址开关
+ var seenGroups = Set()
for t in vm.tunnels {
- let check = t.enabled ? "✓ " : " "
- let title = "\(check)\(t.name) (:\(t.listen_port))"
- let item = NSMenuItem(title: title, action: #selector(toggleTunnelAction(_:)), keyEquivalent: "")
- item.representedObject = t.id
- item.target = self
- menu.addItem(item)
+ 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))"))
+ }
}
}
@@ -4526,6 +4946,37 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
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 {
diff --git a/tools/ui-snapshots/main.swift b/tools/ui-snapshots/main.swift
index 8303c24..b23efb3 100644
--- a/tools/ui-snapshots/main.swift
+++ b/tools/ui-snapshots/main.swift
@@ -189,7 +189,10 @@ func makeDemoVM(tick: Int = 0) -> PortRelayViewModel {
[
{"id":"h1","tunnel_id":"t1","tunnel_name":"MySQL 数据库","port":33660,"client_ip":"192.168.1.21:51000","connected_at":"\(t(-5000))","disconnected_at":"\(t(-4000))","duration_sec":1000,"bytes_in":1288490188,"bytes_out":5368709},
{"id":"h2","tunnel_id":"t1","tunnel_name":"MySQL 数据库","port":33660,"client_ip":"192.168.1.21:51001","connected_at":"\(t(-3000))","disconnected_at":"\(t(-2990))","duration_sec":10,"bytes_in":2048,"bytes_out":4096},
- {"id":"h3","tunnel_id":"t1","tunnel_name":"MySQL 数据库","port":33660,"client_ip":"192.168.1.35:40000","connected_at":"\(t(-90000))","disconnected_at":"\(t(-86000))","duration_sec":4000,"bytes_in":99999999,"bytes_out":1}
+ {"id":"h3","tunnel_id":"t1","tunnel_name":"MySQL 数据库","port":33660,"client_ip":"192.168.1.35:40000","connected_at":"\(t(-90000))","disconnected_at":"\(t(-86000))","duration_sec":4000,"bytes_in":99999999,"bytes_out":1},
+ {"id":"h4","tunnel_id":"g1a","tunnel_name":"订单服务 (10.0.3.11)","port":33670,"client_ip":"192.168.1.42:50100","connected_at":"\(t(-7000))","disconnected_at":"\(t(-6400))","duration_sec":600,"bytes_in":820000,"bytes_out":5200000},
+ {"id":"h5","tunnel_id":"g1b","tunnel_name":"订单服务 (10.0.3.12)","port":33671,"client_ip":"192.168.1.42:50120","connected_at":"\(t(-6800))","disconnected_at":"\(t(-6000))","duration_sec":800,"bytes_in":420000,"bytes_out":3100000},
+ {"id":"h6","tunnel_id":"g1a","tunnel_name":"订单服务 (10.0.3.11)","port":33670,"client_ip":"192.168.1.66:41022","connected_at":"\(t(-90000))","disconnected_at":"\(t(-88200))","duration_sec":1800,"bytes_in":1200000,"bytes_out":9400000}
]
""")
vm.totalActiveConns = 8; vm.totalHistoricalConns = 1656; vm.totalRateIn = totalIn; vm.totalRateOut = totalOut
@@ -241,8 +244,8 @@ MainActor.assumeIsolated {
render("01b-main-min", CGSize(width: 920, height: 640), ContentView(vm: vm))
render("04-clients-history", CGSize(width: 760, height: 600), ClientDetailsSheet(vm: vm, tunnel: t1, initialTab: 1, isPresented: yes))
render("05-clients-auth", CGSize(width: 760, height: 600), ClientDetailsSheet(vm: vm, tunnel: t1, initialTab: 2, isPresented: yes))
- render("06-edit-new", CGSize(width: 490, height: 760), TunnelEditSheet(vm: vm, tunnel: nil, isPresented: yes))
- render("07-edit-existing", CGSize(width: 490, height: 760), TunnelEditSheet(vm: vm, tunnel: vm.tunnels[1], isPresented: yes))
+ render("06-edit-new", CGSize(width: 520, height: 760), TunnelEditSheet(vm: vm, tunnel: nil, isPresented: yes))
+ render("07-edit-existing", CGSize(width: 520, height: 760), TunnelEditSheet(vm: vm, tunnel: vm.tunnels[1], isPresented: yes))
render("08-invite-form", CGSize(width: 560, height: 400), InviteSheet(vm: vm, tunnel: t1, autoGenerate: false, isPresented: yes))
render("09-invite-result", CGSize(width: 560, height: 400), InviteSheet(vm: vm, tunnel: t1, autoGenerate: true, isPresented: yes))
render("10-settings", CGSize(width: 400, height: 320), SettingsSheet(vm: vm, isPresented: yes))
@@ -256,7 +259,7 @@ MainActor.assumeIsolated {
render("readme-invite", CGSize(width: 560, height: 250), InviteSheet(vm: demo, tunnel: demo.tunnels[0], autoGenerate: true, isPresented: yes))
render("readme-clients", CGSize(width: 760, height: 560), ClientDetailsSheet(vm: demo, tunnel: demo.tunnels[0], initialTab: 0, isPresented: yes))
render("readme-access", CGSize(width: 760, height: 490), ClientDetailsSheet(vm: demo, tunnel: demo.tunnels[0], initialTab: 2, isPresented: yes))
- render("readme-edit", CGSize(width: 490, height: 760), TunnelEditSheet(vm: demo, tunnel: demo.tunnels[0], isPresented: yes))
+ render("readme-edit", CGSize(width: 520, height: 760), TunnelEditSheet(vm: demo, tunnel: demo.tunnels[0], isPresented: yes))
let g = demo.groups[0]
render("group-card", CGSize(width: 980, height: 330), GroupCardView(group: g, members: demo.members(of: g), visible: demo.members(of: g), vm: demo,
onEdit: {}, onAddMember: {}, onInvite: {}, onViewClients: { _ in }, onEditMember: { _ in }, onInviteMember: { _ in }, onMemberClients: { _ in }).padding(20))
@@ -264,14 +267,19 @@ MainActor.assumeIsolated {
onEdit: {}, onAddMember: {}, onInvite: {}, onViewClients: { _ in }, onEditMember: { _ in }, onInviteMember: { _ in }, onMemberClients: { _ in }).padding(20))
render("group-card-narrow", CGSize(width: 720, height: 360), GroupCardView(group: g, members: demo.members(of: g), visible: demo.members(of: g), vm: demo,
onEdit: {}, onAddMember: {}, onInvite: {}, onViewClients: { _ in }, onEditMember: { _ in }, onInviteMember: { _ in }, onMemberClients: { _ in }).padding(20))
- render("group-add-batch", CGSize(width: 490, height: 860), TunnelEditSheet(vm: demo, tunnel: nil, isPresented: yes,
- draftName: "订单服务", draftTarget: "10.0.3.11-13:22, 10.0.3.11-13:8080"))
- render("group-add-member", CGSize(width: 490, height: 780), TunnelEditSheet(vm: demo, tunnel: nil, isPresented: yes, groupId: g.id))
+ render("group-add-batch", CGSize(width: 520, height: 900), TunnelEditSheet(vm: demo, tunnel: nil, isPresented: yes,
+ draftName: "订单服务", draftRows: [("10.0.3.11", "22, 8080"), ("10.0.3.12", "22, 8080"), ("10.0.3.13", "22")]))
+ render("group-add-single", CGSize(width: 520, height: 780), TunnelEditSheet(vm: demo, tunnel: nil, isPresented: yes,
+ draftName: "MySQL 实习库", draftRows: [("10.0.1.16", "3306")]))
+ render("group-add-member", CGSize(width: 520, height: 780), TunnelEditSheet(vm: demo, tunnel: nil, isPresented: yes, groupId: g.id))
render("group-sheet-edit", CGSize(width: 520, height: 420), GroupEditSheet(vm: demo, group: g, isPresented: yes))
render("group-invite", CGSize(width: 560, height: 300), InviteSheet(vm: demo, scope: .group(g.id), autoGenerate: true, isPresented: yes))
render("group-access", CGSize(width: 760, height: 560), ClientDetailsSheet(vm: demo, scope: .group(g.id), initialTab: 2, isPresented: yes))
render("group-online", CGSize(width: 760, height: 400), ClientDetailsSheet(vm: demo, scope: .group(g.id), initialTab: 0, isPresented: yes))
- render("group-edit-member", CGSize(width: 490, height: 800), TunnelEditSheet(vm: demo, tunnel: demo.tunnels[3], isPresented: yes))
+ render("group-online-addr", CGSize(width: 760, height: 460), ClientDetailsSheet(vm: demo, scope: .group(g.id), initialTab: 0, byAddress: true, isPresented: yes))
+ render("group-history", CGSize(width: 760, height: 400), ClientDetailsSheet(vm: demo, scope: .group(g.id), initialTab: 1, isPresented: yes))
+ render("group-history-addr", CGSize(width: 760, height: 520), ClientDetailsSheet(vm: demo, scope: .group(g.id), initialTab: 1, byAddress: true, isPresented: yes))
+ render("group-edit-member", CGSize(width: 520, height: 800), TunnelEditSheet(vm: demo, tunnel: demo.tunnels[3], isPresented: yes))
// README 动图逐帧:卡片随时间推移(每帧 = 5 秒)
if only.hasPrefix("gif") {