feat: 新建时不再选择通道组,「添加地址」固定加入所在组;占位提示统一措辞
- 「添加通道」去掉通道组下拉:一个地址为独立通道,多个地址自动放进以名称命名的新组, 只在目标列表下方说明结果 - 组卡片「添加地址」标题为「添加地址到「组名」」,固定加入该组,名称可选(留空按「组名 (主机)」命名), 按钮为「添加 / 添加 N 个地址」,不再自动弹出邀请 - 通道组选择只保留在编辑已有通道时(不分组 / 新建通道组 / 已有组) - 占位提示统一为「如:…」并说明格式:IP 或域名、IP 或 CIDR 网段、负责人或团队等 - App 与网页面板一致
This commit is contained in:
1 parent
dfb3a9fd8e
commit
9a675ba96e
2 files changed
+123
-89
No files matched your search
@@ -452,12 +452,12 @@
|
|||||||
<input type="hidden" id="tunnelId">
|
<input type="hidden" id="tunnelId">
|
||||||
<div class="input-row">
|
<div class="input-row">
|
||||||
<div class="form-group" style="flex: 1;">
|
<div class="form-group" style="flex: 1;">
|
||||||
<label id="tunnelNameLabel">通道名称 / 备注</label>
|
<label id="tunnelNameLabel">通道名称</label>
|
||||||
<input type="text" id="tunnelName" placeholder="例如:MySQL 实习库 或 财务系统 SSH" oninput="schedulePlan()">
|
<input type="text" id="tunnelName" placeholder="如:订单数据库" oninput="schedulePlan()">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group" style="flex: 1;">
|
<div class="form-group" style="flex: 1;">
|
||||||
<label>责任人(可选)</label>
|
<label>责任人(可选)</label>
|
||||||
<input type="text" id="tunnelOwner" placeholder="例如:张三 / 数据组">
|
<input type="text" id="tunnelOwner" placeholder="负责人或团队,如:数据组">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -465,11 +465,12 @@
|
|||||||
<div class="target-head" id="targetHead"><span>主机</span><span>端口</span></div>
|
<div class="target-head" id="targetHead"><span>主机</span><span>端口</span></div>
|
||||||
<div id="targetRows"></div>
|
<div id="targetRows"></div>
|
||||||
<div class="input-row" style="justify-content: space-between; align-items: center;">
|
<div class="input-row" style="justify-content: space-between; align-items: center;">
|
||||||
<button type="button" class="add-member" id="addRowBtn" onclick="addTargetRow()" title="可以一次粘贴多个 IP(每行一个,或用逗号分隔),会自动拆成多行">+ 添加主机</button>
|
<button type="button" class="add-member" id="addRowBtn" onclick="addTargetRow()" title="也可以直接粘贴多个地址(每行一个或逗号分隔,可带 :端口),会自动拆成多行">+ 添加主机</button>
|
||||||
<button type="button" class="btn-secondary btn-sm" onclick="probeTarget()">探测测试</button>
|
<button type="button" class="btn-secondary btn-sm" onclick="probeTarget()">探测测试</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="input-hint" id="probeResult"></div>
|
<div class="input-hint" id="probeResult"></div>
|
||||||
<div class="plan-box" id="tunnelPlan" style="display:none;"></div>
|
<div class="plan-box" id="tunnelPlan" style="display:none;"></div>
|
||||||
|
<div class="input-hint" id="tunnelAddHint"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group" id="tunnelPortWrap">
|
<div class="form-group" id="tunnelPortWrap">
|
||||||
<label>对外监听端口 (范围: 33001 - 34000)</label>
|
<label>对外监听端口 (范围: 33001 - 34000)</label>
|
||||||
@@ -478,7 +479,7 @@
|
|||||||
<button type="button" class="btn-secondary" onclick="autoRecommendPort()">推荐空闲端口</button>
|
<button type="button" class="btn-secondary" onclick="autoRecommendPort()">推荐空闲端口</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group" id="tunnelGroupWrap">
|
||||||
<label>通道组</label>
|
<label>通道组</label>
|
||||||
<div class="input-row">
|
<div class="input-row">
|
||||||
<select id="tunnelGroup" onchange="onTunnelGroupChange()"></select>
|
<select id="tunnelGroup" onchange="onTunnelGroupChange()"></select>
|
||||||
@@ -523,7 +524,7 @@
|
|||||||
<option value="specific">固定 IP / 网段 + 授权链接</option>
|
<option value="specific">固定 IP / 网段 + 授权链接</option>
|
||||||
<option value="all">全部放通 *(不安全)</option>
|
<option value="all">全部放通 *(不安全)</option>
|
||||||
</select>
|
</select>
|
||||||
<input type="text" id="tunnelWhitelist" style="margin-top: 8px; display: none;" placeholder="例如 192.168.1.100, 192.168.1.0/24(逗号分隔)">
|
<input type="text" id="tunnelWhitelist" style="margin-top: 8px; display: none;" placeholder="IP 或 CIDR 网段,逗号分隔,如 192.168.1.100, 10.0.0.0/24">
|
||||||
<div class="input-hint" id="accessModeHint"></div>
|
<div class="input-hint" id="accessModeHint"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -545,7 +546,7 @@
|
|||||||
<div id="inviteForm">
|
<div id="inviteForm">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>备注(发给谁,可选)</label>
|
<label>备注(发给谁,可选)</label>
|
||||||
<input type="text" id="inviteNote" placeholder="例如:张三">
|
<input type="text" id="inviteNote" placeholder="如:张三">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>链接有效期</label>
|
<label>链接有效期</label>
|
||||||
@@ -607,11 +608,11 @@
|
|||||||
<div class="input-hint" style="margin: -8px 0 14px;">组内地址共用邀请与授权:同事授权一次即可访问整组;组内任一地址在用时,整组都不会闲置关闭。</div>
|
<div class="input-hint" style="margin: -8px 0 14px;">组内地址共用邀请与授权:同事授权一次即可访问整组;组内任一地址在用时,整组都不会闲置关闭。</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>通道组名称</label>
|
<label>通道组名称</label>
|
||||||
<input type="text" id="groupName" required placeholder="例如:订单服务集群">
|
<input type="text" id="groupName" required placeholder="如:订单服务集群">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>责任人(可选)</label>
|
<label>责任人(可选)</label>
|
||||||
<input type="text" id="groupOwner" placeholder="例如:张三 / 运维组">
|
<input type="text" id="groupOwner" placeholder="负责人或团队,如:运维组">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group" id="groupExistingWrap">
|
<div class="form-group" id="groupExistingWrap">
|
||||||
<label>把已有通道加入该组(可选)</label>
|
<label>把已有通道加入该组(可选)</label>
|
||||||
@@ -652,7 +653,7 @@
|
|||||||
<form onsubmit="saveSettings(event)">
|
<form onsubmit="saveSettings(event)">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>服务器控制地址 (地址:端口)</label>
|
<label>服务器控制地址 (地址:端口)</label>
|
||||||
<input type="text" id="settingServerAddr" required placeholder="例如 gw.example.com:33000">
|
<input type="text" id="settingServerAddr" required placeholder="地址:端口,如 gw.example.com:33000">
|
||||||
<div class="input-hint">边缘网关的地址与控制端口。局域网、VPN、虚拟组网或公网地址均可,只要本机能连通</div>
|
<div class="input-hint">边缘网关的地址与控制端口。局域网、VPN、虚拟组网或公网地址均可,只要本机能连通</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -1076,13 +1077,18 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 新建代理通道;groupId 非空时默认加入该通道组(通道组卡片上的「添加地址」)
|
// 新建代理通道;groupId 非空时默认加入该通道组(通道组卡片上的「添加地址」)
|
||||||
|
// 组卡片「添加地址」打开时固定加入的通道组;新建时不需要选择通道组
|
||||||
|
let fixedGroupId = null;
|
||||||
|
|
||||||
function openCreateModal(groupId) {
|
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('tunnelId').value = '';
|
||||||
document.getElementById('tunnelName').value = '';
|
document.getElementById('tunnelName').value = '';
|
||||||
document.getElementById('tunnelOwner').value = '';
|
document.getElementById('tunnelOwner').value = '';
|
||||||
document.getElementById('tunnelTargetLabel').innerText = '目标服务(每行一台主机,多个端口用逗号隔开)';
|
document.getElementById('tunnelTargetLabel').innerText = '目标服务(每行一台主机,多个端口用逗号隔开)';
|
||||||
fillTunnelGroupSelect(groupId || '');
|
fillTunnelGroupSelect('');
|
||||||
setAccessForm([]);
|
setAccessForm([]);
|
||||||
setLifecycleForm(30, 0, 0);
|
setLifecycleForm(30, 0, 0);
|
||||||
resetPlan();
|
resetPlan();
|
||||||
@@ -1094,6 +1100,7 @@
|
|||||||
function editTunnel(id) {
|
function editTunnel(id) {
|
||||||
const t = currentTunnels.find(item => item.id === id);
|
const t = currentTunnels.find(item => item.id === id);
|
||||||
if (!t) return;
|
if (!t) return;
|
||||||
|
fixedGroupId = null;
|
||||||
document.getElementById('modalTitle').innerText = '编辑代理通道';
|
document.getElementById('modalTitle').innerText = '编辑代理通道';
|
||||||
document.getElementById('tunnelId').value = t.id;
|
document.getElementById('tunnelId').value = t.id;
|
||||||
document.getElementById('tunnelName').value = t.name;
|
document.getElementById('tunnelName').value = t.name;
|
||||||
@@ -1172,8 +1179,8 @@
|
|||||||
const edit = isEditing();
|
const edit = isEditing();
|
||||||
document.getElementById('targetRows').innerHTML = rows.map(r => `
|
document.getElementById('targetRows').innerHTML = rows.map(r => `
|
||||||
<div class="target-row">
|
<div class="target-row">
|
||||||
<input type="text" class="t-host" value="${escapeHtml(r.host)}" placeholder="${edit ? '例如 10.0.0.10' : '例如 10.0.1.11,可粘贴多个'}" oninput="schedulePlan()" onpaste="onHostPaste(event)">
|
<input type="text" class="t-host" value="${escapeHtml(r.host)}" placeholder="${edit ? 'IP 或域名' : 'IP 或域名,支持粘贴多行'}" oninput="schedulePlan()" onpaste="onHostPaste(event)">
|
||||||
<input type="text" class="t-ports" value="${escapeHtml(r.ports)}" placeholder="${edit ? '3306' : '22, 3306'}" oninput="schedulePlan()">
|
<input type="text" class="t-ports" value="${escapeHtml(r.ports)}" placeholder="${edit ? '如 3306' : '如 22, 3306'}" oninput="schedulePlan()">
|
||||||
${edit ? '' : `<button type="button" class="icon-btn" onclick="removeTargetRow(this)" title="删除这一行" ${rows.length === 1 ? 'disabled' : ''}>⊖</button>`}
|
${edit ? '' : `<button type="button" class="icon-btn" onclick="removeTargetRow(this)" title="删除这一行" ${rows.length === 1 ? 'disabled' : ''}>⊖</button>`}
|
||||||
</div>`).join('');
|
</div>`).join('');
|
||||||
document.getElementById('addRowBtn').style.display = edit ? 'none' : '';
|
document.getElementById('addRowBtn').style.display = edit ? 'none' : '';
|
||||||
@@ -1262,11 +1269,15 @@
|
|||||||
|
|
||||||
// 通道组选项:'' 不分组,'__new__' 新建,其余为已有组 ID
|
// 通道组选项:'' 不分组,'__new__' 新建,其余为已有组 ID
|
||||||
function groupFields() {
|
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;
|
const sel = document.getElementById('tunnelGroup').value;
|
||||||
if (sel !== '__new__') return { group_id: sel, new_group: '' };
|
if (sel !== '__new__') return { group_id: sel, new_group: '' };
|
||||||
const name = document.getElementById('tunnelName').value.trim();
|
return { group_id: '', new_group: document.getElementById('tunnelNewGroup').value.trim() || name };
|
||||||
const custom = isEditing() ? document.getElementById('tunnelNewGroup').value.trim() : '';
|
|
||||||
return { group_id: '', new_group: custom || name };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function tunnelPayload(port, whitelist) {
|
function tunnelPayload(port, whitelist) {
|
||||||
@@ -1312,16 +1323,11 @@
|
|||||||
function renderPlan() {
|
function renderPlan() {
|
||||||
const batch = isBatch(), edit = isEditing();
|
const batch = isBatch(), edit = isEditing();
|
||||||
document.getElementById('tunnelPortWrap').style.display = edit ? 'block' : 'none';
|
document.getElementById('tunnelPortWrap').style.display = edit ? 'block' : 'none';
|
||||||
// 多个地址必须放进通道组;新建时只有一个地址就不需要新建组
|
document.getElementById('tunnelGroupWrap').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 = '';
|
|
||||||
updateGroupHint();
|
updateGroupHint();
|
||||||
document.getElementById('tunnelSaveBtn').innerText = batch ? `创建 ${tunnelPlan.length} 个通道` : '保存通道';
|
const n = tunnelPlan.length;
|
||||||
document.getElementById('tunnelNameLabel').innerText = batch && sel.value === '__new__' ? '通道组名称' : '通道名称 / 备注';
|
document.getElementById('tunnelSaveBtn').innerText = fixedGroupId ? (batch ? `添加 ${n} 个地址` : '添加')
|
||||||
|
: (batch ? `创建 ${n} 个通道` : '保存通道');
|
||||||
|
|
||||||
const box = document.getElementById('tunnelPlan');
|
const box = document.getElementById('tunnelPlan');
|
||||||
if (tunnelPlanError) {
|
if (tunnelPlanError) {
|
||||||
@@ -1358,22 +1364,29 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function updateGroupHint() {
|
function updateGroupHint() {
|
||||||
const sel = document.getElementById('tunnelGroup').value;
|
|
||||||
const name = document.getElementById('tunnelName').value.trim();
|
const name = document.getElementById('tunnelName').value.trim();
|
||||||
const newInput = document.getElementById('tunnelNewGroup');
|
const nameInput = document.getElementById('tunnelName');
|
||||||
newInput.style.display = sel === '__new__' && isEditing() ? 'block' : 'none';
|
const label = document.getElementById('tunnelNameLabel');
|
||||||
newInput.placeholder = name ? `组名,默认「${name}」` : '通道组名称';
|
if (!isEditing()) {
|
||||||
let hint = '';
|
// 新建:不需要选择通道组,只说明结果
|
||||||
if (sel === '__new__' && isBatch()) {
|
const g = fixedGroupId ? ((currentGroups.find(x => x.id === fixedGroupId) || {}).name || '通道组') : null;
|
||||||
hint = `${tunnelPlan.length} 个地址会放进通道组${name ? `「${name}」` : '(填写上面的名称)'}:共用邀请和授权,同事授权一次即可访问全部地址`;
|
let hint = '';
|
||||||
} else if (sel) {
|
if (g) hint = `新地址加入通道组「${g}」,自动沿用组的邀请和授权,已授权的同事无需重新授权`;
|
||||||
hint = '组内通道共用邀请和授权,同事授权一次即可访问组内全部地址';
|
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;
|
document.getElementById('tunnelAddHint').innerText = '';
|
||||||
// 加入已有组时名称可以不填,按组名命名
|
label.innerText = '通道名称';
|
||||||
const g = (currentGroups.find(x => x.id === sel) || {}).name;
|
nameInput.placeholder = '如:订单数据库';
|
||||||
document.getElementById('tunnelName').placeholder = !isEditing() && g ? `可不填,默认「${g} (主机)」`
|
const sel = document.getElementById('tunnelGroup').value;
|
||||||
: (isBatch() ? '例如:订单服务' : '例如:MySQL 实习库 或 财务系统 SSH');
|
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() {
|
function onTunnelGroupChange() {
|
||||||
@@ -1414,7 +1427,8 @@
|
|||||||
const id = document.getElementById('tunnelId').value;
|
const id = document.getElementById('tunnelId').value;
|
||||||
const batch = isBatch();
|
const batch = isBatch();
|
||||||
const sel = document.getElementById('tunnelGroup').value;
|
const sel = document.getElementById('tunnelGroup').value;
|
||||||
const joinsExisting = sel !== '' && sel !== '__new__';
|
// 加入已有组时组的授权自动覆盖新地址,不需要再发邀请
|
||||||
|
const joinsExisting = !id && !!fixedGroupId;
|
||||||
const name = document.getElementById('tunnelName').value.trim();
|
const name = document.getElementById('tunnelName').value.trim();
|
||||||
const target = composeTarget(readTargetRows());
|
const target = composeTarget(readTargetRows());
|
||||||
let port = 0;
|
let port = 0;
|
||||||
@@ -1428,7 +1442,7 @@
|
|||||||
if (!tunnelPlan.length) { alert('请填写目标主机和端口'); return; }
|
if (!tunnelPlan.length) { alert('请填写目标主机和端口'); return; }
|
||||||
}
|
}
|
||||||
if (!name && (id || !joinsExisting)) { alert(batch ? '请填写通道组名称' : '请填写通道名称'); 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;
|
const mode = document.getElementById('tunnelAccessMode').value;
|
||||||
let whitelist = [];
|
let whitelist = [];
|
||||||
if (mode === 'all') whitelist = ['*'];
|
if (mode === 'all') whitelist = ['*'];
|
||||||
|
|||||||
@@ -3773,6 +3773,8 @@ struct TargetRow: Identifiable, Equatable {
|
|||||||
struct TunnelEditSheet: View {
|
struct TunnelEditSheet: View {
|
||||||
@ObservedObject var vm: PortRelayViewModel
|
@ObservedObject var vm: PortRelayViewModel
|
||||||
let tunnel: TunnelItem?
|
let tunnel: TunnelItem?
|
||||||
|
/// 组卡片「添加地址」打开时固定加入的通道组
|
||||||
|
let fixedGroupId: String?
|
||||||
@Binding var isPresented: Bool
|
@Binding var isPresented: Bool
|
||||||
|
|
||||||
@State private var name: String
|
@State private var name: String
|
||||||
@@ -3803,7 +3805,8 @@ struct TunnelEditSheet: View {
|
|||||||
case linkOnly, specific, all
|
case linkOnly, specific, all
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 通道组选项。新建时多个地址自动新建组(以名称为组名);编辑时可以新建组并移入
|
/// 通道组。新建时不用选:一个地址不分组,多个地址自动新建组(以名称为组名),
|
||||||
|
/// 从组卡片「添加地址」进来则固定加入该组;只有编辑已有通道时才能选择移入、新建或移出
|
||||||
enum GroupChoice: Hashable {
|
enum GroupChoice: Hashable {
|
||||||
case none
|
case none
|
||||||
case new
|
case new
|
||||||
@@ -3813,11 +3816,12 @@ struct TunnelEditSheet: View {
|
|||||||
var isEdit: Bool { tunnel != nil }
|
var isEdit: Bool { tunnel != nil }
|
||||||
|
|
||||||
/// 表单字段在创建时就用通道数据填好,不依赖 onAppear(避免第一帧显示空表单)。
|
/// 表单字段在创建时就用通道数据填好,不依赖 onAppear(避免第一帧显示空表单)。
|
||||||
/// groupId:新建时默认加入的通道组(通道组卡片上的「添加地址」)
|
/// groupId:新建时固定加入的通道组(通道组卡片上的「添加地址」)
|
||||||
init(vm: PortRelayViewModel, tunnel: TunnelItem?, isPresented: Binding<Bool>, groupId: String? = nil,
|
init(vm: PortRelayViewModel, tunnel: TunnelItem?, isPresented: Binding<Bool>, groupId: String? = nil,
|
||||||
draftName: String = "", draftRows: [(String, String)] = [], onCreated: ((AccessScope) -> Void)? = nil) {
|
draftName: String = "", draftRows: [(String, String)] = [], onCreated: ((AccessScope) -> Void)? = nil) {
|
||||||
_vm = ObservedObject(wrappedValue: vm)
|
_vm = ObservedObject(wrappedValue: vm)
|
||||||
self.tunnel = tunnel
|
self.tunnel = tunnel
|
||||||
|
self.fixedGroupId = tunnel == nil ? groupId : nil
|
||||||
self.onCreated = onCreated
|
self.onCreated = onCreated
|
||||||
_isPresented = isPresented
|
_isPresented = isPresented
|
||||||
let wl = tunnel?.whitelist ?? []
|
let wl = tunnel?.whitelist ?? []
|
||||||
@@ -3837,7 +3841,7 @@ struct TunnelEditSheet: View {
|
|||||||
_idleCloseMinutes = State(initialValue: tunnel?.effective_idle_close_minutes ?? 30)
|
_idleCloseMinutes = State(initialValue: tunnel?.effective_idle_close_minutes ?? 30)
|
||||||
_maxBandwidthMB = State(initialValue: tunnel?.max_bandwidth_mb ?? 0)
|
_maxBandwidthMB = State(initialValue: tunnel?.max_bandwidth_mb ?? 0)
|
||||||
_whitelistString = State(initialValue: mode == .specific ? wl.joined(separator: ", ") : "")
|
_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))
|
_groupChoice = State(initialValue: gid.isEmpty ? .none : .existing(gid))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3856,20 +3860,26 @@ struct TunnelEditSheet: View {
|
|||||||
return isEdit && !n.isEmpty ? n : trimmedName
|
return isEdit && !n.isEmpty ? n : trimmedName
|
||||||
}
|
}
|
||||||
|
|
||||||
var existingGroupName: String? {
|
/// 实际生效的通道组:编辑时按选择;新建时由入口和地址数决定
|
||||||
if case .existing(let id) = groupChoice { return vm.group(id)?.name }
|
var effectiveChoice: GroupChoice {
|
||||||
return nil
|
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 {
|
var nameTitle: String {
|
||||||
isBatch && groupChoice == .new ? "通道组名称" : "通道名称"
|
if fixedGroupName != nil { return "通道名称(可选)" }
|
||||||
|
return isBatch ? "通道组名称" : "通道名称"
|
||||||
}
|
}
|
||||||
|
|
||||||
var namePlaceholder: String {
|
var namePlaceholder: String {
|
||||||
if !isEdit, let g = existingGroupName {
|
if let g = fixedGroupName { return "留空则命名为「\(g) (主机)」" }
|
||||||
return "可不填,默认「\(g) (主机)」"
|
return isBatch ? "如:订单服务集群" : "如:订单数据库"
|
||||||
}
|
|
||||||
return isBatch ? "例如:订单服务" : "例如:MySQL 实习库"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var canSave: Bool {
|
var canSave: Bool {
|
||||||
@@ -3878,12 +3888,18 @@ struct TunnelEditSheet: View {
|
|||||||
return !trimmedName.isEmpty && !composedTarget.isEmpty && (groupChoice != .new || !resolvedGroupName.isEmpty)
|
return !trimmedName.isEmpty && !composedTarget.isEmpty && (groupChoice != .new || !resolvedGroupName.isEmpty)
|
||||||
}
|
}
|
||||||
if plan.isEmpty || planError != nil { return false }
|
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 {
|
func makeForm(port: Int, whitelist: [String]) -> TunnelForm {
|
||||||
var groupId = "", newGroup = ""
|
var groupId = "", newGroup = ""
|
||||||
switch groupChoice {
|
switch effectiveChoice {
|
||||||
case .none: break
|
case .none: break
|
||||||
case .new: newGroup = resolvedGroupName
|
case .new: newGroup = resolvedGroupName
|
||||||
case .existing(let id): groupId = id
|
case .existing(let id): groupId = id
|
||||||
@@ -3899,8 +3915,9 @@ struct TunnelEditSheet: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 14) {
|
VStack(alignment: .leading, spacing: 14) {
|
||||||
Text(isEdit ? "编辑通道" : "添加通道")
|
Text(verbatim: isEdit ? "编辑通道" : (fixedGroupName.map { "添加地址到「\($0)」" } ?? "添加通道"))
|
||||||
.font(.system(size: 15, weight: .bold))
|
.font(.system(size: 15, weight: .bold))
|
||||||
|
.lineLimit(1)
|
||||||
|
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
field(nameTitle) {
|
field(nameTitle) {
|
||||||
@@ -3908,7 +3925,7 @@ struct TunnelEditSheet: View {
|
|||||||
.textFieldStyle(.roundedBorder)
|
.textFieldStyle(.roundedBorder)
|
||||||
}
|
}
|
||||||
field("关联责任人") {
|
field("关联责任人") {
|
||||||
TextField("例如:张三 / 数据组", text: $owner)
|
TextField("负责人或团队,如:数据组", text: $owner)
|
||||||
.textFieldStyle(.roundedBorder)
|
.textFieldStyle(.roundedBorder)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3928,7 +3945,9 @@ struct TunnelEditSheet: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
groupSection
|
if isEdit {
|
||||||
|
groupSection
|
||||||
|
}
|
||||||
closeSection
|
closeSection
|
||||||
bandwidthSection
|
bandwidthSection
|
||||||
accessSection
|
accessSection
|
||||||
@@ -3943,7 +3962,7 @@ struct TunnelEditSheet: View {
|
|||||||
Spacer()
|
Spacer()
|
||||||
Button("取消") { isPresented = false }
|
Button("取消") { isPresented = false }
|
||||||
.controlSize(.small)
|
.controlSize(.small)
|
||||||
Button(isSaving ? "保存中…" : (isBatch ? "创建 \(plan.count) 个通道" : "保存")) { save() }
|
Button(saveTitle) { save() }
|
||||||
.buttonStyle(.borderedProminent)
|
.buttonStyle(.borderedProminent)
|
||||||
.controlSize(.small)
|
.controlSize(.small)
|
||||||
.disabled(!canSave)
|
.disabled(!canSave)
|
||||||
@@ -3952,13 +3971,8 @@ struct TunnelEditSheet: View {
|
|||||||
}
|
}
|
||||||
.padding(20)
|
.padding(20)
|
||||||
.frame(width: 520)
|
.frame(width: 520)
|
||||||
.task(id: "\(composedTarget)|\(trimmedName)|\(groupChoice)") { await refreshPlan() }
|
.task(id: "\(composedTarget)|\(trimmedName)") { await refreshPlan() }
|
||||||
.onChange(of: composedTarget) { _ in probes = [:] }
|
.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
|
// MARK: Target list
|
||||||
@@ -3994,7 +4008,7 @@ struct TunnelEditSheet: View {
|
|||||||
.font(.system(size: 12))
|
.font(.system(size: 12))
|
||||||
}
|
}
|
||||||
.buttonStyle(.borderless)
|
.buttonStyle(.borderless)
|
||||||
.help("可以一次粘贴多个 IP(每行一个,或用逗号分隔),会自动拆成多行")
|
.help("也可以直接粘贴多个地址(每行一个或逗号分隔,可带 :端口),会自动拆成多行")
|
||||||
Spacer()
|
Spacer()
|
||||||
probeButton
|
probeButton
|
||||||
}
|
}
|
||||||
@@ -4012,6 +4026,11 @@ struct TunnelEditSheet: View {
|
|||||||
} else if !plan.isEmpty {
|
} else if !plan.isEmpty {
|
||||||
planList
|
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 {
|
func targetRow(_ row: TargetRow) -> some View {
|
||||||
HStack(spacing: 8) {
|
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)
|
.textFieldStyle(.roundedBorder)
|
||||||
.focused($focusedRow, equals: row.id)
|
.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)
|
.textFieldStyle(.roundedBorder)
|
||||||
.frame(width: 150)
|
.frame(width: 150)
|
||||||
if !isEdit {
|
if !isEdit {
|
||||||
@@ -4137,6 +4156,14 @@ struct TunnelEditSheet: View {
|
|||||||
|
|
||||||
// MARK: Sections
|
// 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 {
|
var groupSection: some View {
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
Text("通道组")
|
Text("通道组")
|
||||||
@@ -4144,12 +4171,8 @@ struct TunnelEditSheet: View {
|
|||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
Picker("", selection: $groupChoice) {
|
Picker("", selection: $groupChoice) {
|
||||||
if !isBatch {
|
Text("不分组").tag(GroupChoice.none)
|
||||||
Text("不分组").tag(GroupChoice.none)
|
Text("新建通道组").tag(GroupChoice.new)
|
||||||
}
|
|
||||||
if isBatch || isEdit {
|
|
||||||
Text("新建通道组").tag(GroupChoice.new)
|
|
||||||
}
|
|
||||||
if !vm.groups.isEmpty {
|
if !vm.groups.isEmpty {
|
||||||
Divider()
|
Divider()
|
||||||
ForEach(vm.groups) { g in
|
ForEach(vm.groups) { g in
|
||||||
@@ -4159,8 +4182,8 @@ struct TunnelEditSheet: View {
|
|||||||
}
|
}
|
||||||
.labelsHidden()
|
.labelsHidden()
|
||||||
.fixedSize()
|
.fixedSize()
|
||||||
if isEdit && groupChoice == .new {
|
if groupChoice == .new {
|
||||||
TextField(trimmedName.isEmpty ? "通道组名称" : "组名,默认「\(trimmedName)」", text: $newGroupName)
|
TextField(trimmedName.isEmpty ? "通道组名称" : "通道组名称,留空则用「\(trimmedName)」", text: $newGroupName)
|
||||||
.textFieldStyle(.roundedBorder)
|
.textFieldStyle(.roundedBorder)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4174,13 +4197,9 @@ struct TunnelEditSheet: View {
|
|||||||
|
|
||||||
var groupHint: String? {
|
var groupHint: String? {
|
||||||
switch groupChoice {
|
switch groupChoice {
|
||||||
case .none:
|
case .none: return nil
|
||||||
return nil
|
case .new: return "新建通道组并移入;之后可在组卡片的「编辑」里把其它通道加入"
|
||||||
case .new where isBatch:
|
case .existing: return "组内通道共用邀请和授权,同事授权一次即可访问组内全部地址"
|
||||||
let n = trimmedName.isEmpty ? "(填写上面的名称)" : "「\(trimmedName)」"
|
|
||||||
return "\(plan.count) 个地址会放进通道组\(n):共用邀请和授权,同事授权一次即可访问全部地址"
|
|
||||||
default:
|
|
||||||
return "组内通道共用邀请和授权,同事授权一次即可访问组内全部地址"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4244,7 +4263,7 @@ struct TunnelEditSheet: View {
|
|||||||
.font(.system(size: 11))
|
.font(.system(size: 11))
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
case .specific:
|
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)
|
.textFieldStyle(.roundedBorder)
|
||||||
Text("名单内的设备始终可以访问;其他人仍可通过邀请链接临时授权。")
|
Text("名单内的设备始终可以访问;其他人仍可通过邀请链接临时授权。")
|
||||||
.font(.system(size: 11))
|
.font(.system(size: 11))
|
||||||
@@ -4328,7 +4347,8 @@ struct TunnelEditSheet: View {
|
|||||||
}
|
}
|
||||||
guard let wl = whitelist() else { return }
|
guard let wl = whitelist() else { return }
|
||||||
let form = makeForm(port: port, whitelist: wl)
|
let form = makeForm(port: port, whitelist: wl)
|
||||||
let joinsExisting = existingGroupName != nil
|
// 加入已有组时组的授权自动覆盖新地址,不需要再发邀请
|
||||||
|
let joinsExisting = fixedGroupId != nil
|
||||||
isSaving = true
|
isSaving = true
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
Task {
|
Task {
|
||||||
@@ -4402,14 +4422,14 @@ struct GroupEditSheet: View {
|
|||||||
Text("通道组名称")
|
Text("通道组名称")
|
||||||
.font(.system(size: 12))
|
.font(.system(size: 12))
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
TextField("例如:订单服务集群", text: $name)
|
TextField("如:订单服务集群", text: $name)
|
||||||
.textFieldStyle(.roundedBorder)
|
.textFieldStyle(.roundedBorder)
|
||||||
}
|
}
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
Text("关联责任人")
|
Text("关联责任人")
|
||||||
.font(.system(size: 12))
|
.font(.system(size: 12))
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
TextField("例如:张三 / 运维组", text: $owner)
|
TextField("负责人或团队,如:运维组", text: $owner)
|
||||||
.textFieldStyle(.roundedBorder)
|
.textFieldStyle(.roundedBorder)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4612,7 +4632,7 @@ struct InviteSheet: View {
|
|||||||
Text("备注(发给谁,可选)")
|
Text("备注(发给谁,可选)")
|
||||||
.font(.system(size: 12))
|
.font(.system(size: 12))
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
TextField("例如:张三", text: $note)
|
TextField("如:张三", text: $note)
|
||||||
.textFieldStyle(.roundedBorder)
|
.textFieldStyle(.roundedBorder)
|
||||||
}
|
}
|
||||||
HStack(spacing: 6) {
|
HStack(spacing: 6) {
|
||||||
@@ -4741,7 +4761,7 @@ struct SettingsSheet: View {
|
|||||||
Text("服务器控制地址 (地址:端口)")
|
Text("服务器控制地址 (地址:端口)")
|
||||||
.font(.system(size: 12))
|
.font(.system(size: 12))
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
TextField("例如 gw.example.com:33000", text: $serverAddr)
|
TextField("地址:端口,如 gw.example.com:33000", text: $serverAddr)
|
||||||
.textFieldStyle(.roundedBorder)
|
.textFieldStyle(.roundedBorder)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user