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 bfc0a2ea..d12890c2 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 @@ -27,6 +27,35 @@ afterEach(() => { window.sessionStorage.clear(); }); +test('uses a branded Semi UI session state while restoring authentication', () => { + mocks.session.mockReturnValue(new Promise(() => undefined)); + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + + const view = render(
受保护工作台
); + + expect(screen.getByRole('status')).toHaveTextContent('正在验证登录状态'); + expect(screen.getByRole('status')).toHaveTextContent('账号、菜单与车辆权限'); + expect(screen.getByAltText('羚牛智能')).toHaveAttribute('src', '/brand-logo.svg'); + expect(view.container.querySelector('.v2-auth-loading.semi-card')).toBeInTheDocument(); + expect(view.container.querySelectorAll('.v2-auth-loading-skeleton > i')).toHaveLength(3); +}); + +test('presents failed credentials as a clear Semi UI alert without losing the login form', async () => { + mocks.session.mockRejectedValue(new Error('未登录')); + mocks.login.mockRejectedValue(new Error('账号或密码错误')); + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + + 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: '登录' })); + + expect(await screen.findByRole('alert')).toHaveTextContent('登录未完成'); + expect(screen.getByRole('alert')).toHaveTextContent('账号或密码错误'); + expect(view.container.querySelector('.v2-auth-error .semi-banner')).toBeInTheDocument(); + expect(screen.getByPlaceholderText('请输入用户名')).toBeInTheDocument(); +}); + test('treats login and logout as complete query and mutation cache boundaries', 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 bbc3c813..6c2e21df 100644 --- a/vehicle-data-platform/apps/web/src/v2/auth/AuthGate.tsx +++ b/vehicle-data-platform/apps/web/src/v2/auth/AuthGate.tsx @@ -1,5 +1,6 @@ +import { IconAlertTriangle, IconLock, IconSafe } from '@douyinfe/semi-icons'; import { useQuery, useQueryClient } from '@tanstack/react-query'; -import { Button, Card, Input, Spin, Tag, Typography } from '@douyinfe/semi-ui'; +import { Banner, Button, Card, Input, Spin, 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'; @@ -13,6 +14,40 @@ type AuthContextValue = { const AuthContext = createContext(null); +function AuthLoadingState() { + return
+ +
+
+ 羚牛智能 + }>安全会话 +
+
+ +
+ 正在验证登录状态 + 正在恢复账号、菜单与车辆权限,完成后会自动进入工作台。 +
+
+ +
会话仅保存在当前浏览器,切换账号时会清理用户数据缓存。
+
+
+
; +} + +function AuthErrorMessage({ message }: { message: string }) { + return
+ } + title="登录未完成" + description={message} + /> +
; +} + export function usePlatformSession() { const value = useContext(AuthContext); if (!value) throw new Error('usePlatformSession must be used inside AuthGate'); @@ -79,7 +114,7 @@ export function AuthGate({ children }: { children: ReactNode }) { }; if (session.isPending) { - return
羚牛智能正在验证登录状态…
; + return ; } if (!session.data) { const error = loginError || (attempted && session.error ? session.error.message : ''); @@ -92,19 +127,21 @@ export function AuthGate({ children }: { children: ReactNode }) {
- 羚牛智能 - 登录车辆数据中台 - {legacyMode ? '仅供平台运维使用,输入服务器访问令牌。' : '使用管理员为你开通的账号登录。'} +
+ 羚牛智能 + 登录车辆数据中台 + {legacyMode ? '仅供平台运维使用,输入服务器访问令牌。' : '使用管理员为你开通的账号登录。'} +
{legacyMode ? : <> } - {error ? {error} : null} + {error ? : null} - 登录即表示你同意遵守平台数据安全规范 +
登录后按账号隔离菜单与车辆数据
; diff --git a/vehicle-data-platform/apps/web/src/v2/layout/AppShell.test.tsx b/vehicle-data-platform/apps/web/src/v2/layout/AppShell.test.tsx index 3c3e1804..a37f474d 100644 --- a/vehicle-data-platform/apps/web/src/v2/layout/AppShell.test.tsx +++ b/vehicle-data-platform/apps/web/src/v2/layout/AppShell.test.tsx @@ -12,11 +12,15 @@ const auth = vi.hoisted(() => ({ session: { name: 'test-user', username: 'test-user', role: 'viewer' as const } as any, logout: vi.fn() })); +const apiMocks = vi.hoisted(() => ({ + changePassword: vi.fn() +})); vi.mock('../routing/routeModules', () => routing); vi.mock('../auth/AuthGate', () => ({ usePlatformSession: () => ({ session: auth.session, logout: auth.logout }) })); +vi.mock('../../api/client', () => ({ api: { changePassword: apiMocks.changePassword } })); import { AppShell } from './AppShell'; @@ -25,6 +29,7 @@ afterEach(() => { vi.restoreAllMocks(); vi.clearAllMocks(); auth.session = { name: 'test-user', username: 'test-user', role: 'viewer' }; + apiMocks.changePassword.mockReset(); }); test('renders only explicitly assigned customer menus', () => { @@ -91,6 +96,8 @@ test('opens contextual help for the active module and closes it without navigati fireEvent.click(help); expect(screen.getByRole('dialog', { name: '全局监控' })).toHaveTextContent('筛选会自动生效'); + expect(document.querySelector('.v2-help-overview.semi-card')).toBeInTheDocument(); + expect(screen.getByText('建议操作')).toBeInTheDocument(); expect(help).toHaveAttribute('aria-expanded', 'true'); fireEvent.click(screen.getByLabelText('关闭帮助')); expect(help).toHaveAttribute('aria-expanded', 'false'); @@ -108,6 +115,7 @@ test('uses one Semi account menu for identity, password and logout actions', asy fireEvent.click(trigger); expect(await screen.findByText('@test-user')).toBeInTheDocument(); + expect(screen.getByText('当前登录账号')).toBeInTheDocument(); await waitFor(() => expect(screen.getByRole('button', { name: '账号菜单,test-user' })).toHaveAttribute('aria-expanded', 'true')); fireEvent.click(screen.getByText('修改登录密码')); @@ -119,6 +127,35 @@ test('uses one Semi account menu for identity, password and logout actions', asy expect(auth.logout).toHaveBeenCalledTimes(1); }); +test('validates password policy while typing and submits only a complete secure change', async () => { + apiMocks.changePassword.mockResolvedValue({ changed: true }); + render( + }>页面内容} /> + ); + + fireEvent.click(screen.getByRole('button', { name: '账号菜单,test-user' })); + fireEvent.click(await screen.findByText('修改登录密码')); + + const submit = screen.getByRole('button', { name: '确认修改' }); + expect(submit).toBeDisabled(); + expect(document.querySelector('.v2-password-dialog > .semi-banner-warning')).toHaveTextContent('当前设备和其他设备上的旧会话都会失效'); + + fireEvent.change(screen.getByLabelText('当前密码'), { target: { value: 'CurrentPass!1' } }); + fireEvent.change(screen.getByLabelText('新密码'), { target: { value: 'StrongPass!1' } }); + expect(screen.getByLabelText('新密码安全规则').querySelectorAll('.is-valid')).toHaveLength(2); + fireEvent.change(screen.getByLabelText('确认新密码'), { target: { value: 'different' } }); + expect(screen.getByRole('status')).toHaveTextContent('两次输入的新密码不一致'); + expect(submit).toBeDisabled(); + + fireEvent.change(screen.getByLabelText('确认新密码'), { target: { value: 'StrongPass!1' } }); + expect(screen.getByRole('status')).toHaveTextContent('两次输入一致'); + expect(submit).toBeEnabled(); + fireEvent.click(submit); + + await waitFor(() => expect(apiMocks.changePassword).toHaveBeenCalledWith({ currentPassword: 'CurrentPass!1', newPassword: 'StrongPass!1' })); + expect(auth.logout).toHaveBeenCalledTimes(1); +}); + test('uses the compact mobile navigation and opens secondary modules in a Semi SideSheet', () => { vi.spyOn(window, 'matchMedia').mockReturnValue({ matches: true, diff --git a/vehicle-data-platform/apps/web/src/v2/layout/AppShell.tsx b/vehicle-data-platform/apps/web/src/v2/layout/AppShell.tsx index fb4aa1e8..93fb010e 100644 --- a/vehicle-data-platform/apps/web/src/v2/layout/AppShell.tsx +++ b/vehicle-data-platform/apps/web/src/v2/layout/AppShell.tsx @@ -6,14 +6,16 @@ import { IconChevronLeft, IconHelpCircle, IconHome, + IconLock, IconMapPin, IconMore, IconSearch, IconSetting, + IconTickCircle, IconUser, IconExit } from '@douyinfe/semi-icons'; -import { Avatar, Button, Dropdown, Input, Layout, Modal, Nav, SideSheet, Tag, Typography } from '@douyinfe/semi-ui'; +import { Avatar, Banner, Button, Card, Dropdown, Input, Layout, Modal, Nav, SideSheet, Tag, Typography } from '@douyinfe/semi-ui'; import { FormEvent, type MouseEvent, useEffect, useLayoutEffect, useState } from 'react'; import { NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom'; import { api } from '../../api/client'; @@ -65,15 +67,51 @@ function ContextHelp({ section, visible, onClose }: { section: string; visible: const content = pageHelp[section] ?? { summary: '查看当前模块的操作说明。', tips: ['通过左侧导航切换模块,页面状态会在当前任务中保留。'] }; const title = pageNames[section] ?? '车辆数据中台'; useSideSheetA11y(visible, '.v2-help-sheet', 'v2-context-help', title, '关闭帮助'); - return 当前页面帮助{title}} onCancel={onClose} footer={}> + return
当前页面帮助{title}
} + onCancel={onClose} + footer={} + >
- {content.summary} + + 当前模块 + {content.summary} + +
建议操作{content.tips.length} 项
    {content.tips.map((tip, index) =>
  1. {index + 1}{tip}
  2. )}
Esc关闭帮助
; } +function AccountMenu({ + session, + roleLabel, + onPassword, + onLogout +}: { + session: ReturnType['session']; + roleLabel?: string; + onPassword: () => void; + onLogout: () => void; +}) { + const accountLabel = session.username ? `@${session.username}` : session.authProvider === 'legacy-token' ? '运维令牌账号' : '平台账号'; + return + + {session.name.slice(0, 1)} + 当前登录账号{session.name}{accountLabel} + {roleLabel} + + + } onClick={onPassword}>修改登录密码 + } onClick={onLogout}>退出登录 + ; +} + export function AppShell() { const location = useLocation(); const section = location.pathname.split('/')[1] || 'monitor'; @@ -114,23 +152,19 @@ export function AppShell() {
羚牛车辆数据中台{pageNames[section] ?? '车辆数据中台'}
- - - {session.name.slice(0, 1)} - {session.name}{session.username ? `@${session.username}` : session.authProvider === 'legacy-token' ? '运维令牌账号' : '平台账号'} - {roleLabel} - - - } onClick={() => { setAccountOpen(false); setPasswordOpen(true); }}>修改登录密码 - } onClick={() => { setAccountOpen(false); logout(); }}>退出登录 - } + render={ { setAccountOpen(false); setPasswordOpen(true); }} + onLogout={() => { setAccountOpen(false); logout(); }} + />} >
} + aria-label="修改登录密码" + onCancel={onClose} + closeOnEsc={!pending} + maskClosable={!pending} + footer={null} + >
- 修改成功后,所有设备需要重新登录。 + +
+ 10–128 位字符 + 大小写字母、数字、特殊字符至少三类 +
- {error ? {error} : null} -
+ {confirmationStarted ? {passwordsMatch ? '两次输入一致' : '两次输入的新密码不一致'} : null} + {error ? : null} +
; } diff --git a/vehicle-data-platform/apps/web/src/v2/productionEntry.test.ts b/vehicle-data-platform/apps/web/src/v2/productionEntry.test.ts index aa21bbdc..4c8300b6 100644 --- a/vehicle-data-platform/apps/web/src/v2/productionEntry.test.ts +++ b/vehicle-data-platform/apps/web/src/v2/productionEntry.test.ts @@ -446,7 +446,7 @@ describe('V2 production entry', () => { expect(appShellSource).toContain(''); expect(appShellSource).toContain('