diff --git a/frontend/index.html b/frontend/index.html index 07b51c5..35e9059 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -43,10 +43,6 @@
-
- - 离线 -
@@ -248,7 +244,7 @@ data-i18n-aria-label-zh="实时连接状态" data-i18n-aria-label-en="Live connection status" > - +
无活动会话 选择目标并连接 @@ -297,9 +293,10 @@ aria-controls="file-manager-panel" aria-expanded="false" tabindex="0" - data-i18n-zh="文件管理" - data-i18n-en="Files" - >文件管理 + > + + 文件管理 + + > + + 会话日志 +
diff --git a/frontend/src/main.ts b/frontend/src/main.ts index e044422..281aa65 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -272,8 +272,6 @@ const ui = { formError: element('form-error'), connect: element('connect-button'), shareLink: element('share-link'), - globalStatus: element('global-status'), - globalStatusLabel: element('global-status-label'), languageToggle: element('language-toggle'), themeToggle: element('theme-toggle'), sessionTitle: element('session-title'), @@ -1049,8 +1047,7 @@ function toast(message: string, kind: 'info' | 'error' = 'info'): void { function setState(state: ConnectionState, label?: string): void { connectionState = state; - ui.globalStatus.dataset.state = state; - ui.globalStatusLabel.textContent = label ?? ({ + const stateLabel = label ?? ({ idle: bilingual('离线', 'Offline'), connecting: bilingual('连接中', 'Connecting'), connected: bilingual('在线', 'Online'), @@ -1058,6 +1055,8 @@ function setState(state: ConnectionState, label?: string): void { error: bilingual('错误', 'Error'), } satisfies Record)[state]; ui.liveOrb.className = `live-orb ${state}`; + ui.liveOrb.setAttribute('aria-label', stateLabel); + ui.liveOrb.title = stateLabel; const control = resolveConnectionControl(state, historyPasswordLoading); const controlLabel = control.action === 'cancel' ? bilingual('取消连接', 'Cancel connection') @@ -1489,7 +1488,7 @@ async function connect(): Promise { ui.sessionSubtitle.textContent = localize(currentSessionSubtitle); ui.metricRtt.textContent = '--'; ui.metricHostKey.textContent = '--'; - event(bilingual(`正在连接 ${username}@${host}:${port}`, `Starting ${username}@${host}:${port}`), 'connect'); + event(bilingual(`正在连接 ${currentTargetLabel}`, `Starting ${currentTargetLabel}`), 'connect'); try { const password = ui.password.value; diff --git a/frontend/src/style.css b/frontend/src/style.css index df8b42f..5cf7f9d 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -147,8 +147,7 @@ button:disabled { .event-bar, .file-picker, .profile-main, -.profile-meta, -.status-pill { +.profile-meta { display: flex; align-items: center; } @@ -289,69 +288,72 @@ button:disabled { stroke: none; } -.status-pill { - gap: 7px; - min-width: 92px; - justify-content: center; - padding: 7px 11px; - border: 1px solid var(--border); - border-radius: 999px; - background: var(--surface); - color: var(--muted); - font-family: var(--mono); - font-size: 10px; - font-weight: 700; - text-transform: uppercase; -} - -.status-dot, .live-orb { + position: relative; display: block; - width: 7px; - height: 7px; + width: 9px; + height: 9px; flex: 0 0 auto; + margin: 0 2px; border-radius: 50%; background: var(--muted-2); - box-shadow: 0 0 0 3px color-mix(in srgb, var(--muted-2) 14%, transparent); + box-shadow: + 0 0 0 3px color-mix(in srgb, var(--muted-2) 16%, transparent), + inset 0 1px 1px rgba(255, 255, 255, 0.25); + transition: background 180ms ease, box-shadow 180ms ease, transform 180ms ease; +} + +.live-orb::after { + position: absolute; + inset: -5px; + border: 1px solid transparent; + border-radius: inherit; + content: ''; + pointer-events: none; } -.status-pill[data-state='connecting'] .status-dot, .live-orb.connecting { background: var(--warning); - box-shadow: 0 0 0 3px color-mix(in srgb, var(--warning) 14%, transparent); - animation: pulse 1.25s ease-in-out infinite; + box-shadow: + 0 0 0 3px color-mix(in srgb, var(--warning) 18%, transparent), + 0 0 12px color-mix(in srgb, var(--warning) 42%, transparent); } -.status-pill[data-state='disconnecting'] { - color: var(--danger); +.live-orb.connecting::after { + border-color: color-mix(in srgb, var(--warning) 50%, transparent); + animation: live-orb-ring 1.35s ease-out infinite; } -.status-pill[data-state='disconnecting'] .status-dot, .live-orb.disconnecting { background: var(--danger); - box-shadow: 0 0 0 3px var(--danger-dim); - animation: pulse 1.25s ease-in-out infinite; + box-shadow: + 0 0 0 3px var(--danger-dim), + 0 0 10px color-mix(in srgb, var(--danger) 35%, transparent); + animation: live-orb-pulse 1.1s ease-in-out infinite; } -.status-pill[data-state='connected'] { - border-color: color-mix(in srgb, var(--accent) 30%, var(--border)); - color: var(--accent); -} - -.status-pill[data-state='connected'] .status-dot, .live-orb.connected { background: var(--accent); - box-shadow: 0 0 0 3px var(--accent-dim), 0 0 12px color-mix(in srgb, var(--accent) 50%, transparent); + box-shadow: + 0 0 0 3px var(--accent-dim), + 0 0 14px color-mix(in srgb, var(--accent) 55%, transparent), + inset 0 1px 1px rgba(255, 255, 255, 0.35); } -.status-pill[data-state='error'] { - color: var(--danger); -} - -.status-pill[data-state='error'] .status-dot, .live-orb.error { background: var(--danger); - box-shadow: 0 0 0 3px var(--danger-dim); + box-shadow: + 0 0 0 3px var(--danger-dim), + 0 0 12px color-mix(in srgb, var(--danger) 45%, transparent); +} + +@keyframes live-orb-ring { + 0% { opacity: 0.9; transform: scale(0.55); } + 75%, 100% { opacity: 0; transform: scale(1.35); } +} + +@keyframes live-orb-pulse { + 50% { transform: scale(0.72); opacity: 0.7; } } .app-shell { @@ -1141,7 +1143,11 @@ button:disabled { .dock-tab { position: relative; + display: inline-flex; min-width: 92px; + align-items: center; + justify-content: center; + gap: 6px; padding: 0 13px; border: 0; border-left: 1px solid var(--border); @@ -1151,6 +1157,17 @@ button:disabled { font-size: inherit; } +.dock-tab-icon { + width: 14px; + height: 14px; + flex: 0 0 auto; + fill: none; + stroke: currentColor; + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: 1.7; +} + .dock-tab:hover { background: var(--surface-3); color: var(--text); @@ -1665,10 +1682,6 @@ button:disabled { display: none; } -@keyframes pulse { - 50% { opacity: 0.45; } -} - @keyframes toast-in { from { opacity: 0; transform: translateY(8px); } } @@ -1810,15 +1823,6 @@ button:disabled { display: none; } - .status-pill { - min-width: 0; - padding: 7px 9px; - } - - #global-status-label { - display: none; - } - .field-grid.two { grid-template-columns: 1fr; } diff --git a/src/backend/durable-object.ts b/src/backend/durable-object.ts index 45bf00f..6271264 100644 --- a/src/backend/durable-object.ts +++ b/src/backend/durable-object.ts @@ -130,7 +130,11 @@ export class SSHSessionDO implements DurableObject { if (config.port === 25) throw new Error('Cloudflare Workers cannot connect to outbound TCP port 25'); const verifiedAddresses = await assertPublicTarget(config.host); this.assertConnectionActive(ws, pending); - ws.send(JSON.stringify({ type: 'status', event: 'tcp_connecting', message: `Connecting to ${config.host}:${config.port}` })); + ws.send(JSON.stringify({ + type: 'status', + event: 'tcp_connecting', + message: `Connecting to ${toSocketHostname(config.host)}:${config.port}`, + })); // Connect to the exact address that passed SSRF validation. Keeping the // original hostname only for host-key pinning prevents DNS rebinding.