Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 60 additions & 40 deletions frontend/src/components/PortForwardDialog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,8 @@ function applyForwardFormError(code: ForwardFormError) {
forwardFormMessage.value = code ? t(`forwards.error.${code}`) : "";
}

const submitting = ref(false);

async function submitForward() {
const error = validateForwardForm(forwardForm);
if (error) {
Expand All @@ -92,7 +94,14 @@ async function submitForward() {
});
return;
}
if (!props.sessionId) return;
// 无会话时按钮本身已禁用,这里再兜底一次并给出可见提示,避免静默无响应。
if (!props.sessionId) {
forwardFormMessage.value = t("forwards.error.noSession");
return;
}
if (submitting.value) return;
submitting.value = true;
forwardFormMessage.value = "";
try {
const payload = await window.dbxPlugin.invoke(
"ssh/forward/start",
Expand All @@ -104,8 +113,11 @@ async function submitForward() {
}
forwardFormMessage.value = "";
} catch (cause) {
forwardFormMessage.value = "";
// 既在弹窗内显示错误文案,又向父级冒泡,确保任何失败都有可见反馈。
forwardFormMessage.value = cause instanceof Error ? cause.message : String(cause);
emit("error", cause);
} finally {
submitting.value = false;
}
}

Expand Down Expand Up @@ -174,46 +186,54 @@ watch(
<button class="forward-stop" :title="t('forwards.stop')" :disabled="forwardsBusyId !== null" @click="stopForward(row.id)"><Square v-if="forwardsBusyId === row.id" /><X v-else /></button>
</li>
</ul>
<form class="forward-form" :disabled="!props.sessionId" @submit.prevent="submitForward">
<div class="forward-form-row forward-form-kinds">
<label class="forward-kind-picker">
<input v-model="forwardForm.kind" type="radio" value="local" />{{ t("forwards.local") }}
</label>
<label class="forward-kind-picker">
<input v-model="forwardForm.kind" type="radio" value="remote" />{{ t("forwards.remote") }}
</label>
</div>
<div class="forward-form-row forward-form-addresses">
<label class="forward-field">
<span>{{ t("forwards.listen") }}</span>
<span class="forward-field-pair">
<input
v-model="forwardForm.listenHost"
:placeholder="t('forwards.listenHostPlaceholder')"
:list="hostOptionListId"
/>
<!-- ip+网卡名同框:datalist 候选 value=可绑定 IP,网卡名作说明
文案;手输与点选同一输入框,探测失败自动退化为纯手输。 -->
<datalist :id="hostOptionListId">
<option value="0.0.0.0">{{ t("forwards.allInterfaces") }}</option>
<option v-for="iface in interfaces" :key="iface.addr" :value="iface.addr">
{{ iface.isLoopback ? t("forwards.loopback") : iface.name }}
</option>
</datalist>
<input v-model="forwardForm.listenPort" inputmode="numeric" :placeholder="t('forwards.portPlaceholder')" />
</span>
</label>
<label class="forward-field">
<span>{{ t("forwards.target") }}</span>
<span class="forward-field-pair">
<input v-model="forwardForm.targetHost" :placeholder="t('forwards.targetHostPlaceholder')" />
<input v-model="forwardForm.targetPort" inputmode="numeric" :placeholder="t('forwards.portPlaceholder')" />
</span>
</label>
</div>
<form class="forward-form" @submit.prevent="submitForward">
<!-- fieldset 的 disabled 才是真正禁用内部控件;原 <form disabled> 是无效
属性,会导致输入框仍可用、只有按钮变灰,造成「填了内容却点不动」的
静默死按钮。无会话或提交中统一禁用并给出可见提示。 -->
<fieldset class="forward-form-fields" :disabled="!props.sessionId || submitting" style="border:0;margin:0;padding:0;min-inline-size:0">
<div class="forward-form-row forward-form-kinds">
<label class="forward-kind-picker">
<input v-model="forwardForm.kind" type="radio" value="local" />{{ t("forwards.local") }}
</label>
<label class="forward-kind-picker">
<input v-model="forwardForm.kind" type="radio" value="remote" />{{ t("forwards.remote") }}
</label>
</div>
<div class="forward-form-row forward-form-addresses">
<label class="forward-field">
<span>{{ t("forwards.listen") }}</span>
<span class="forward-field-pair">
<input
v-model="forwardForm.listenHost"
:placeholder="t('forwards.listenHostPlaceholder')"
:list="hostOptionListId"
/>
<!-- ip+网卡名同框:datalist 候选 value=可绑定 IP,网卡名作说明
文案;手输与点选同一输入框,探测失败自动退化为纯手输。 -->
<datalist :id="hostOptionListId">
<option value="0.0.0.0">{{ t("forwards.allInterfaces") }}</option>
<option v-for="iface in interfaces" :key="iface.addr" :value="iface.addr">
{{ iface.isLoopback ? t("forwards.loopback") : iface.name }}
</option>
</datalist>
<input v-model="forwardForm.listenPort" inputmode="numeric" :placeholder="t('forwards.portPlaceholder')" />
</span>
</label>
<label class="forward-field">
<span>{{ t("forwards.target") }}</span>
<span class="forward-field-pair">
<input v-model="forwardForm.targetHost" :placeholder="t('forwards.targetHostPlaceholder')" />
<input v-model="forwardForm.targetPort" inputmode="numeric" :placeholder="t('forwards.portPlaceholder')" />
</span>
</label>
</div>
</fieldset>
<p v-if="!props.sessionId" class="forward-form-hint">{{ t("forwards.error.noSession") }}</p>
<p v-if="forwardFormMessage" class="forward-form-error">{{ forwardFormMessage }}</p>
<footer>
<button type="submit" class="primary-button" :disabled="!props.sessionId"><Plus />{{ t("forwards.add") }}</button>
<button type="submit" class="primary-button" :disabled="!props.sessionId || submitting">
<Loader2 v-if="submitting" class="spinning" /><Plus v-else />{{ t("forwards.add") }}
</button>
</footer>
</form>
</div>
Expand Down
14 changes: 7 additions & 7 deletions frontend/src/lib/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ export const messages = {
allInterfaces: "All interfaces (0.0.0.0)",
loopback: "loopback",
state: { starting: "Starting", active: "Active", stopped: "Stopped", error: "Error" },
error: { targetHost: "Target host must be a valid IPv4, IPv6 or hostname.", listenHost: "Listen host must be a valid IPv4, IPv6 or hostname.", port: "Ports must be integers within 0-65535 (0 = auto).", conflict: "Listen endpoint {route} is already forwarded ({existing})." },
error: { targetHost: "Target host must be a valid IPv4, IPv6 or hostname.", listenHost: "Listen host must be a valid IPv4, IPv6 or hostname.", port: "Ports must be integers within 0-65535 (0 = auto).", conflict: "Listen endpoint {route} is already forwarded ({existing}).", noSession: "Establish and keep an SSH session before adding a port mapping." },
},
settingsLoadFailed: "Could not load SSH settings. Retry before saving.",
alertTriageLoadFailed: "Could not analyze this alert: {error}",
Expand Down Expand Up @@ -554,7 +554,7 @@ export const messages = {
allInterfaces: "Todas las interfaces (0.0.0.0)",
loopback: "loopback",
state: { starting: "Iniciando", active: "Activa", stopped: "Detenida", error: "Error" },
error: { targetHost: "El host de destino debe ser IPv4, IPv6 o un nombre de host válido.", listenHost: "El host de escucha debe ser IPv4, IPv6 o un nombre de host válido.", port: "Los puertos deben ser enteros entre 0 y 65535 (0 = auto).", conflict: "El punto de escucha {route} ya está redirigido ({existing})." },
error: { targetHost: "El host de destino debe ser IPv4, IPv6 o un nombre de host válido.", listenHost: "El host de escucha debe ser IPv4, IPv6 o un nombre de host válido.", port: "Los puertos deben ser enteros entre 0 y 65535 (0 = auto).", conflict: "El punto de escucha {route} ya está redirigido ({existing}).", noSession: "Establezca y mantenga una sesión SSH antes de añadir una asignación de puertos." },
},
settingsLoadFailed: "No se pudo cargar la configuración SSH. Vuelve a intentarlo antes de guardar.",
alertTriageLoadFailed: "No se pudo analizar esta alerta: {error}",
Expand Down Expand Up @@ -1028,7 +1028,7 @@ export const messages = {
allInterfaces: "Tutte le interfacce (0.0.0.0)",
loopback: "loopback",
state: { starting: "Avvio", active: "Attivo", stopped: "Interrotto", error: "Errore" },
error: { targetHost: "L'host di destinazione deve essere IPv4, IPv6 o un nome host valido.", listenHost: "L'host di ascolto deve essere IPv4, IPv6 o un nome host valido.", port: "Le porte devono essere interi tra 0 e 65535 (0 = auto).", conflict: "L'endpoint di ascolto {route} è già inoltrato ({existing})." },
error: { targetHost: "L'host di destinazione deve essere IPv4, IPv6 o un nome host valido.", listenHost: "L'host di ascolto deve essere IPv4, IPv6 o un nome host valido.", port: "Le porte devono essere interi tra 0 e 65535 (0 = auto).", conflict: "L'endpoint di ascolto {route} è già inoltrato ({existing}).", noSession: "Stabilire e mantenere una sessione SSH prima di aggiungere una mappatura delle porte." },
},
settingsLoadFailed: "Impossibile caricare le impostazioni SSH. Riprova prima di salvare.",
alertTriageLoadFailed: "Impossibile analizzare questo avviso: {error}",
Expand Down Expand Up @@ -1502,7 +1502,7 @@ export const messages = {
allInterfaces: "すべてのインターフェース (0.0.0.0)",
loopback: "ループバック",
state: { starting: "開始中", active: "有効", stopped: "停止", error: "エラー" },
error: { targetHost: "転送先ホストは有効な IPv4・IPv6・ホスト名で指定してください。", listenHost: "待ち受けホストは有効な IPv4・IPv6・ホスト名で指定してください。", port: "ポートは 0〜65535 の整数で指定してください(0 = 自動)。", conflict: "待ち受けエンドポイント {route} は既に転送に使われています({existing})。" },
error: { targetHost: "転送先ホストは有効な IPv4・IPv6・ホスト名で指定してください。", listenHost: "待ち受けホストは有効な IPv4・IPv6・ホスト名で指定してください。", port: "ポートは 0〜65535 の整数で指定してください(0 = 自動)。", conflict: "待ち受けエンドポイント {route} は既に転送に使われています({existing})。", noSession: "ポート転送を追加する前に、SSH セッションを確立して保持してください。" },
},
settingsLoadFailed: "SSH 設定を読み込めませんでした。保存する前に再試行してください。",
alertTriageLoadFailed: "このアラートを分析できませんでした:{error}",
Expand Down Expand Up @@ -1976,7 +1976,7 @@ export const messages = {
allInterfaces: "Todas as interfaces (0.0.0.0)",
loopback: "loopback",
state: { starting: "Iniciando", active: "Ativo", stopped: "Parado", error: "Erro" },
error: { targetHost: "O host de destino deve ser IPv4, IPv6 ou um nome de host válido.", listenHost: "O host de escuta deve ser IPv4, IPv6 ou um nome de host válido.", port: "As portas devem ser inteiros entre 0 e 65535 (0 = automático).", conflict: "O ponto de escuta {route} já está encaminhado ({existing})." },
error: { targetHost: "O host de destino deve ser IPv4, IPv6 ou um nome de host válido.", listenHost: "O host de escuta deve ser IPv4, IPv6 ou um nome de host válido.", port: "As portas devem ser inteiros entre 0 e 65535 (0 = automático).", conflict: "O ponto de escuta {route} já está encaminhado ({existing}).", noSession: "Estabeleça e mantenha uma sessão SSH antes de adicionar um mapeamento de porta." },
},
settingsLoadFailed: "Não foi possível carregar as configurações SSH. Tente novamente antes de salvar.",
alertTriageLoadFailed: "Não foi possível analisar este alerta: {error}",
Expand Down Expand Up @@ -2450,7 +2450,7 @@ export const messages = {
allInterfaces: "所有接口 (0.0.0.0)",
loopback: "回环",
state: { starting: "启动中", active: "运行中", stopped: "已停止", error: "错误" },
error: { targetHost: "目标地址必须是合法的 IPv4、IPv6 或主机名。", listenHost: "监听地址必须是合法的 IPv4、IPv6 或主机名。", port: "端口必须是 0-65535 的整数(0 = 自动)。", conflict: "监听端点 {route} 已有映射({existing})。" },
error: { targetHost: "目标地址必须是合法的 IPv4、IPv6 或主机名。", listenHost: "监听地址必须是合法的 IPv4、IPv6 或主机名。", port: "端口必须是 0-65535 的整数(0 = 自动)。", conflict: "监听端点 {route} 已有映射({existing})。", noSession: "请先建立并保持 SSH 会话,再添加端口映射。" },
},
settingsLoadFailed: "SSH 设置加载失败,请重试后再保存。",
alertTriageLoadFailed: "告警分析失败:{error}",
Expand Down Expand Up @@ -2931,7 +2931,7 @@ export const messages = {
allInterfaces: "所有介面 (0.0.0.0)",
loopback: "回環",
state: { starting: "啟動中", active: "執行中", stopped: "已停止", error: "錯誤" },
error: { targetHost: "目標位址必須是合法的 IPv4、IPv6 或主機名稱。", listenHost: "監聽位址必須是合法的 IPv4、IPv6 或主機名稱。", port: "連接埠必須是 0-65535 的整數(0 = 自動)。", conflict: "監聽端點 {route} 已有映射({existing})。" },
error: { targetHost: "目標位址必須是合法的 IPv4、IPv6 或主機名稱。", listenHost: "監聽位址必須是合法的 IPv4、IPv6 或主機名稱。", port: "連接埠必須是 0-65535 的整數(0 = 自動)。", conflict: "監聽端點 {route} 已有映射({existing})。", noSession: "請先建立並保持 SSH 連線,再新增連接埠映射。" },
},
settingsLoadFailed: "SSH 設定載入失敗,請重試後再儲存。",
alertTriageLoadFailed: "告警分析失敗:{error}",
Expand Down
Loading