diff --git a/backend/app/api/v1/auth.py b/backend/app/api/v1/auth.py index b6824db5..14d7690d 100644 --- a/backend/app/api/v1/auth.py +++ b/backend/app/api/v1/auth.py @@ -9,7 +9,7 @@ import uuid from app.core.config import settings from app.core.login_crypto import create_login_challenge, decrypt_login_payload, get_public_key_pem -from app.core.request_context import resolve_ctms_client_type +from app.core.request_context import resolve_client_ip, resolve_ctms_client_type from app.core.security import create_access_token, decode_token, decode_token_allow_expired, oauth2_scheme, verify_password from app.core.deps import get_current_user, get_db_session from app.crud import user as user_crud @@ -379,6 +379,7 @@ async def heartbeat_login_session( return { "status": "online", "last_seen_at": session.last_seen_at.isoformat(), + "client_ip": resolve_client_ip(request), } diff --git a/backend/tests/test_user_login_sessions.py b/backend/tests/test_user_login_sessions.py index 343ace6e..e1420621 100644 --- a/backend/tests/test_user_login_sessions.py +++ b/backend/tests/test_user_login_sessions.py @@ -1,5 +1,6 @@ from datetime import datetime, timedelta, timezone import uuid +from pathlib import Path import pytest @@ -20,6 +21,13 @@ def test_legacy_session_id_is_stable_without_storing_a_token(): assert isinstance(first, uuid.UUID) +def test_session_heartbeat_returns_server_observed_client_ip(): + auth_source = (Path(__file__).resolve().parents[1] / "app" / "api" / "v1" / "auth.py").read_text() + + assert "resolve_client_ip(request)" in auth_source + assert '"client_ip": resolve_client_ip(request)' in auth_source + + @pytest.mark.asyncio async def test_login_summary_marks_recent_unended_sessions_online(db_session, monkeypatch): now = datetime.now(timezone.utc) diff --git a/frontend/src/api/authClient.ts b/frontend/src/api/authClient.ts index e7d6da99..39a07541 100644 --- a/frontend/src/api/authClient.ts +++ b/frontend/src/api/authClient.ts @@ -36,6 +36,7 @@ export type LoginKeyResponse = { export type SessionHeartbeatResponse = { status: "online"; last_seen_at: string; + client_ip: string | null; }; export type EncryptedPasswordRequest = { diff --git a/frontend/src/components/AccountConnectionStatus.test.ts b/frontend/src/components/AccountConnectionStatus.test.ts index 5b30f13b..d2d59402 100644 --- a/frontend/src/components/AccountConnectionStatus.test.ts +++ b/frontend/src/components/AccountConnectionStatus.test.ts @@ -14,6 +14,22 @@ describe("account connection status", () => { expect(component).toContain("当前账号与连接状态"); expect(component).toContain("API 延迟"); expect(component).toContain("会话状态"); + expect(component).toContain("mode === 'network'"); + expect(component).toContain("当前 IP"); + expect(component).toContain("clientIpLabel"); + expect(component).toContain("连接安全检查"); + expect(component).toContain("runSecurityCheck"); + expect(component).toContain("evaluateConnectionSecurity"); + expect(component).toContain("const securityChecked = ref(true)"); + expect(component).toContain("const securityCheckExpanded = ref(true)"); + expect(component).toContain("const nowTs = ref(Date.now())"); + expect(component).toContain("const tokenExpiryAt = computed"); + expect(component).toContain("const credentialDeadlineAt = computed"); + expect(component).toContain("return session.lastUserActiveAt + IDLE_TIMEOUT_MINUTES * 60 * 1000"); + expect(component).toContain("sessionDeadlineAt: credentialDeadlineAt.value"); + expect(component).toContain("Math.min(IDLE_TIMEOUT_MINUTES, calculatedMinutes)"); + expect(component).toContain("sessionClockTimer !== undefined"); + expect(component).toContain("window.clearInterval(sessionClockTimer)"); expect(component).toContain("不代表系统健康评分"); expect(component).not.toContain("ip_address"); expect(component).not.toContain("access_token"); diff --git a/frontend/src/components/AccountConnectionStatus.vue b/frontend/src/components/AccountConnectionStatus.vue index 72cca195..add7dab4 100644 --- a/frontend/src/components/AccountConnectionStatus.vue +++ b/frontend/src/components/AccountConnectionStatus.vue @@ -9,6 +9,43 @@ {{ statusLabel }} +
+
+ 网络状态 + + + {{ statusLabel }} + +
+
+
+
当前 IP
+
{{ clientIpLabel }}
+
+
+
API 延迟
+
{{ latencyLabel }}
+
+
+
最近通信
+
{{ lastCommunicationLabel }}
+
+
+ + +
+
当前账号 @@ -51,15 +88,16 @@ @@ -138,6 +220,156 @@ onBeforeUnmount(() => { white-space: nowrap; } +.network-overview { + box-sizing: border-box; + width: 100%; + margin-top: 14px; + padding: 14px 16px; + border: 1px solid rgba(203, 213, 225, 0.82); + border-radius: 12px; + background: rgba(255, 255, 255, 0.66); + color: #334155; +} + +.network-overview-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + color: #334155; + font-size: 12.5px; + font-weight: 750; +} + +.network-overview-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px 14px; + margin: 12px 0 0; + padding-top: 11px; + border-top: 1px solid rgba(226, 232, 240, 0.9); +} + +.network-overview-grid div, +.network-overview-grid dt, +.network-overview-grid dd { + min-width: 0; + margin: 0; +} + +.security-check-trigger { + appearance: none; + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + margin-top: 12px; + padding: 10px 0 0; + border: 0; + border-top: 1px solid rgba(226, 232, 240, 0.9); + background: transparent; + color: #475569; + font: inherit; + font-size: 11px; + font-weight: 700; + cursor: pointer; +} + +.security-check-trigger:disabled { + cursor: wait; + opacity: 0.72; +} + +.security-check-trigger strong { + font-size: 10.5px; + font-weight: 750; +} + +.security-check-trigger .is-safe { + color: #15803d; +} + +.security-check-trigger .is-warning { + color: #b45309; +} + +.security-check-trigger .is-danger { + color: #dc2626; +} + +.security-check-list { + display: grid; + gap: 7px; + margin: 10px 0 0; + padding: 10px 0 0; + border-top: 1px dashed rgba(203, 213, 225, 0.9); + list-style: none; +} + +.security-check-list li { + display: grid; + grid-template-columns: 7px minmax(0, 1fr) auto; + align-items: center; + gap: 7px; + min-width: 0; +} + +.security-check-list i { + width: 6px; + height: 6px; + border-radius: 50%; + background: #94a3b8; +} + +.security-check-list i.is-safe { + background: #22a663; +} + +.security-check-list i.is-warning { + background: #d99b21; +} + +.security-check-list i.is-danger { + background: #e05252; +} + +.security-check-list span, +.security-check-list small { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.security-check-list span { + color: #475569; + font-size: 10.5px; + font-weight: 650; +} + +.security-check-list small { + color: #94a3b8; + font-size: 9.5px; +} + +.network-overview-grid .network-ip-row { + grid-column: 1 / -1; +} + +.network-overview-grid dt { + margin-bottom: 3px; + color: #94a3b8; + font-size: 10px; +} + +.network-overview-grid dd { + overflow: hidden; + color: #334155; + font-size: 11.5px; + font-weight: 650; + text-overflow: ellipsis; + white-space: nowrap; +} + .account-connection-dot { display: inline-block; width: 7px; @@ -252,6 +484,30 @@ onBeforeUnmount(() => { color: #dbe5f1; } +:global([data-ctms-theme="dark"]) .network-overview { + border-color: rgba(255, 255, 255, 0.06); + background: rgba(255, 255, 255, 0.02); +} + +:global([data-ctms-theme="dark"]) .network-overview-head, +:global([data-ctms-theme="dark"]) .network-overview-grid dd { + color: #e2e8f0; +} + +:global([data-ctms-theme="dark"]) .network-overview-grid { + border-top-color: rgba(255, 255, 255, 0.06); +} + +:global([data-ctms-theme="dark"]) .security-check-trigger, +:global([data-ctms-theme="dark"]) .security-check-list { + border-color: rgba(255, 255, 255, 0.06); +} + +:global([data-ctms-theme="dark"]) .security-check-trigger, +:global([data-ctms-theme="dark"]) .security-check-list span { + color: #cbd5e1; +} + :global([data-ctms-theme="dark"]) .connection-account-name, :global([data-ctms-theme="dark"]) .connection-details-grid dd { color: #f8fafc; diff --git a/frontend/src/components/DesktopLayout.vue b/frontend/src/components/DesktopLayout.vue index 3d704c9c..926b6aff 100644 --- a/frontend/src/components/DesktopLayout.vue +++ b/frontend/src/components/DesktopLayout.vue @@ -2642,6 +2642,141 @@ useDesktopShortcuts( background: #131b28; } +/* 小尺寸桌面窗口:工作台保持可用宽度,内容区负责滚动而不是把整个窗口撑开。 */ +@media (max-width: 1240px) { + .desktop-workbench { + grid-template-columns: 216px minmax(0, 1fr); + min-width: 0; + } + + .desktop-toolbar { + gap: 8px; + padding: 0 8px; + } + + .toolbar-right { + gap: 4px; + } + + .update-notice { + display: none; + } + + .command-trigger, + .connection-button, + .account-button { + width: 32px; + justify-content: center; + gap: 0; + padding: 0; + } + + .command-trigger span, + .command-trigger kbd, + .connection-button > span:not(.connection-dot), + .account-name, + .account-button > .el-icon { + display: none; + } + + .account-button { + padding: 0 3px; + } +} + +@media (max-width: 960px) { + .desktop-workbench { + grid-template-columns: 196px minmax(0, 1fr); + } + + .sidebar-head { + padding-right: 8px; + padding-left: 8px; + } + + .sidebar-scroll { + padding-right: 6px; + padding-left: 6px; + } + + .sidebar-link { + font-size: 13px; + } + + .toolbar-title { + font-size: 13px; + } + + .toolbar-subtitle { + font-size: 10px; + } +} + +/* 大屏桌面端:扩大导航和工作区的节奏,避免高分辨率下内容过度拥挤。 */ +@media (min-width: 1440px) { + .desktop-workbench { + grid-template-columns: 264px minmax(0, 1fr); + } + + .desktop-sidebar .sidebar-head { + padding-right: 14px; + padding-left: 14px; + } + + .sidebar-scroll { + padding: 14px 12px 28px; + } + + .desktop-main { + grid-template-rows: 56px auto minmax(0, 1fr); + } + + .desktop-toolbar { + gap: 16px; + padding-right: 16px; + padding-left: 16px; + } + + .workspace-tabs { + gap: 8px; + padding: 9px 14px; + } + + .workspace-tab { + height: 34px; + } + + .desktop-workbench .desktop-route-shell .table-card-toolbar, + .desktop-workbench .desktop-route-shell .ctms-page-header, + .desktop-workbench .desktop-route-shell .ctms-section-header, + .desktop-workbench .desktop-route-shell .unified-action-bar, + .desktop-workbench .desktop-route-shell .module-header { + padding-right: 16px; + padding-left: 16px; + } +} + +@media (min-width: 1920px) { + .desktop-workbench { + grid-template-columns: 280px minmax(0, 1fr); + } + + .desktop-toolbar { + padding-right: 20px; + padding-left: 20px; + } + + .toolbar-title { + font-size: 15px; + } + + .desktop-workbench .desktop-route-shell .module-content, + .desktop-workbench .desktop-route-shell .unified-section { + padding-right: 16px; + padding-left: 16px; + } +} + @media (prefers-reduced-motion: reduce) { .icon-button, .desktop-route-enter-active, diff --git a/frontend/src/components/WebLayout.vue b/frontend/src/components/WebLayout.vue index ee7f6569..c3988f5b 100644 --- a/frontend/src/components/WebLayout.vue +++ b/frontend/src/components/WebLayout.vue @@ -1,6 +1,14 @@