From 9fa68e4ed74d6b34d89697337b5be8db2a5bf750 Mon Sep 17 00:00:00 2001 From: lingniu Date: Sat, 18 Jul 2026 05:15:57 +0800 Subject: [PATCH] refine Semi UI authentication workspace --- .../apps/web/src/v2/auth/AuthGate.test.tsx | 35 +- .../apps/web/src/v2/auth/AuthGate.tsx | 87 ++++- .../apps/web/src/v2/styles/workspace.css | 358 +++++++++++++++++- 3 files changed, 462 insertions(+), 18 deletions(-) diff --git a/vehicle-data-platform/apps/web/src/v2/auth/AuthGate.test.tsx b/vehicle-data-platform/apps/web/src/v2/auth/AuthGate.test.tsx index d12890c2..63f4b912 100644 --- a/vehicle-data-platform/apps/web/src/v2/auth/AuthGate.test.tsx +++ b/vehicle-data-platform/apps/web/src/v2/auth/AuthGate.test.tsx @@ -48,7 +48,7 @@ test('presents failed credentials as a clear Semi UI alert without losing the lo const view = render(
受保护工作台
); fireEvent.change(await screen.findByPlaceholderText('请输入用户名'), { target: { value: 'customer' } }); fireEvent.change(screen.getByPlaceholderText('请输入密码'), { target: { value: 'wrong-password' } }); - fireEvent.click(screen.getByRole('button', { name: '登录' })); + fireEvent.click(screen.getByRole('button', { name: '登录工作台' })); expect(await screen.findByRole('alert')).toHaveTextContent('登录未完成'); expect(screen.getByRole('alert')).toHaveTextContent('账号或密码错误'); @@ -71,7 +71,7 @@ test('treats login and logout as complete query and mutation cache boundaries', render(); fireEvent.change(await screen.findByPlaceholderText('请输入用户名'), { target: { value: 'next-user' } }); fireEvent.change(screen.getByPlaceholderText('请输入密码'), { target: { value: 'StrongPass!1' } }); - fireEvent.click(screen.getByRole('button', { name: '登录' })); + fireEvent.click(screen.getByRole('button', { name: '登录工作台' })); expect(await screen.findByText('next-user')).toBeInTheDocument(); expect(client.getQueryData(['previous-user-vehicle-data'])).toBeUndefined(); @@ -88,6 +88,37 @@ test('treats login and logout as complete query and mutation cache boundaries', expect(window.sessionStorage.length).toBe(0); }); +test('makes account and operations-token entry explicit while preserving the account draft', async () => { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + mocks.session.mockImplementation(async () => { + if (getAccessToken() !== 'ops-session-token') throw new Error('未登录'); + return { name: 'platform-operator', role: 'admin', authMode: 'enforce' }; + }); + + render( undefined} />); + + expect(await screen.findByRole('form', { name: '平台账号登录' })).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: '账号登录' })).toHaveAttribute('aria-selected', 'true'); + expect(screen.getByRole('tab', { name: '运维令牌' })).toHaveAttribute('aria-selected', 'false'); + fireEvent.change(screen.getByPlaceholderText('请输入用户名'), { target: { value: 'remember-me' } }); + + fireEvent.click(screen.getByRole('tab', { name: '运维令牌' })); + expect(screen.getByRole('form', { name: '运维令牌登录' })).toBeInTheDocument(); + expect(screen.getByText('仅限平台运维')).toBeInTheDocument(); + expect(screen.getByText('令牌只保存在当前浏览器会话,关闭或退出后失效。')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Close' })).not.toBeInTheDocument(); + fireEvent.change(screen.getByPlaceholderText('请输入服务器访问令牌'), { target: { value: 'ops-session-token' } }); + + fireEvent.click(screen.getByRole('tab', { name: '账号登录' })); + expect(screen.getByPlaceholderText('请输入用户名')).toHaveValue('remember-me'); + fireEvent.click(screen.getByRole('tab', { name: '运维令牌' })); + fireEvent.click(screen.getByRole('button', { name: '验证令牌并进入' })); + + expect(await screen.findByText('platform-operator')).toBeInTheDocument(); + expect(mocks.login).not.toHaveBeenCalled(); + expect(getAccessToken()).toBe('ops-session-token'); +}); + test('an expired protected request returns to login and removes the active user cache', async () => { mocks.logout.mockResolvedValue({ loggedOut: true }); const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); diff --git a/vehicle-data-platform/apps/web/src/v2/auth/AuthGate.tsx b/vehicle-data-platform/apps/web/src/v2/auth/AuthGate.tsx index 6c2e21df..df1e6665 100644 --- a/vehicle-data-platform/apps/web/src/v2/auth/AuthGate.tsx +++ b/vehicle-data-platform/apps/web/src/v2/auth/AuthGate.tsx @@ -1,6 +1,6 @@ -import { IconAlertTriangle, IconLock, IconSafe } from '@douyinfe/semi-icons'; +import { IconAlertTriangle, IconKey, IconLock, IconSafe, IconShield, IconUser } from '@douyinfe/semi-icons'; import { useQuery, useQueryClient } from '@tanstack/react-query'; -import { Banner, Button, Card, Input, Spin, Tag, Typography } from '@douyinfe/semi-ui'; +import { Banner, Button, Card, Input, Spin, Tabs, Tag, Typography } from '@douyinfe/semi-ui'; import { FormEvent, ReactNode, useCallback, useContext, useEffect, useState, createContext } from 'react'; import { api } from '../../api/client'; import { clearAccessToken, getAccessToken, PLATFORM_UNAUTHORIZED_EVENT, PlatformSession, setAccessToken } from './session'; @@ -14,6 +14,24 @@ type AuthContextValue = { const AuthContext = createContext(null); +const authAssurances = [ + { + icon: , + title: '车辆级权限', + description: '管理员按车牌与菜单精确授权' + }, + { + icon: , + title: '会话隔离', + description: '切换账号自动清理当前用户缓存' + }, + { + icon: , + title: '身份可扩展', + description: '预留统一身份与外部系统接入' + } +]; + function AuthLoadingState() { return
@@ -41,6 +59,7 @@ function AuthErrorMessage({ message }: { message: string }) { } title="登录未完成" description={message} @@ -112,6 +131,10 @@ export function AuthGate({ children }: { children: ReactNode }) { setLoginPending(false); } }; + const switchLoginMode = (mode: string) => { + setLegacyMode(mode === 'token'); + setLoginError(''); + }; if (session.isPending) { return ; @@ -119,29 +142,63 @@ export function AuthGate({ children }: { children: ReactNode }) { if (!session.data) { const error = loginError || (attempted && session.error ? session.error.message : ''); return
-
; diff --git a/vehicle-data-platform/apps/web/src/v2/styles/workspace.css b/vehicle-data-platform/apps/web/src/v2/styles/workspace.css index ea76e8de..aac688ef 100644 --- a/vehicle-data-platform/apps/web/src/v2/styles/workspace.css +++ b/vehicle-data-platform/apps/web/src/v2/styles/workspace.css @@ -3771,6 +3771,283 @@ font-size: 10px; } +/* Semi UI identity workspace: one explicit account and operations entry system. */ +.v2-auth-screen:not(.is-session-loading) { + grid-template-columns: minmax(430px, 1.12fr) minmax(420px, 460px); + column-gap: clamp(34px, 5vw, 82px); + background: + radial-gradient(circle at 18% 14%, rgba(18, 104, 243, .11), transparent 30%), + linear-gradient(135deg, #f7faff 0%, #f2f6fb 56%, #eef3f9 100%); + padding: clamp(30px, 5vw, 72px); +} + +.v2-auth-intro { + width: 100%; + max-width: 720px; + min-width: 0; + padding: 30px clamp(0px, 3vw, 44px); +} + +.v2-auth-intro > header { + display: flex; + min-width: 0; + align-items: center; + justify-content: space-between; + gap: 22px; +} + +.v2-auth-intro > header > img { + display: block; + width: 168px; + max-width: 48%; + height: auto; +} + +.v2-auth-intro > header > .semi-tag { + height: 28px; + flex: 0 0 auto; + border-radius: 999px; + padding-inline: 10px; + font-size: 10px; + font-weight: 700; +} + +.v2-auth-intro > h1.semi-typography { + max-width: 650px; + margin: clamp(42px, 7vh, 66px) 0 20px; + color: #172a43; + font-size: clamp(40px, 4.2vw, 62px); + font-weight: 730; + line-height: 1.1; + letter-spacing: -.052em; +} + +.v2-auth-intro > h1 + .semi-typography { + display: block; + max-width: 560px; + margin: 0; + color: #65758b; + font-size: 15px; + line-height: 1.85; +} + +.v2-auth-assurances { + display: grid; + max-width: 650px; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; + margin: 30px 0 0; + padding: 0; + list-style: none; +} + +.v2-auth-assurances > li { + display: grid; + min-width: 0; + grid-template-columns: 34px minmax(0, 1fr); + align-items: start; + gap: 10px; + border: 1px solid rgba(218, 228, 240, .92); + border-radius: 14px; + background: rgba(255, 255, 255, .72); + padding: 13px 12px; + box-shadow: 0 8px 28px rgba(36, 59, 90, .035); +} + +.v2-auth-assurances > li > span { + display: grid; + width: 34px; + height: 34px; + place-items: center; + border-radius: 10px; + background: #eaf3ff; + color: #1268f3; + font-size: 16px; +} + +.v2-auth-assurances > li > div { + display: grid; + min-width: 0; + gap: 3px; +} + +.v2-auth-assurances strong { + color: #34465d; + font-size: 11px; + font-weight: 750; + line-height: 1.45; +} + +.v2-auth-assurances small { + color: #8290a3; + font-size: 9px; + line-height: 1.55; +} + +.v2-auth-intro > footer { + display: flex; + align-items: center; + gap: 7px; + margin-top: 22px; + color: #74869d; +} + +.v2-auth-intro > footer > svg { + color: #4676b6; + font-size: 12px; +} + +.v2-auth-intro > footer > .semi-typography { + margin: 0; + color: #74869d; + font-size: 10px; +} + +.v2-auth-screen:not(.is-session-loading) > .v2-auth-card.semi-card { + width: min(460px, 100%); + justify-self: end; + border-color: rgba(207, 219, 234, .96); + border-radius: 24px; + background: rgba(255, 255, 255, .98); + box-shadow: 0 28px 78px rgba(25, 43, 69, .14); +} + +.v2-auth-form { + padding: 32px 34px 28px; +} + +.v2-auth-logo { + width: 142px; + margin: 0 auto 16px; +} + +.v2-auth-form-heading > .semi-tag { + height: 26px; + margin-bottom: 11px; + border-radius: 999px; + padding-inline: 9px; + font-size: 10px; + font-weight: 700; +} + +.v2-auth-form-heading > h3.semi-typography { + font-size: 24px; +} + +.v2-auth-form-heading > h3 + .semi-typography { + max-width: 330px; + margin: 8px 0 0; + font-size: 12px; + line-height: 1.65; +} + +.v2-auth-mode-tabs { + margin-top: 20px; +} + +.v2-auth-mode-tabs > .semi-tabs-bar { + width: 100%; + border: 1px solid #e5ebf3; + border-radius: 12px; + background: #f4f7fb; + padding: 4px; +} + +.v2-auth-mode-tabs > .semi-tabs-bar .semi-tabs-tab { + width: 50%; + min-width: 0; + height: 38px; + justify-content: center; + border-radius: 9px; + color: #718096; + font-size: 11px; + font-weight: 650; +} + +.v2-auth-mode-tabs > .semi-tabs-bar .semi-tabs-tab-active { + background: #fff; + color: #1268f3; + box-shadow: 0 2px 8px rgba(30, 58, 92, .1); +} + +.v2-auth-mode-tabs > .semi-tabs-content { + padding: 0; +} + +.v2-auth-fields { + display: grid; + gap: 0; + min-height: 145px; +} + +.v2-auth-fields > label { + display: flex; + flex-direction: column; + gap: 7px; + margin-top: 14px; + color: #405269; + font-size: 12px; + font-weight: 680; +} + +.v2-auth-fields > label .semi-input-wrapper { + height: 44px; + border-color: #dbe4ef; + border-radius: 10px; + background: #fbfcfe; + box-shadow: none; +} + +.v2-auth-fields > label .semi-input-wrapper:hover { + border-color: #b8c9dd; + background: #fff; +} + +.v2-auth-fields > label .semi-input-wrapper-focus { + border-color: #6da3f7; + background: #fff; + box-shadow: 0 0 0 3px rgba(18, 104, 243, .1); +} + +.v2-auth-token-note.semi-banner { + align-items: flex-start; + margin-top: 14px; + border: 1px solid #f4d9a7; + border-radius: 10px; + background: #fffbf2; + padding: 9px 10px; +} + +.v2-auth-token-note .semi-banner-title { + color: #815d16; + font-size: 10px; + font-weight: 750; +} + +.v2-auth-token-note .semi-banner-description { + margin-top: 2px; + color: #9b7a37; + font-size: 9px; + line-height: 1.5; +} + +.v2-auth-form > .semi-button-primary { + height: 46px; + margin-top: 18px; + border-radius: 10px; + font-size: 13px; + font-weight: 750; + box-shadow: 0 10px 22px rgba(18, 104, 243, .2); +} + +.v2-auth-trust-note { + margin-top: 12px; +} + +.v2-auth-trust-note > .semi-typography { + line-height: 1.45; +} + .v2-route-error { align-content: center; background: @@ -3902,6 +4179,36 @@ } @media (max-width: 680px) { + .v2-auth-screen:not(.is-session-loading) { + display: block; + min-height: 100dvh; + background: + radial-gradient(circle at 50% 0, rgba(18, 104, 243, .1), transparent 34%), + #f3f6fa; + padding: max(12px, env(safe-area-inset-top)) 12px max(12px, env(safe-area-inset-bottom)); + } + + .v2-auth-screen:not(.is-session-loading) > .v2-auth-card.semi-card { + width: 100%; + justify-self: auto; + border-radius: 18px; + box-shadow: 0 16px 42px rgba(25, 43, 69, .1); + backdrop-filter: none; + } + + .v2-auth-form { + padding: 24px 18px 20px; + } + + .v2-auth-logo { + width: 132px; + margin-bottom: 14px; + } + + .v2-auth-form-heading > .semi-tag { + margin-bottom: 9px; + } + .v2-auth-loading-inner { gap: 20px; padding: 22px 18px; @@ -3942,13 +4249,44 @@ } .v2-auth-form-heading > h3.semi-typography { - font-size: 22px; + font-size: 21px; } .v2-auth-form-heading > h3 + .semi-typography { + max-width: 290px; + font-size: 11px; + } + + .v2-auth-mode-tabs { + margin-top: 17px; + } + + .v2-auth-mode-tabs > .semi-tabs-bar .semi-tabs-tab { + height: 40px; font-size: 12px; } + .v2-auth-fields { + min-height: 143px; + } + + .v2-auth-fields > label { + font-size: 12px; + } + + .v2-auth-fields > label .semi-input-wrapper { + height: 46px; + } + + .v2-auth-form > .semi-button-primary { + height: 46px; + font-size: 13px; + } + + .v2-auth-token-note.semi-banner { + margin-top: 12px; + } + .v2-route-error > .v2-route-error-card.semi-card { border-radius: 15px; } @@ -3987,6 +4325,24 @@ } } +@media (min-width: 681px) and (max-width: 900px) { + .v2-auth-screen:not(.is-session-loading) { + grid-template-columns: minmax(0, 460px); + justify-content: center; + padding: 30px; + } + + .v2-auth-screen:not(.is-session-loading) > .v2-auth-card.semi-card { + justify-self: center; + } +} + +@media (prefers-reduced-motion: reduce) { + .v2-auth-loading-skeleton > i { + animation: none; + } +} + /* Unified application shell: page context, help, account and password flows. */ .v2-topbar { border-bottom-color: #e4eaf2;