mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-05 15:26:36 +08:00
perf(frontend,config): parallelize auth, virtualize logs, cache public configs
Frontend layout renders immediately; pages gate via RequireAuth and useAuthRedirect. Login/register skip getUserInfo. Admin log panel uses useVirtualizer to avoid 2000-row DOM. ListVisibleSystemConfigs caches visibility=1 configs in Redis with invalidation on create/update. PERFORMANCE.md marks completed optimizations.
This commit is contained in:
@@ -1,35 +1,18 @@
|
||||
"use client"
|
||||
|
||||
import { AdminUsersProvider } from "@/contexts/admin-users-context"
|
||||
import { useUser } from "@/contexts/user-context"
|
||||
import { LoadingPage } from "@/components/layout/loading"
|
||||
import { ErrorPage } from "@/components/layout/error"
|
||||
import {AdminUsersProvider} from "@/contexts/admin-users-context"
|
||||
import {RequireAdminAuth} from "@/components/auth/require-auth"
|
||||
|
||||
export default function AdminLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const { user, loading } = useUser()
|
||||
|
||||
/* 等待用户信息加载完成 */
|
||||
if (loading) {
|
||||
return <LoadingPage text="管理后台" badgeText="管理" />
|
||||
}
|
||||
|
||||
/* 权限检查:只有管理员才能访问 */
|
||||
if (!user?.is_admin) {
|
||||
return (
|
||||
<ErrorPage
|
||||
title="访问被拒绝"
|
||||
message="您没有权限访问此页面"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminUsersProvider>
|
||||
{children}
|
||||
</AdminUsersProvider>
|
||||
<RequireAdminAuth>
|
||||
<AdminUsersProvider>
|
||||
{children}
|
||||
</AdminUsersProvider>
|
||||
</RequireAdminAuth>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -3,11 +3,13 @@
|
||||
import * as React from "react"
|
||||
import {motion} from "motion/react"
|
||||
|
||||
import {RequireAuth} from "@/components/auth/require-auth"
|
||||
import {UserFileManager} from "@/components/common/user/file-manager"
|
||||
import {FolderOpen} from "lucide-react"
|
||||
|
||||
export default function UserFilesPage() {
|
||||
return (
|
||||
<RequireAuth>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 15 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
@@ -26,5 +28,6 @@ export default function UserFilesPage() {
|
||||
|
||||
<UserFileManager />
|
||||
</motion.div>
|
||||
</RequireAuth>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
"use client"
|
||||
|
||||
import {motion} from "motion/react"
|
||||
import {usePathname, useRouter} from "next/navigation"
|
||||
import {useEffect, useState} from "react"
|
||||
import {usePathname} from "next/navigation"
|
||||
import {useState} from "react"
|
||||
import {AppSidebar} from "@/components/layout/sidebar"
|
||||
import {SiteHeader} from "@/components/layout/header"
|
||||
import {SidebarInset, SidebarProvider} from "@/components/ui/sidebar"
|
||||
import {LoadingPage} from "@/components/layout/loading"
|
||||
import {useUser} from "@/contexts/user-context"
|
||||
import {useAuthRedirect} from "@/hooks/use-auth-redirect"
|
||||
|
||||
|
||||
export default function MainLayout({
|
||||
@@ -15,26 +14,10 @@ export default function MainLayout({
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
const {user, loading} = useUser()
|
||||
const [isFullWidth, setIsFullWidth] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (loading || user) return
|
||||
|
||||
const queryString = window.location.search
|
||||
const callbackUrl = queryString ? `${pathname}${queryString}` : pathname
|
||||
const loginUrl = new URL("/login", window.location.origin)
|
||||
|
||||
loginUrl.searchParams.set("callbackUrl", callbackUrl)
|
||||
sessionStorage.setItem("redirect_after_login", callbackUrl)
|
||||
router.replace(loginUrl.toString())
|
||||
}, [loading, pathname, router, user])
|
||||
|
||||
if (loading || !user) {
|
||||
return <LoadingPage text="登录状态" badgeText="Auth" />
|
||||
}
|
||||
useAuthRedirect()
|
||||
|
||||
return (
|
||||
<SidebarProvider
|
||||
@@ -67,4 +50,4 @@ export default function MainLayout({
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
"use client"
|
||||
|
||||
import {RequireAuth} from "@/components/auth/require-auth"
|
||||
|
||||
export default function SettingsLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return <RequireAuth>{children}</RequireAuth>
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
"use client"
|
||||
|
||||
import type {ReactNode} from "react"
|
||||
import {Loader2} from "lucide-react"
|
||||
|
||||
import {ErrorPage} from "@/components/layout/error"
|
||||
import {useUser} from "@/contexts/user-context"
|
||||
|
||||
type RequireAuthProps = {
|
||||
children: ReactNode
|
||||
fallback?: ReactNode
|
||||
minHeightClassName?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Guards page content until the session user is available.
|
||||
* Redirect to login is handled by the parent layout; this only covers the content slot.
|
||||
*/
|
||||
export function RequireAuth({
|
||||
children,
|
||||
fallback,
|
||||
minHeightClassName = "min-h-[400px]",
|
||||
}: RequireAuthProps) {
|
||||
const {user, loading} = useUser()
|
||||
|
||||
if (loading) {
|
||||
return fallback ?? (
|
||||
<div className={`flex items-center justify-center ${minHeightClassName}`}>
|
||||
<Loader2 className="size-6 animate-spin text-primary" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return null
|
||||
}
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
type RequireAdminAuthProps = {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
/** Guards admin routes after the shared shell has rendered. */
|
||||
export function RequireAdminAuth({children}: RequireAdminAuthProps) {
|
||||
const {user, loading} = useUser()
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex min-h-[400px] items-center justify-center">
|
||||
<Loader2 className="size-6 animate-spin text-primary" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!user?.is_admin) {
|
||||
return (
|
||||
<ErrorPage
|
||||
title="访问被拒绝"
|
||||
message="您没有权限访问此页面"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
@@ -16,7 +16,8 @@ limitations under the License.
|
||||
|
||||
"use client"
|
||||
|
||||
import {useCallback, useEffect, useRef, useState} from "react"
|
||||
import {memo, useCallback, useEffect, useRef, useState} from "react"
|
||||
import {useVirtualizer} from "@tanstack/react-virtual"
|
||||
import {toast} from "sonner"
|
||||
import {ArrowDown, ChevronUp, Loader2, Pause, Play} from "lucide-react"
|
||||
|
||||
@@ -33,6 +34,25 @@ interface LogEntry {
|
||||
|
||||
// Distance (px) from bottom to treat as "at bottom"
|
||||
const BOTTOM_THRESHOLD = 40
|
||||
const LOG_LINE_ESTIMATE_PX = 20
|
||||
const LOG_VIRTUAL_OVERSCAN = 24
|
||||
|
||||
const LogLine = memo(function LogLine({data}: {data: string}) {
|
||||
const level = parseLogLevel(data)
|
||||
const color = level === "error"
|
||||
? "text-red-400"
|
||||
: level === "warn"
|
||||
? "text-yellow-400"
|
||||
: level === "debug"
|
||||
? "text-gray-500"
|
||||
: "text-gray-300"
|
||||
|
||||
return (
|
||||
<div className={`${color} whitespace-pre-wrap break-all hover:bg-white/5`}>
|
||||
{data}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
function getApiBaseUrl(): string {
|
||||
if (typeof window !== "undefined") {
|
||||
@@ -87,6 +107,14 @@ export function AppLogs() {
|
||||
useEffect(() => { pausedRef.current = paused }, [paused])
|
||||
useEffect(() => { autoScrollRef.current = autoScroll }, [autoScroll])
|
||||
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: logs.length,
|
||||
getScrollElement: () => containerRef.current,
|
||||
estimateSize: () => LOG_LINE_ESTIMATE_PX,
|
||||
overscan: LOG_VIRTUAL_OVERSCAN,
|
||||
measureElement: (element) => element.getBoundingClientRect().height,
|
||||
})
|
||||
|
||||
// ---- Scroll detection ------------------------------------------------
|
||||
const handleScroll = useCallback(() => {
|
||||
const el = containerRef.current
|
||||
@@ -154,15 +182,14 @@ export function AppLogs() {
|
||||
if (isInitial) {
|
||||
setLogs(data.lines || [])
|
||||
} else {
|
||||
const el = containerRef.current
|
||||
const previousScrollHeight = el?.scrollHeight ?? 0
|
||||
setLogs(prev => [...(data.lines || []), ...prev])
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const el = containerRef.current
|
||||
if (!el) return
|
||||
const newCount = (data.lines || []).length
|
||||
const lineH = 20
|
||||
isUserScrolling.current = false
|
||||
el.scrollTop = el.scrollTop + newCount * lineH
|
||||
el.scrollTop = el.scrollHeight - previousScrollHeight
|
||||
})
|
||||
}
|
||||
setHasMore(data.has_more)
|
||||
@@ -289,31 +316,31 @@ export function AppLogs() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Log lines */}
|
||||
<div className="px-3 py-2">
|
||||
{logs.length === 0 ? (
|
||||
<div className="text-center text-gray-500 py-8">暂无日志</div>
|
||||
) : (
|
||||
logs.map((entry) => {
|
||||
const level = parseLogLevel(entry.data)
|
||||
const color = level === "error"
|
||||
? "text-red-400"
|
||||
: level === "warn"
|
||||
? "text-yellow-400"
|
||||
: level === "debug"
|
||||
? "text-gray-500"
|
||||
: "text-gray-300"
|
||||
{logs.length === 0 ? (
|
||||
<div className="px-3 py-8 text-center text-gray-500">暂无日志</div>
|
||||
) : (
|
||||
<div
|
||||
className="relative px-3 py-2"
|
||||
style={{height: `${rowVirtualizer.getTotalSize()}px`}}
|
||||
>
|
||||
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
|
||||
const entry = logs[virtualRow.index]
|
||||
if (!entry) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
key={entry.index}
|
||||
className={`${color} whitespace-pre-wrap break-all hover:bg-white/5`}
|
||||
ref={rowVirtualizer.measureElement}
|
||||
data-index={virtualRow.index}
|
||||
className="absolute top-0 left-0 w-full"
|
||||
style={{transform: `translateY(${virtualRow.start}px)`}}
|
||||
>
|
||||
{entry.data}
|
||||
<LogLine data={entry.data} />
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Floating "back to latest" button */}
|
||||
|
||||
@@ -117,7 +117,7 @@ function parseMenuDisplayConfig(raw: string | undefined): Record<string, boolean
|
||||
*/
|
||||
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
const { toggleSidebar, state, isMobile, setOpenMobile } = useSidebar()
|
||||
const { user, logout } = useUser()
|
||||
const {user, logout, loading: userLoading} = useUser()
|
||||
const { config } = usePublicConfig()
|
||||
const [showLogoutDialog, setShowLogoutDialog] = React.useState(false)
|
||||
const [isLoggingOut, setIsLoggingOut] = React.useState(false)
|
||||
@@ -264,10 +264,12 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-base font-semibold truncate mt-2">
|
||||
{user?.nickname || user?.username || "Unknown User"}
|
||||
{userLoading
|
||||
? "加载中..."
|
||||
: user?.nickname || user?.username || "未登录"}
|
||||
</span>
|
||||
<span className="text-xs font-base text-muted-foreground">
|
||||
{user?.is_admin ? "系统管理员" : "普通用户"}
|
||||
{userLoading ? "正在同步账户" : user?.is_admin ? "系统管理员" : "普通用户"}
|
||||
</span>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
|
||||
@@ -103,9 +103,18 @@ export function UserProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
}, [])
|
||||
|
||||
/** 组件挂载时获取用户信息 */
|
||||
/** 组件挂载时获取用户信息(登录/注册页跳过,避免无意义请求) */
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true
|
||||
|
||||
const path = window.location.pathname
|
||||
if (path === "/login" || path === "/register") {
|
||||
setState({user: null, loading: false, error: null})
|
||||
return () => {
|
||||
isMountedRef.current = false
|
||||
}
|
||||
}
|
||||
|
||||
fetchUser()
|
||||
|
||||
return () => {
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
"use client"
|
||||
|
||||
import {useEffect} from "react"
|
||||
import {usePathname, useRouter} from "next/navigation"
|
||||
|
||||
import {useUser} from "@/contexts/user-context"
|
||||
|
||||
/** Redirects unauthenticated users to login while allowing the shell to render first. */
|
||||
export function useAuthRedirect() {
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
const {user, loading} = useUser()
|
||||
|
||||
useEffect(() => {
|
||||
if (loading || user) {
|
||||
return
|
||||
}
|
||||
|
||||
const queryString = window.location.search
|
||||
const callbackUrl = queryString ? `${pathname}${queryString}` : pathname
|
||||
const loginUrl = new URL("/login", window.location.origin)
|
||||
|
||||
loginUrl.searchParams.set("callbackUrl", callbackUrl)
|
||||
sessionStorage.setItem("redirect_after_login", callbackUrl)
|
||||
router.replace(loginUrl.toString())
|
||||
}, [loading, pathname, router, user])
|
||||
}
|
||||
Reference in New Issue
Block a user