feat: build vehicle data platform and production pipeline

This commit is contained in:
lingniu
2026-07-14 12:35:33 +08:00
parent b452be3b94
commit bb59303a4b
270 changed files with 88016 additions and 1975 deletions

View File

@@ -0,0 +1,50 @@
import { useQuery } from '@tanstack/react-query';
import { createContext, FormEvent, ReactNode, useContext, useState } from 'react';
import { api } from '../../api/client';
import { clearAccessToken, getAccessToken, PlatformSession, setAccessToken } from './session';
type AuthContextValue = {
session: PlatformSession;
logout: () => void;
};
const AuthContext = createContext<AuthContextValue | null>(null);
export function usePlatformSession() {
const value = useContext(AuthContext);
if (!value) throw new Error('usePlatformSession must be used inside AuthGate');
return value;
}
export function AuthGate({ children }: { children: ReactNode }) {
const [tokenVersion, setTokenVersion] = useState(0);
const [draftToken, setDraftToken] = useState('');
const [attempted, setAttempted] = useState(() => Boolean(getAccessToken()));
const session = useQuery({
queryKey: ['platform-session', tokenVersion],
queryFn: api.session,
retry: false,
staleTime: Infinity
});
const login = (event: FormEvent) => {
event.preventDefault();
setAccessToken(draftToken);
setAttempted(true);
setTokenVersion((value) => value + 1);
};
const logout = () => {
clearAccessToken();
setDraftToken('');
setAttempted(false);
setTokenVersion((value) => value + 1);
};
if (session.isPending) {
return <div className="v2-auth-screen"><div className="v2-auth-card"><i className="v2-auth-spinner" /><strong>访</strong></div></div>;
}
if (!session.data) {
return <div className="v2-auth-screen"><form className="v2-auth-card" onSubmit={login}><div className="v2-auth-mark"></div><h1></h1><p>访</p><label><span>访</span><input autoFocus required type="password" autoComplete="current-password" value={draftToken} onChange={(event) => setDraftToken(event.target.value)} placeholder="Bearer token" /></label>{attempted && session.error ? <em>{session.error.message}</em> : null}<button type="submit" disabled={!draftToken.trim()}></button></form></div>;
}
return <AuthContext.Provider value={{ session: session.data, logout }}>{children}</AuthContext.Provider>;
}

View File

@@ -0,0 +1,22 @@
import { afterEach, expect, test } from 'vitest';
import { canAdminister, canOperate, clearAccessToken, getAccessToken, setAccessToken } from './session';
afterEach(() => {
window.sessionStorage.clear();
window.localStorage.clear();
});
test('access token is scoped to the browser session and can be cleared', () => {
setAccessToken(' secret-token ');
expect(getAccessToken()).toBe('secret-token');
expect(window.localStorage.length).toBe(0);
clearAccessToken();
expect(getAccessToken()).toBe('');
});
test('role helpers follow the server permission hierarchy', () => {
expect(canOperate({ name: 'v', role: 'viewer', authMode: 'enforce' })).toBe(false);
expect(canOperate({ name: 'o', role: 'operator', authMode: 'enforce' })).toBe(true);
expect(canAdminister({ name: 'o', role: 'operator', authMode: 'enforce' })).toBe(false);
expect(canAdminister({ name: 'a', role: 'admin', authMode: 'enforce' })).toBe(true);
});

View File

@@ -0,0 +1,31 @@
const TOKEN_KEY = 'vehicle-platform.access-token';
export type PlatformRole = 'viewer' | 'operator' | 'admin';
export interface PlatformSession {
name: string;
role: PlatformRole;
authMode: 'disabled' | 'enforce';
}
export function getAccessToken() {
return window.sessionStorage.getItem(TOKEN_KEY) ?? '';
}
export function setAccessToken(token: string) {
const normalized = token.trim();
if (normalized) window.sessionStorage.setItem(TOKEN_KEY, normalized);
else window.sessionStorage.removeItem(TOKEN_KEY);
}
export function clearAccessToken() {
window.sessionStorage.removeItem(TOKEN_KEY);
}
export function canOperate(session: PlatformSession) {
return session.role === 'operator' || session.role === 'admin';
}
export function canAdminister(session: PlatformSession) {
return session.role === 'admin';
}