[重构] 前端改造v2

This commit is contained in:
ryan
2026-03-11 13:56:44 +08:00
parent 7a1abe008c
commit 5713ee2b44
25 changed files with 1135 additions and 30 deletions
@@ -0,0 +1,35 @@
import { cn } from '@/lib/utils/cn';
type InlineMessageTone = 'info' | 'success' | 'danger';
const toneClasses: Record<InlineMessageTone, string> = {
info: 'border-[var(--status-info-border)] bg-[var(--status-info-soft)] text-[var(--status-info-foreground)]',
success:
'border-[var(--status-success-border)] bg-[var(--status-success-soft)] text-[var(--status-success-foreground)]',
danger:
'border-[var(--status-danger-border)] bg-[var(--status-danger-soft)] text-[var(--status-danger-foreground)]',
};
interface InlineMessageProps {
tone?: InlineMessageTone;
message: string;
className?: string;
}
export function InlineMessage({
tone = 'info',
message,
className,
}: InlineMessageProps) {
return (
<div
className={cn(
'rounded-2xl border px-4 py-3 text-sm leading-6',
toneClasses[tone],
className,
)}
>
{message}
</div>
);
}
@@ -0,0 +1,96 @@
'use client';
import { useEffect, useId, useRef } from 'react';
declare global {
interface Window {
turnstile?: {
render: (
container: string | HTMLElement,
options: {
sitekey: string;
callback: (token: string) => void;
'expired-callback'?: () => void;
'error-callback'?: () => void;
theme?: 'auto' | 'light' | 'dark';
},
) => string;
remove: (widgetId: string) => void;
reset: (widgetId?: string) => void;
};
}
}
const TURNSTILE_SCRIPT_ID = 'cloudflare-turnstile-script';
const TURNSTILE_SCRIPT_SRC = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit';
interface TurnstileWidgetProps {
siteKey: string;
onVerify: (token: string) => void;
onExpire?: () => void;
onError?: () => void;
}
export function TurnstileWidget({
siteKey,
onVerify,
onExpire,
onError,
}: TurnstileWidgetProps) {
const containerRef = useRef<HTMLDivElement | null>(null);
const widgetIdRef = useRef<string | null>(null);
const elementId = useId().replace(/:/g, '-');
useEffect(() => {
let cancelled = false;
const mountWidget = () => {
if (cancelled || !containerRef.current || !window.turnstile) {
return;
}
if (widgetIdRef.current) {
window.turnstile.remove(widgetIdRef.current);
widgetIdRef.current = null;
}
widgetIdRef.current = window.turnstile.render(containerRef.current, {
sitekey: siteKey,
callback: onVerify,
'expired-callback': onExpire,
'error-callback': onError,
theme: 'auto',
});
};
const existingScript = document.getElementById(TURNSTILE_SCRIPT_ID) as HTMLScriptElement | null;
if (window.turnstile) {
mountWidget();
} else if (existingScript) {
existingScript.addEventListener('load', mountWidget);
} else {
const script = document.createElement('script');
script.id = TURNSTILE_SCRIPT_ID;
script.src = TURNSTILE_SCRIPT_SRC;
script.async = true;
script.defer = true;
script.addEventListener('load', mountWidget);
document.head.appendChild(script);
}
return () => {
cancelled = true;
const script = document.getElementById(TURNSTILE_SCRIPT_ID) as HTMLScriptElement | null;
if (script) {
script.removeEventListener('load', mountWidget);
}
if (widgetIdRef.current && window.turnstile) {
window.turnstile.remove(widgetIdRef.current);
widgetIdRef.current = null;
}
};
}, [onError, onExpire, onVerify, siteKey]);
return <div id={elementId} ref={containerRef} className='min-h-16' />;
}
@@ -1,5 +1,9 @@
'use client';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { useAuth } from '@/components/providers/auth-provider';
import { ThemeToggle } from '@/components/ui/theme-toggle';
import { getCurrentNavigationItem } from '@/lib/utils/navigation';
import { publicEnv } from '@/lib/env/public-env';
@@ -7,10 +11,19 @@ import { useAppShellStore } from '@/store/app-shell';
import { usePathname } from 'next/navigation';
export function DashboardTopbar() {
const router = useRouter();
const pathname = usePathname();
const currentPath = pathname ?? '/';
const { logout, user } = useAuth();
const toggleSidebar = useAppShellStore((state) => state.toggleSidebar);
const currentItem = getCurrentNavigationItem(currentPath);
const [isLoggingOut, setIsLoggingOut] = useState(false);
const handleLogout = async () => {
setIsLoggingOut(true);
await logout();
router.replace('/login');
};
return (
<header className='sticky top-0 z-10 border-b border-[var(--border-default)] bg-[var(--surface-panel)]/75 px-4 py-4 backdrop-blur md:px-8'>
@@ -33,6 +46,11 @@ export function DashboardTopbar() {
</div>
<div className='flex items-center gap-3 text-sm text-[var(--foreground-secondary)]'>
{user ? (
<span className='hidden rounded-full border border-[var(--border-default)] px-3 py-1.5 md:inline-flex'>
{user.display_name || user.username}
</span>
) : null}
<ThemeToggle />
<span className='rounded-full border border-[var(--border-default)] px-3 py-1.5'>
静态导出模式
@@ -40,6 +58,14 @@ export function DashboardTopbar() {
<span className='rounded-full border border-[var(--border-default)] px-3 py-1.5'>
版本 {publicEnv.appVersion}
</span>
<button
type='button'
onClick={() => void handleLogout()}
disabled={isLoggingOut}
className='inline-flex rounded-full border border-[var(--border-default)] bg-[var(--control-background)] px-3 py-1.5 text-[var(--foreground-primary)] transition hover:bg-[var(--control-background-hover)] disabled:cursor-not-allowed disabled:opacity-60'
>
{isLoggingOut ? '退出中...' : '退出登录'}
</button>
</div>
</div>
</header>
@@ -3,6 +3,7 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { type ReactNode, useState } from 'react';
import { AuthProvider } from '@/components/providers/auth-provider';
import { ThemeProvider } from '@/components/providers/theme-provider';
interface AppProvidersProps {
@@ -27,8 +28,10 @@ export function AppProviders({ children }: AppProvidersProps) {
);
return (
<ThemeProvider>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</ThemeProvider>
<QueryClientProvider client={queryClient}>
<ThemeProvider>
<AuthProvider>{children}</AuthProvider>
</ThemeProvider>
</QueryClientProvider>
);
}
@@ -0,0 +1,89 @@
'use client';
import {
createContext,
type ReactNode,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from 'react';
import { logout as logoutRequest, getCurrentUser } from '@/features/auth/api/auth';
import type { AuthUser } from '@/types/auth';
interface AuthContextValue {
user: AuthUser | null;
isLoading: boolean;
isAuthenticated: boolean;
refreshUser: () => Promise<AuthUser | null>;
setUser: (user: AuthUser | null) => void;
logout: () => Promise<void>;
}
const AuthContext = createContext<AuthContextValue | null>(null);
interface AuthProviderProps {
children: ReactNode;
}
export function AuthProvider({ children }: AuthProviderProps) {
const [user, setUserState] = useState<AuthUser | null>(null);
const [isLoading, setIsLoading] = useState(true);
const refreshUser = useCallback(async () => {
try {
const nextUser = await getCurrentUser();
setUserState(nextUser);
return nextUser;
} catch {
setUserState(null);
return null;
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
void refreshUser();
}, [refreshUser]);
const setUser = useCallback((nextUser: AuthUser | null) => {
setUserState(nextUser);
setIsLoading(false);
}, []);
const logout = useCallback(async () => {
try {
await logoutRequest();
} finally {
setUserState(null);
setIsLoading(false);
}
}, []);
const value = useMemo(
() => ({
user,
isLoading,
isAuthenticated: user !== null,
refreshUser,
setUser,
logout,
}),
[isLoading, logout, refreshUser, setUser, user],
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within AuthProvider');
}
return context;
}