block
This commit is contained in:
parent
8d456dacf7
commit
bf02608d93
36
app/[slug]/not-found.tsx
Normal file
36
app/[slug]/not-found.tsx
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { Home, Search } from "lucide-react";
|
||||||
|
|
||||||
|
export default function NotFound() {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-6xl font-bold text-gray-300 dark:text-gray-700 mb-4">404</div>
|
||||||
|
<h1 className="text-2xl font-semibold text-gray-900 dark:text-white mb-4">
|
||||||
|
页面未找到
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mb-8 max-w-md">
|
||||||
|
抱歉,您访问的页面不存在或已被移除。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex gap-4 justify-center">
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="flex items-center gap-2 px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||||
|
>
|
||||||
|
<Home className="w-4 h-4" />
|
||||||
|
返回首页
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href="/example"
|
||||||
|
className="flex items-center gap-2 px-6 py-3 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
|
||||||
|
>
|
||||||
|
<Search className="w-4 h-4" />
|
||||||
|
查看示例
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
39
app/[slug]/page.tsx
Normal file
39
app/[slug]/page.tsx
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import { fetchPage } from "@/lib/fetchers";
|
||||||
|
import { RenderBlock } from "@/components/registry";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
|
||||||
|
export const revalidate = 60; // ISR: 60秒后重新验证
|
||||||
|
|
||||||
|
export default async function Page({ params }: { params: Promise<{ slug: string }> }) {
|
||||||
|
const { slug } = await params;
|
||||||
|
const page = await fetchPage(slug);
|
||||||
|
|
||||||
|
if (!page) {
|
||||||
|
return notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||||
|
<div className="max-w-6xl mx-auto px-4 py-8">
|
||||||
|
{/* 页面标题 */}
|
||||||
|
<header className="mb-8">
|
||||||
|
<h1 className="text-3xl md:text-4xl font-bold text-gray-900 dark:text-white">
|
||||||
|
{page.title}
|
||||||
|
</h1>
|
||||||
|
{page.description && (
|
||||||
|
<p className="mt-2 text-lg text-gray-600 dark:text-gray-400">
|
||||||
|
{page.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* 渲染所有块 */}
|
||||||
|
<div className="space-y-8">
|
||||||
|
{page.blocks.map((block) => (
|
||||||
|
<RenderBlock key={(block as any).id} block={block} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
36
app/admin/[slug]/not-found.tsx
Normal file
36
app/admin/[slug]/not-found.tsx
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { Home, Search } from "lucide-react";
|
||||||
|
|
||||||
|
export default function NotFound() {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-6xl font-bold text-gray-300 dark:text-gray-700 mb-4">404</div>
|
||||||
|
<h1 className="text-2xl font-semibold text-gray-900 dark:text-white mb-4">
|
||||||
|
页面未找到
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mb-8 max-w-md">
|
||||||
|
抱歉,您访问的页面不存在或已被移除。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex gap-4 justify-center">
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="flex items-center gap-2 px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||||
|
>
|
||||||
|
<Home className="w-4 h-4" />
|
||||||
|
返回首页
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href="/search"
|
||||||
|
className="flex items-center gap-2 px-6 py-3 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
|
||||||
|
>
|
||||||
|
<Search className="w-4 h-4" />
|
||||||
|
搜索页面
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
37
app/admin/[slug]/page.tsx
Normal file
37
app/admin/[slug]/page.tsx
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import { fetchPage } from "@/lib/fetchers";
|
||||||
|
import { RenderBlock } from "@/components/registry";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
|
||||||
|
export const revalidate = 60; // ISR: 60秒后重新验证
|
||||||
|
|
||||||
|
export default async function Page({ params }: { params: { slug: string } }) {
|
||||||
|
|
||||||
|
const { slug } = await params
|
||||||
|
const jwt = (await cookies()).get('jwt')?.value
|
||||||
|
const page = await fetchPage(slug, jwt);
|
||||||
|
|
||||||
|
if (!page) {
|
||||||
|
return notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||||
|
<div className="max-w-6xl mx-auto px-4 py-8">
|
||||||
|
{/* 页面标题 */}
|
||||||
|
<header className="mb-8">
|
||||||
|
<h1 className="text-3xl md:text-4xl font-bold text-gray-900 dark:text-white">
|
||||||
|
{page.title}
|
||||||
|
</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* 渲染所有块 */}
|
||||||
|
<div className="space-y-8">
|
||||||
|
{page.blocks.map((block) => (
|
||||||
|
<RenderBlock key={(block as any).id} block={block} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -8,8 +8,7 @@ import {
|
|||||||
import { redirect } from "next/navigation"
|
import { redirect } from "next/navigation"
|
||||||
|
|
||||||
export default async function Layout({ children }: { children: React.ReactNode }) {
|
export default async function Layout({ children }: { children: React.ReactNode }) {
|
||||||
|
const isLoggedIn = (await cookies()).get('jwt')?.value
|
||||||
const isLoggedIn = (await cookies()).get('is_logged_in')?.value === 'true'
|
|
||||||
|
|
||||||
if (!isLoggedIn) {
|
if (!isLoggedIn) {
|
||||||
redirect('/login')
|
redirect('/login')
|
||||||
|
|||||||
@ -1,12 +1,11 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
IconDots,
|
IconDots,
|
||||||
IconFolder,
|
IconFolder,
|
||||||
IconShare3,
|
IconShare3,
|
||||||
IconTrash,
|
IconTrash,
|
||||||
type Icon,
|
|
||||||
} from "@tabler/icons-react"
|
} from "@tabler/icons-react"
|
||||||
|
import { cookies } from "next/headers"
|
||||||
|
import { fetchCategories } from "@/lib/admin-fetchers"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@ -22,30 +21,22 @@ import {
|
|||||||
SidebarMenuAction,
|
SidebarMenuAction,
|
||||||
SidebarMenuButton,
|
SidebarMenuButton,
|
||||||
SidebarMenuItem,
|
SidebarMenuItem,
|
||||||
useSidebar,
|
|
||||||
} from "@/components/ui/sidebar"
|
} from "@/components/ui/sidebar"
|
||||||
|
|
||||||
export function NavDocuments({
|
export async function NavDocuments() {
|
||||||
items,
|
const jwt = (await cookies()).get('jwt')?.value;
|
||||||
}: {
|
const categoriesData = await fetchCategories(jwt);
|
||||||
items: {
|
|
||||||
name: string
|
|
||||||
url: string
|
|
||||||
icon: Icon
|
|
||||||
}[]
|
|
||||||
}) {
|
|
||||||
const { isMobile } = useSidebar()
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
|
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
|
||||||
<SidebarGroupLabel>Documents</SidebarGroupLabel>
|
<SidebarGroupLabel>Categories</SidebarGroupLabel>
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
{items.map((item) => (
|
{categoriesData?.settingCategories?.map((item) => (
|
||||||
<SidebarMenuItem key={item.name}>
|
<SidebarMenuItem key={item.page.slug}>
|
||||||
<SidebarMenuButton asChild>
|
<SidebarMenuButton asChild>
|
||||||
<a href={item.url}>
|
<a href={`/admin/${item.page.slug}`}>
|
||||||
<item.icon />
|
<IconFolder />
|
||||||
<span>{item.name}</span>
|
<span>{item.page.title}</span>
|
||||||
</a>
|
</a>
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
@ -60,8 +51,8 @@ export function NavDocuments({
|
|||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent
|
<DropdownMenuContent
|
||||||
className="w-24 rounded-lg"
|
className="w-24 rounded-lg"
|
||||||
side={isMobile ? "bottom" : "right"}
|
side="right"
|
||||||
align={isMobile ? "end" : "start"}
|
align="start"
|
||||||
>
|
>
|
||||||
<DropdownMenuItem>
|
<DropdownMenuItem>
|
||||||
<IconFolder />
|
<IconFolder />
|
||||||
|
|||||||
68
app/admin/nav-main-client.tsx
Normal file
68
app/admin/nav-main-client.tsx
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import {
|
||||||
|
IconDashboard,
|
||||||
|
IconChartBar,
|
||||||
|
IconUsers,
|
||||||
|
IconCamera,
|
||||||
|
IconFileDescription,
|
||||||
|
IconFileAi,
|
||||||
|
IconSettings,
|
||||||
|
IconHelp,
|
||||||
|
IconSearch,
|
||||||
|
IconDatabase,
|
||||||
|
IconReport
|
||||||
|
} from "@tabler/icons-react"
|
||||||
|
import { usePathname } from "next/navigation"
|
||||||
|
import Link from "next/link"
|
||||||
|
import {
|
||||||
|
SidebarMenu,
|
||||||
|
SidebarMenuButton,
|
||||||
|
SidebarMenuItem,
|
||||||
|
} from "@/components/ui/sidebar"
|
||||||
|
|
||||||
|
// 图标映射
|
||||||
|
const iconMap = {
|
||||||
|
dashboard: IconDashboard,
|
||||||
|
chartBar: IconChartBar,
|
||||||
|
users: IconUsers,
|
||||||
|
camera: IconCamera,
|
||||||
|
fileDescription: IconFileDescription,
|
||||||
|
fileAi: IconFileAi,
|
||||||
|
settings: IconSettings,
|
||||||
|
help: IconHelp,
|
||||||
|
search: IconSearch,
|
||||||
|
database: IconDatabase,
|
||||||
|
report: IconReport,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export function NavMainClient({
|
||||||
|
items,
|
||||||
|
}: {
|
||||||
|
items: {
|
||||||
|
title: string
|
||||||
|
url: string
|
||||||
|
iconName: string
|
||||||
|
}[]
|
||||||
|
}) {
|
||||||
|
const pathname = usePathname()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidebarMenu>
|
||||||
|
{items.map((item) => {
|
||||||
|
const IconComponent = iconMap[item.iconName as keyof typeof iconMap];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidebarMenuItem key={item.title}>
|
||||||
|
<Link href={item.url}>
|
||||||
|
<SidebarMenuButton tooltip={item.title} isActive={pathname === item.url}>
|
||||||
|
{IconComponent && <IconComponent />}
|
||||||
|
<span>{item.title}</span>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</Link>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</SidebarMenu>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -1,7 +1,4 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { IconCirclePlusFilled, IconMail, type Icon } from "@tabler/icons-react"
|
import { IconCirclePlusFilled, IconMail, type Icon } from "@tabler/icons-react"
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import {
|
import {
|
||||||
SidebarGroup,
|
SidebarGroup,
|
||||||
@ -10,10 +7,8 @@ import {
|
|||||||
SidebarMenuButton,
|
SidebarMenuButton,
|
||||||
SidebarMenuItem,
|
SidebarMenuItem,
|
||||||
} from "@/components/ui/sidebar"
|
} from "@/components/ui/sidebar"
|
||||||
import { useEffect, useState } from "react"
|
// import { NavMainClient } from "./nav-main-client"
|
||||||
|
import { NavMainClient } from "./nav-main-client"
|
||||||
import { usePathname } from "next/navigation"
|
|
||||||
import Link from "next/link"
|
|
||||||
|
|
||||||
export function NavMain({
|
export function NavMain({
|
||||||
items,
|
items,
|
||||||
@ -21,11 +16,9 @@ export function NavMain({
|
|||||||
items: {
|
items: {
|
||||||
title: string
|
title: string
|
||||||
url: string
|
url: string
|
||||||
icon?: Icon
|
iconName: string
|
||||||
}[]
|
}[]
|
||||||
}) {
|
}) {
|
||||||
|
|
||||||
const pathname = usePathname()
|
|
||||||
return (
|
return (
|
||||||
<SidebarGroup>
|
<SidebarGroup>
|
||||||
<SidebarGroupContent className="flex flex-col gap-2">
|
<SidebarGroupContent className="flex flex-col gap-2">
|
||||||
@ -48,19 +41,7 @@ export function NavMain({
|
|||||||
</Button>
|
</Button>
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
<SidebarMenu>
|
<NavMainClient items={items} />
|
||||||
{items.map((item) => (
|
|
||||||
<SidebarMenuItem key={item.title}>
|
|
||||||
|
|
||||||
<Link href={item.url}>
|
|
||||||
<SidebarMenuButton tooltip={item.title} isActive={pathname === item.url} >
|
|
||||||
{item.icon && <item.icon />}
|
|
||||||
<span>{item.title}</span>
|
|
||||||
</SidebarMenuButton>
|
|
||||||
</Link>
|
|
||||||
</SidebarMenuItem>
|
|
||||||
))}
|
|
||||||
</SidebarMenu>
|
|
||||||
</SidebarGroupContent>
|
</SidebarGroupContent>
|
||||||
</SidebarGroup>
|
</SidebarGroup>
|
||||||
)
|
)
|
||||||
|
|||||||
48
app/admin/nav-secondary-client.tsx
Normal file
48
app/admin/nav-secondary-client.tsx
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import {
|
||||||
|
IconSettings,
|
||||||
|
IconHelp,
|
||||||
|
IconSearch
|
||||||
|
} from "@tabler/icons-react"
|
||||||
|
import {
|
||||||
|
SidebarMenu,
|
||||||
|
SidebarMenuButton,
|
||||||
|
SidebarMenuItem,
|
||||||
|
} from "@/components/ui/sidebar"
|
||||||
|
|
||||||
|
// 图标映射
|
||||||
|
const iconMap = {
|
||||||
|
settings: IconSettings,
|
||||||
|
help: IconHelp,
|
||||||
|
search: IconSearch,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export function NavSecondaryClient({
|
||||||
|
items,
|
||||||
|
}: {
|
||||||
|
items: {
|
||||||
|
title: string
|
||||||
|
url: string
|
||||||
|
iconName: string
|
||||||
|
}[]
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SidebarMenu>
|
||||||
|
{items.map((item) => {
|
||||||
|
const IconComponent = iconMap[item.iconName as keyof typeof iconMap];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidebarMenuItem key={item.title}>
|
||||||
|
<SidebarMenuButton asChild>
|
||||||
|
<a href={item.url}>
|
||||||
|
{IconComponent && <IconComponent />}
|
||||||
|
<span>{item.title}</span>
|
||||||
|
</a>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</SidebarMenu>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -1,5 +1,3 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { type Icon } from "@tabler/icons-react"
|
import { type Icon } from "@tabler/icons-react"
|
||||||
|
|
||||||
@ -10,6 +8,8 @@ import {
|
|||||||
SidebarMenuButton,
|
SidebarMenuButton,
|
||||||
SidebarMenuItem,
|
SidebarMenuItem,
|
||||||
} from "@/components/ui/sidebar"
|
} from "@/components/ui/sidebar"
|
||||||
|
// import { NavSecondaryClient } from "./nav-secondary-client"
|
||||||
|
import { NavSecondaryClient } from "./nav-secondary-client"
|
||||||
|
|
||||||
export function NavSecondary({
|
export function NavSecondary({
|
||||||
items,
|
items,
|
||||||
@ -18,24 +18,13 @@ export function NavSecondary({
|
|||||||
items: {
|
items: {
|
||||||
title: string
|
title: string
|
||||||
url: string
|
url: string
|
||||||
icon: Icon
|
iconName: string
|
||||||
}[]
|
}[]
|
||||||
} & React.ComponentPropsWithoutRef<typeof SidebarGroup>) {
|
} & React.ComponentPropsWithoutRef<typeof SidebarGroup>) {
|
||||||
return (
|
return (
|
||||||
<SidebarGroup {...props}>
|
<SidebarGroup {...props}>
|
||||||
<SidebarGroupContent>
|
<SidebarGroupContent>
|
||||||
<SidebarMenu>
|
<NavSecondaryClient items={items} />
|
||||||
{items.map((item) => (
|
|
||||||
<SidebarMenuItem key={item.title}>
|
|
||||||
<SidebarMenuButton asChild>
|
|
||||||
<a href={item.url}>
|
|
||||||
<item.icon />
|
|
||||||
<span>{item.title}</span>
|
|
||||||
</a>
|
|
||||||
</SidebarMenuButton>
|
|
||||||
</SidebarMenuItem>
|
|
||||||
))}
|
|
||||||
</SidebarMenu>
|
|
||||||
</SidebarGroupContent>
|
</SidebarGroupContent>
|
||||||
</SidebarGroup>
|
</SidebarGroup>
|
||||||
)
|
)
|
||||||
|
|||||||
104
app/admin/nav-user-client.tsx
Normal file
104
app/admin/nav-user-client.tsx
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import {
|
||||||
|
IconCreditCard,
|
||||||
|
IconDotsVertical,
|
||||||
|
IconLogout,
|
||||||
|
IconNotification,
|
||||||
|
IconUserCircle,
|
||||||
|
} from "@tabler/icons-react"
|
||||||
|
|
||||||
|
import {
|
||||||
|
Avatar,
|
||||||
|
AvatarFallback,
|
||||||
|
AvatarImage,
|
||||||
|
} from "@/components/ui/avatar"
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu"
|
||||||
|
import {
|
||||||
|
SidebarMenuButton,
|
||||||
|
useSidebar,
|
||||||
|
} from "@/components/ui/sidebar"
|
||||||
|
|
||||||
|
export function NavUserClient({
|
||||||
|
user,
|
||||||
|
}: {
|
||||||
|
user: {
|
||||||
|
name: string
|
||||||
|
email: string
|
||||||
|
avatar: string
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
const { isMobile } = useSidebar()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<SidebarMenuButton
|
||||||
|
size="lg"
|
||||||
|
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||||||
|
>
|
||||||
|
<Avatar className="h-8 w-8 rounded-lg grayscale">
|
||||||
|
<AvatarImage src={user.avatar} alt={user.name} />
|
||||||
|
<AvatarFallback className="rounded-lg">CN</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||||
|
<span className="truncate font-medium">{user.name}</span>
|
||||||
|
<span className="text-muted-foreground truncate text-xs">
|
||||||
|
{user.email}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<IconDotsVertical className="ml-auto size-4" />
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent
|
||||||
|
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
|
||||||
|
side={isMobile ? "bottom" : "right"}
|
||||||
|
align="end"
|
||||||
|
sideOffset={4}
|
||||||
|
>
|
||||||
|
<DropdownMenuLabel className="p-0 font-normal">
|
||||||
|
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
|
||||||
|
<Avatar className="h-8 w-8 rounded-lg">
|
||||||
|
<AvatarImage src={user.avatar} alt={user.name} />
|
||||||
|
<AvatarFallback className="rounded-lg">CN</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||||
|
<span className="truncate font-medium">{user.name}</span>
|
||||||
|
<span className="text-muted-foreground truncate text-xs">
|
||||||
|
{user.email}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuGroup>
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<IconUserCircle />
|
||||||
|
Account
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<IconCreditCard />
|
||||||
|
Billing
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<IconNotification />
|
||||||
|
Notifications
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuGroup>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<IconLogout />
|
||||||
|
Log out
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -1,5 +1,3 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
IconCreditCard,
|
IconCreditCard,
|
||||||
IconDotsVertical,
|
IconDotsVertical,
|
||||||
@ -26,8 +24,8 @@ import {
|
|||||||
SidebarMenu,
|
SidebarMenu,
|
||||||
SidebarMenuButton,
|
SidebarMenuButton,
|
||||||
SidebarMenuItem,
|
SidebarMenuItem,
|
||||||
useSidebar,
|
|
||||||
} from "@/components/ui/sidebar"
|
} from "@/components/ui/sidebar"
|
||||||
|
import { NavUserClient } from "./nav-user-client"
|
||||||
|
|
||||||
export function NavUser({
|
export function NavUser({
|
||||||
user,
|
user,
|
||||||
@ -38,72 +36,10 @@ export function NavUser({
|
|||||||
avatar: string
|
avatar: string
|
||||||
}
|
}
|
||||||
}) {
|
}) {
|
||||||
const { isMobile } = useSidebar()
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
<SidebarMenuItem>
|
<SidebarMenuItem>
|
||||||
<DropdownMenu>
|
<NavUserClient user={user} />
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<SidebarMenuButton
|
|
||||||
size="lg"
|
|
||||||
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
|
||||||
>
|
|
||||||
<Avatar className="h-8 w-8 rounded-lg grayscale">
|
|
||||||
<AvatarImage src={user.avatar} alt={user.name} />
|
|
||||||
<AvatarFallback className="rounded-lg">CN</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
|
||||||
<span className="truncate font-medium">{user.name}</span>
|
|
||||||
<span className="text-muted-foreground truncate text-xs">
|
|
||||||
{user.email}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<IconDotsVertical className="ml-auto size-4" />
|
|
||||||
</SidebarMenuButton>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent
|
|
||||||
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
|
|
||||||
side={isMobile ? "bottom" : "right"}
|
|
||||||
align="end"
|
|
||||||
sideOffset={4}
|
|
||||||
>
|
|
||||||
<DropdownMenuLabel className="p-0 font-normal">
|
|
||||||
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
|
|
||||||
<Avatar className="h-8 w-8 rounded-lg">
|
|
||||||
<AvatarImage src={user.avatar} alt={user.name} />
|
|
||||||
<AvatarFallback className="rounded-lg">CN</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
|
||||||
<span className="truncate font-medium">{user.name}</span>
|
|
||||||
<span className="text-muted-foreground truncate text-xs">
|
|
||||||
{user.email}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DropdownMenuLabel>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuGroup>
|
|
||||||
<DropdownMenuItem>
|
|
||||||
<IconUserCircle />
|
|
||||||
Account
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem>
|
|
||||||
<IconCreditCard />
|
|
||||||
Billing
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem>
|
|
||||||
<IconNotification />
|
|
||||||
Notifications
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuGroup>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem>
|
|
||||||
<IconLogout />
|
|
||||||
Log out
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1,5 +1,3 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import {
|
import {
|
||||||
IconCamera,
|
IconCamera,
|
||||||
@ -43,23 +41,23 @@ const data = {
|
|||||||
{
|
{
|
||||||
title: "Dashboard",
|
title: "Dashboard",
|
||||||
url: "/admin/dashboard",
|
url: "/admin/dashboard",
|
||||||
icon: IconDashboard,
|
iconName: "dashboard",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Analytics",
|
title: "Analytics",
|
||||||
url: "/admin/analytics",
|
url: "/admin/analytics",
|
||||||
icon: IconChartBar,
|
iconName: "chartBar",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Users",
|
title: "Users",
|
||||||
url: "/admin/users",
|
url: "/admin/users",
|
||||||
icon: IconUsers,
|
iconName: "users",
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
navClouds: [
|
navClouds: [
|
||||||
{
|
{
|
||||||
title: "Capture",
|
title: "Capture",
|
||||||
icon: IconCamera,
|
iconName: "camera",
|
||||||
isActive: true,
|
isActive: true,
|
||||||
url: "#",
|
url: "#",
|
||||||
items: [
|
items: [
|
||||||
@ -75,7 +73,7 @@ const data = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Proposal",
|
title: "Proposal",
|
||||||
icon: IconFileDescription,
|
iconName: "fileDescription",
|
||||||
url: "#",
|
url: "#",
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
@ -90,7 +88,7 @@ const data = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Prompts",
|
title: "Prompts",
|
||||||
icon: IconFileAi,
|
iconName: "fileAi",
|
||||||
url: "#",
|
url: "#",
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
@ -108,37 +106,35 @@ const data = {
|
|||||||
{
|
{
|
||||||
title: "Settings",
|
title: "Settings",
|
||||||
url: "#",
|
url: "#",
|
||||||
icon: IconSettings,
|
iconName: "settings",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Get Help",
|
title: "Get Help",
|
||||||
url: "#",
|
url: "#",
|
||||||
icon: IconHelp,
|
iconName: "help",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Search",
|
title: "Search",
|
||||||
url: "#",
|
url: "#",
|
||||||
icon: IconSearch,
|
iconName: "search",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
documents: [
|
documents: [
|
||||||
{
|
{
|
||||||
name: "Data Library",
|
name: "Data Library",
|
||||||
url: "#",
|
url: "#",
|
||||||
icon: IconDatabase,
|
iconName: "database",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Reports",
|
name: "Reports",
|
||||||
url: "#",
|
url: "#",
|
||||||
icon: IconReport,
|
iconName: "report",
|
||||||
},
|
},
|
||||||
|
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
export async function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||||
const [nav, setNav] = React.useState(data.navMain)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sidebar collapsible="offcanvas" {...props}>
|
<Sidebar collapsible="offcanvas" {...props}>
|
||||||
<SidebarHeader>
|
<SidebarHeader>
|
||||||
@ -158,7 +154,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
|||||||
</SidebarHeader>
|
</SidebarHeader>
|
||||||
<SidebarContent>
|
<SidebarContent>
|
||||||
<NavMain items={data.navMain} />
|
<NavMain items={data.navMain} />
|
||||||
<NavDocuments items={data.documents} />
|
<NavDocuments />
|
||||||
<NavSecondary items={data.navSecondary} className="mt-auto" />
|
<NavSecondary items={data.navSecondary} className="mt-auto" />
|
||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
<SidebarFooter>
|
<SidebarFooter>
|
||||||
|
|||||||
60
app/api/bff/route.ts
Normal file
60
app/api/bff/route.ts
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
|
||||||
|
// 确保请求体包含必要的GraphQL字段
|
||||||
|
if (!body.query) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Missing GraphQL query' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转发到后端GraphQL
|
||||||
|
const response = await fetch(process.env.GRAPHQL_BACKEND_URL || 'http://localhost:3050/graphql', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': request.headers.get('authorization') || '',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
query: body.query,
|
||||||
|
variables: body.variables || {},
|
||||||
|
operationName: body.operationName,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Backend responded with status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
return NextResponse.json(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('BFF proxy error:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: 'Internal server error',
|
||||||
|
message: error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
return NextResponse.json({
|
||||||
|
message: 'BFF endpoint - use POST for GraphQL queries',
|
||||||
|
usage: {
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
query: 'GraphQL query string',
|
||||||
|
variables: 'Query variables object (optional)',
|
||||||
|
operationName: 'Operation name (optional)'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
66
app/api/login/route.ts
Normal file
66
app/api/login/route.ts
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { gql, GraphQLClient } from 'graphql-request';
|
||||||
|
|
||||||
|
const LOGIN_MUTATION = gql`
|
||||||
|
mutation Login($username: String!, $password: String!) {
|
||||||
|
login(input: { username: $username, password: $password }) {
|
||||||
|
token
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
|
||||||
|
const { username, password } = body;
|
||||||
|
|
||||||
|
if (!username || !password) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Missing username or password' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = new GraphQLClient(process.env.GRAPHQL_BACKEND_URL || 'http://localhost:3050/graphql');
|
||||||
|
const response: any = await client.request(LOGIN_MUTATION, { username, password });
|
||||||
|
|
||||||
|
const jwt = response.login.token;
|
||||||
|
|
||||||
|
const res = NextResponse.json({ ok: true, token: jwt })
|
||||||
|
res.cookies.set('jwt', jwt, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: true,
|
||||||
|
sameSite: 'lax',
|
||||||
|
path: '/',
|
||||||
|
maxAge: 60 * 60 * 24, // 1d
|
||||||
|
})
|
||||||
|
return res
|
||||||
|
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
|
||||||
|
console.error('Login error:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: 'Internal server error',
|
||||||
|
message: error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
return NextResponse.json({
|
||||||
|
message: 'BFF endpoint - use POST for GraphQL queries',
|
||||||
|
usage: {
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
query: 'GraphQL query string',
|
||||||
|
variables: 'Query variables object (optional)',
|
||||||
|
operationName: 'Operation name (optional)'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
0
app/api/session/logout/route.ts
Normal file
0
app/api/session/logout/route.ts
Normal file
73
app/api/session/sync/route.ts
Normal file
73
app/api/session/sync/route.ts
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { gql, GraphQLClient } from 'graphql-request';
|
||||||
|
|
||||||
|
const LOGIN_MUTATION = gql`
|
||||||
|
mutation Login($username: String!, $password: String!) {
|
||||||
|
login(input: { username: $username, password: $password }) {
|
||||||
|
token
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
const GET_USER_QUERY = gql`
|
||||||
|
query GetUser {
|
||||||
|
currentUser {
|
||||||
|
id
|
||||||
|
username
|
||||||
|
email
|
||||||
|
role
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
|
||||||
|
const jwt = body.jwt;
|
||||||
|
|
||||||
|
const client = new GraphQLClient(process.env.GRAPHQL_BACKEND_URL || 'http://localhost:3050/graphql', {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${jwt}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const response: any = await client.request(GET_USER_QUERY);
|
||||||
|
const res = NextResponse.json({ ok: true, token: jwt })
|
||||||
|
|
||||||
|
res.cookies.set('jwt', jwt, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: true,
|
||||||
|
sameSite: 'lax',
|
||||||
|
path: '/',
|
||||||
|
maxAge: 60 * 60 * 24, // 1d
|
||||||
|
})
|
||||||
|
return res
|
||||||
|
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
|
||||||
|
console.error('Login error:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: 'Internal server error',
|
||||||
|
message: error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
return NextResponse.json({
|
||||||
|
message: 'BFF endpoint - use POST for GraphQL queries',
|
||||||
|
usage: {
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
query: 'GraphQL query string',
|
||||||
|
variables: 'Query variables object (optional)',
|
||||||
|
operationName: 'Operation name (optional)'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -26,7 +26,7 @@ export default function RootLayout({
|
|||||||
}>) {
|
}>) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang="en" suppressHydrationWarning>
|
<html lang="en" className="dark" suppressHydrationWarning>
|
||||||
<body
|
<body
|
||||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -7,7 +7,7 @@ import { redirect } from "next/navigation"
|
|||||||
|
|
||||||
export default async function Layout({ children }: { children: React.ReactNode }) {
|
export default async function Layout({ children }: { children: React.ReactNode }) {
|
||||||
|
|
||||||
const isLoggedIn = (await cookies()).get('is_logged_in')?.value === 'true'
|
const isLoggedIn = (await cookies()).get('jwt')?.value
|
||||||
|
|
||||||
if (isLoggedIn) {
|
if (isLoggedIn) {
|
||||||
redirect('/')
|
redirect('/')
|
||||||
|
|||||||
@ -8,7 +8,7 @@ import { redirect } from "next/navigation"
|
|||||||
|
|
||||||
export default async function Layout({ children }: { children: React.ReactNode }) {
|
export default async function Layout({ children }: { children: React.ReactNode }) {
|
||||||
|
|
||||||
const isLoggedIn = (await cookies()).get('is_logged_in')?.value === 'true'
|
const isLoggedIn = (await cookies()).get('jwt')?.value
|
||||||
|
|
||||||
console.log(isLoggedIn)
|
console.log(isLoggedIn)
|
||||||
|
|
||||||
|
|||||||
@ -31,13 +31,6 @@ const GET_USER_QUERY = gql`
|
|||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
|
||||||
const LOGIN_MUTATION = gql`
|
|
||||||
mutation Login($username: String!, $password: String!) {
|
|
||||||
login(input: { username: $username, password: $password }) {
|
|
||||||
token
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`
|
|
||||||
|
|
||||||
const REGISTER_MUTATION = gql`
|
const REGISTER_MUTATION = gql`
|
||||||
mutation Register($username: String!, $password: String!) {
|
mutation Register($username: String!, $password: String!) {
|
||||||
@ -62,8 +55,6 @@ export function UserProvider({ children }: UserProviderProps) {
|
|||||||
isLoading: true
|
isLoading: true
|
||||||
})
|
})
|
||||||
|
|
||||||
// 将 useMutation hooks 移到组件顶层
|
|
||||||
const [loginMutation, { loading: loginLoading, error: loginError }] = useMutation(LOGIN_MUTATION)
|
|
||||||
const [registerMutation, { loading: registerLoading, error: registerError }] = useMutation(REGISTER_MUTATION)
|
const [registerMutation, { loading: registerLoading, error: registerError }] = useMutation(REGISTER_MUTATION)
|
||||||
|
|
||||||
// 定期查询用户信息的 hook
|
// 定期查询用户信息的 hook
|
||||||
@ -105,11 +96,10 @@ export function UserProvider({ children }: UserProviderProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const initializeAuth = () => {
|
const initializeAuth = async () => {
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem(TOKEN_KEY)
|
const token = localStorage.getItem(TOKEN_KEY)
|
||||||
|
|
||||||
|
|
||||||
if (token && isTokenValid(token)) {
|
if (token && isTokenValid(token)) {
|
||||||
const payload = parseJWT(token)
|
const payload = parseJWT(token)
|
||||||
if (payload) {
|
if (payload) {
|
||||||
@ -126,7 +116,16 @@ export function UserProvider({ children }: UserProviderProps) {
|
|||||||
isAuthenticated: true,
|
isAuthenticated: true,
|
||||||
isLoading: false
|
isLoading: false
|
||||||
})
|
})
|
||||||
document.cookie = 'is_logged_in=true; path=/; max-age=3600'
|
|
||||||
|
const res = await fetch('/api/session/sync', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ jwt: token })
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error('Failed to sync session')
|
||||||
|
}
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -138,7 +137,6 @@ export function UserProvider({ children }: UserProviderProps) {
|
|||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
isLoading: false
|
isLoading: false
|
||||||
})
|
})
|
||||||
document.cookie = 'is_logged_in=false; path=/; max-age=3600'
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Auth initialization error:', error)
|
console.error('Auth initialization error:', error)
|
||||||
setAuthState({
|
setAuthState({
|
||||||
@ -147,7 +145,6 @@ export function UserProvider({ children }: UserProviderProps) {
|
|||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
isLoading: false
|
isLoading: false
|
||||||
})
|
})
|
||||||
document.cookie = 'is_logged_in=false; path=/; max-age=3600'
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -155,20 +152,24 @@ export function UserProvider({ children }: UserProviderProps) {
|
|||||||
try {
|
try {
|
||||||
setAuthState(prev => ({ ...prev, isLoading: true }))
|
setAuthState(prev => ({ ...prev, isLoading: true }))
|
||||||
|
|
||||||
|
const response = await fetch('/api/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(credentials)
|
||||||
|
})
|
||||||
|
|
||||||
const response = await loginMutation({ variables: credentials })
|
const { token, ok } = await response.json()
|
||||||
|
|
||||||
if (loginError) {
|
if (!ok) {
|
||||||
throw new Error('Login failed')
|
throw new Error('Login failed')
|
||||||
}
|
}
|
||||||
|
|
||||||
const { token } = response.data.login
|
|
||||||
|
|
||||||
if (!isTokenValid(token)) {
|
if (!isTokenValid(token)) {
|
||||||
throw new Error('Invalid token received')
|
throw new Error('Invalid token received')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const payload = parseJWT(token)
|
const payload = parseJWT(token)
|
||||||
if (!payload) {
|
if (!payload) {
|
||||||
throw new Error('Failed to parse token')
|
throw new Error('Failed to parse token')
|
||||||
@ -188,11 +189,6 @@ export function UserProvider({ children }: UserProviderProps) {
|
|||||||
isAuthenticated: true,
|
isAuthenticated: true,
|
||||||
isLoading: false
|
isLoading: false
|
||||||
})
|
})
|
||||||
|
|
||||||
document.cookie = 'is_logged_in=true; path=/; max-age=3600'
|
|
||||||
|
|
||||||
// 登录成功后重置 Apollo 缓存
|
|
||||||
resetApolloCache()
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Login error:', error)
|
console.error('Login error:', error)
|
||||||
setAuthState({
|
setAuthState({
|
||||||
@ -264,7 +260,6 @@ export function UserProvider({ children }: UserProviderProps) {
|
|||||||
isLoading: false
|
isLoading: false
|
||||||
})
|
})
|
||||||
|
|
||||||
document.cookie = 'is_logged_in=false; path=/; max-age=3600'
|
|
||||||
|
|
||||||
// 登出后重置 Apollo 缓存
|
// 登出后重置 Apollo 缓存
|
||||||
resetApolloCache()
|
resetApolloCache()
|
||||||
@ -308,7 +303,6 @@ export function UserProvider({ children }: UserProviderProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
localStorage.setItem(TOKEN_KEY, token)
|
localStorage.setItem(TOKEN_KEY, token)
|
||||||
document.cookie = 'is_logged_in=true; path=/; max-age=3600'
|
|
||||||
setAuthState({
|
setAuthState({
|
||||||
user,
|
user,
|
||||||
token,
|
token,
|
||||||
@ -320,7 +314,6 @@ export function UserProvider({ children }: UserProviderProps) {
|
|||||||
resetApolloCache()
|
resetApolloCache()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Token refresh error:', error)
|
console.error('Token refresh error:', error)
|
||||||
document.cookie = 'is_logged_in=false; path=/; max-age=3600'
|
|
||||||
logout()
|
logout()
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
@ -350,8 +343,8 @@ export function UserProvider({ children }: UserProviderProps) {
|
|||||||
|
|
||||||
// 监听登录和注册的 loading 状态
|
// 监听登录和注册的 loading 状态
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setAuthState(prev => ({ ...prev, isLoading: loginLoading || registerLoading || userLoading }))
|
setAuthState(prev => ({ ...prev, isLoading: registerLoading || userLoading }))
|
||||||
}, [loginLoading, registerLoading, userLoading])
|
}, [registerLoading, userLoading])
|
||||||
|
|
||||||
// 监听用户数据变化,定期更新用户信息
|
// 监听用户数据变化,定期更新用户信息
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
41
components/blocks/ChartBlock.tsx
Normal file
41
components/blocks/ChartBlock.tsx
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
"use client";
|
||||||
|
import type { ChartBlock } from "@/types/page";
|
||||||
|
import { LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid } from "recharts";
|
||||||
|
|
||||||
|
export default function ChartBlockView({ title, series }: ChartBlock) {
|
||||||
|
return (
|
||||||
|
<div className="w-full h-80 rounded-xl border border-gray-200 dark:border-gray-700 p-6 bg-white dark:bg-gray-800">
|
||||||
|
<div className="mb-4 font-medium text-lg text-gray-900 dark:text-gray-100">{title}</div>
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<LineChart data={series}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="x"
|
||||||
|
stroke="#6b7280"
|
||||||
|
tick={{ fill: '#6b7280' }}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
stroke="#6b7280"
|
||||||
|
tick={{ fill: '#6b7280' }}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: '#1f2937',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '8px',
|
||||||
|
color: '#f9fafb'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="y"
|
||||||
|
stroke="#3b82f6"
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={{ fill: '#3b82f6', strokeWidth: 2, r: 4 }}
|
||||||
|
activeDot={{ r: 6, stroke: '#3b82f6', strokeWidth: 2 }}
|
||||||
|
/>
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
28
components/blocks/HeroBlock.tsx
Normal file
28
components/blocks/HeroBlock.tsx
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
"use client";
|
||||||
|
import type { HeroBlock } from "@/types/page";
|
||||||
|
|
||||||
|
export default function HeroBlockView({ title, subtitle, backgroundImage, ctaText, ctaLink }: HeroBlock) {
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className="relative h-[56vh] flex flex-col justify-center items-center text-white text-center rounded-2xl overflow-hidden"
|
||||||
|
style={{
|
||||||
|
backgroundImage: backgroundImage ? `url(${backgroundImage})` : undefined,
|
||||||
|
backgroundSize: "cover",
|
||||||
|
backgroundPosition: "center"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="backdrop-brightness-75 p-6 w-full h-full flex flex-col justify-center items-center">
|
||||||
|
<h1 className="text-4xl md:text-5xl font-semibold mb-4">{title}</h1>
|
||||||
|
{subtitle && <p className="mt-2 opacity-90 text-lg md:text-xl max-w-2xl">{subtitle}</p>}
|
||||||
|
{ctaText && (
|
||||||
|
<a
|
||||||
|
href={ctaLink ?? "#"}
|
||||||
|
className="mt-8 inline-block px-8 py-3 rounded-xl bg-white/90 text-black hover:bg-white transition-all duration-200 font-medium"
|
||||||
|
>
|
||||||
|
{ctaText}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
41
components/blocks/SettingsBlock.tsx
Normal file
41
components/blocks/SettingsBlock.tsx
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
"use client";
|
||||||
|
import type { SettingsBlock } from "@/types/page";
|
||||||
|
import { Settings, Edit, Lock } from "lucide-react";
|
||||||
|
|
||||||
|
export default function SettingsBlockView({ category, editable }: SettingsBlock) {
|
||||||
|
return (
|
||||||
|
<div className="w-full rounded-xl border border-gray-200 dark:border-gray-700 p-6 bg-white dark:bg-gray-800">
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<Settings className="w-5 h-5 text-gray-600 dark:text-gray-400" />
|
||||||
|
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100">
|
||||||
|
设置 - {category}
|
||||||
|
</h3>
|
||||||
|
{editable ? (
|
||||||
|
<Edit className="w-4 h-4 text-green-600" />
|
||||||
|
) : (
|
||||||
|
<Lock className="w-4 h-4 text-gray-400" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||||
|
<span className="text-sm text-gray-600 dark:text-gray-400">类别</span>
|
||||||
|
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">{category}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||||
|
<span className="text-sm text-gray-600 dark:text-gray-400">编辑权限</span>
|
||||||
|
<span className={`text-sm font-medium ${editable ? 'text-green-600' : 'text-gray-500'}`}>
|
||||||
|
{editable ? '可编辑' : '只读'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{editable && (
|
||||||
|
<button className="mt-4 w-full px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors">
|
||||||
|
编辑设置
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
156
components/blocks/TextBlock.tsx
Normal file
156
components/blocks/TextBlock.tsx
Normal file
@ -0,0 +1,156 @@
|
|||||||
|
"use client";
|
||||||
|
import ReactMarkdown from 'react-markdown';
|
||||||
|
import remarkGfm from 'remark-gfm';
|
||||||
|
import remarkMath from 'remark-math';
|
||||||
|
import rehypeKatex from 'rehype-katex';
|
||||||
|
import rehypePrism from 'rehype-prism-plus';
|
||||||
|
import type { TextBlock } from "@/types/page";
|
||||||
|
|
||||||
|
export default function TextBlockView(props: TextBlock) {
|
||||||
|
return (
|
||||||
|
<article className="prose prose-gray max-w-none dark:prose-invert">
|
||||||
|
<div className="text-gray-700 dark:text-gray-300 leading-relaxed">
|
||||||
|
<ReactMarkdown
|
||||||
|
remarkPlugins={[remarkGfm, remarkMath]}
|
||||||
|
rehypePlugins={[rehypeKatex, rehypePrism]}
|
||||||
|
components={{
|
||||||
|
// 自定义代码块样式
|
||||||
|
code({ inline, className, children, ...props }: any) {
|
||||||
|
const match = /language-(\w+)/.exec(className || '');
|
||||||
|
return !inline && match ? (
|
||||||
|
<pre className="bg-gray-100 dark:bg-gray-800 rounded-lg p-4 overflow-x-auto">
|
||||||
|
<code className={className} {...props}>
|
||||||
|
{children}
|
||||||
|
</code>
|
||||||
|
</pre>
|
||||||
|
) : (
|
||||||
|
<code className="bg-gray-100 dark:bg-gray-700 px-1 py-0.5 rounded text-sm" {...props}>
|
||||||
|
{children}
|
||||||
|
</code>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
// 自定义链接样式
|
||||||
|
a({ href, children }: any) {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={href}
|
||||||
|
className="text-blue-600 dark:text-blue-400 hover:underline transition-colors"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
// 自定义表格样式
|
||||||
|
table({ children }: any) {
|
||||||
|
return (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-full border-collapse border border-gray-300 dark:border-gray-600">
|
||||||
|
{children}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
th({ children }: any) {
|
||||||
|
return (
|
||||||
|
<th className="border border-gray-300 dark:border-gray-600 px-4 py-2 bg-gray-100 dark:bg-gray-700 font-semibold">
|
||||||
|
{children}
|
||||||
|
</th>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
td({ children }: any) {
|
||||||
|
return (
|
||||||
|
<td className="border border-gray-300 dark:border-gray-600 px-4 py-2">
|
||||||
|
{children}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
// 自定义引用样式
|
||||||
|
blockquote({ children }: any) {
|
||||||
|
return (
|
||||||
|
<blockquote className="border-l-4 border-blue-500 pl-4 py-2 bg-blue-50 dark:bg-blue-900/20 italic">
|
||||||
|
{children}
|
||||||
|
</blockquote>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
// 自定义列表样式
|
||||||
|
ul({ children }: any) {
|
||||||
|
return (
|
||||||
|
<ul className="list-disc list-inside space-y-1">
|
||||||
|
{children}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
ol({ children }: any) {
|
||||||
|
return (
|
||||||
|
<ol className="list-decimal list-inside space-y-1">
|
||||||
|
{children}
|
||||||
|
</ol>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
// 自定义标题样式
|
||||||
|
h1({ children }: any) {
|
||||||
|
return (
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100 mb-4 mt-6">
|
||||||
|
{children}
|
||||||
|
</h1>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
h2({ children }: any) {
|
||||||
|
return (
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-gray-100 mb-3 mt-5">
|
||||||
|
{children}
|
||||||
|
</h2>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
h3({ children }: any) {
|
||||||
|
return (
|
||||||
|
<h3 className="text-xl font-bold text-gray-900 dark:text-gray-100 mb-2 mt-4">
|
||||||
|
{children}
|
||||||
|
</h3>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
h4({ children }: any) {
|
||||||
|
return (
|
||||||
|
<h4 className="text-lg font-bold text-gray-900 dark:text-gray-100 mb-2 mt-3">
|
||||||
|
{children}
|
||||||
|
</h4>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
h5({ children }: any) {
|
||||||
|
return (
|
||||||
|
<h5 className="text-base font-bold text-gray-900 dark:text-gray-100 mb-1 mt-2">
|
||||||
|
{children}
|
||||||
|
</h5>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
h6({ children }: any) {
|
||||||
|
return (
|
||||||
|
<h6 className="text-sm font-bold text-gray-900 dark:text-gray-100 mb-1 mt-2">
|
||||||
|
{children}
|
||||||
|
</h6>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
// 自定义段落样式
|
||||||
|
p({ children }: any) {
|
||||||
|
return (
|
||||||
|
<p className="mb-4 leading-relaxed">
|
||||||
|
{children}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
// 自定义分割线样式
|
||||||
|
hr() {
|
||||||
|
return (
|
||||||
|
<hr className="my-8 border-gray-300 dark:border-gray-600" />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{props.markdown}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -142,6 +142,11 @@ export function MapComponent({
|
|||||||
}
|
}
|
||||||
|
|
||||||
vec4 texColor = texture(u_tex, uv);
|
vec4 texColor = texture(u_tex, uv);
|
||||||
|
|
||||||
|
if (texColor.r <= 0.0196 || texColor.r > 0.29411) {
|
||||||
|
discard;
|
||||||
|
}
|
||||||
|
|
||||||
float value = texColor.r * 3.4;
|
float value = texColor.r * 3.4;
|
||||||
value = clamp(value, 0.0, 1.0);
|
value = clamp(value, 0.0, 1.0);
|
||||||
|
|
||||||
|
|||||||
41
components/registry/index.tsx
Normal file
41
components/registry/index.tsx
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
|
import type { Block } from "@/types/page";
|
||||||
|
import TextBlockView from "@/components/blocks/TextBlock";
|
||||||
|
import HeroBlockView from "@/components/blocks/HeroBlock";
|
||||||
|
import SettingsBlockView from "@/components/blocks/SettingsBlock";
|
||||||
|
|
||||||
|
// 客户端大组件按需加载(减少首屏 JS)
|
||||||
|
const ChartBlockView = dynamic(() => import("@/components/blocks/ChartBlock"), {
|
||||||
|
ssr: false, // 图表多数只在浏览器渲染
|
||||||
|
loading: () => (
|
||||||
|
<div className="w-full h-80 rounded-xl border border-gray-200 dark:border-gray-700 p-6 bg-white dark:bg-gray-800 flex items-center justify-center">
|
||||||
|
<div className="text-sm text-gray-500 dark:text-gray-400">图表加载中…</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
export function RenderBlock({ block }: { block: Block }) {
|
||||||
|
switch (block.__typename) {
|
||||||
|
case "TextBlockType":
|
||||||
|
return <TextBlockView {...block} />;
|
||||||
|
|
||||||
|
case "ChartBlockType":
|
||||||
|
return <ChartBlockView {...block} />;
|
||||||
|
|
||||||
|
case "SettingsBlockType":
|
||||||
|
return <SettingsBlockView {...block} />;
|
||||||
|
|
||||||
|
case "HeroBlockType":
|
||||||
|
return <HeroBlockView {...block} />;
|
||||||
|
|
||||||
|
default:
|
||||||
|
console.warn(`未知块类型:${(block as any).__typename ?? "Unknown"}`, block);
|
||||||
|
return (
|
||||||
|
<div className="w-full p-4 text-center text-xs text-gray-500 dark:text-gray-400 border border-dashed border-gray-300 dark:border-gray-600 rounded-lg">
|
||||||
|
未知块类型:{(block as any).__typename ?? "Unknown"}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -16,7 +16,7 @@ type ThemeProviderState = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const initialState: ThemeProviderState = {
|
const initialState: ThemeProviderState = {
|
||||||
theme: 'system',
|
theme: 'dark',
|
||||||
setTheme: () => null,
|
setTheme: () => null,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
49
lib/admin-fetchers.ts
Normal file
49
lib/admin-fetchers.ts
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
import { gql, GraphQLClient } from "graphql-request";
|
||||||
|
import { getBaseUrl } from "./gr-client";
|
||||||
|
|
||||||
|
const CategoriesQuery = gql`
|
||||||
|
query Categories {
|
||||||
|
settingCategories {
|
||||||
|
page {
|
||||||
|
id
|
||||||
|
title
|
||||||
|
slug
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export type CategoryPage = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
slug: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SettingCategory = {
|
||||||
|
page: CategoryPage;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CategoriesData = {
|
||||||
|
settingCategories: SettingCategory[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function fetchCategories(jwt?: string): Promise<CategoriesData | null> {
|
||||||
|
const client = new GraphQLClient(getBaseUrl());
|
||||||
|
|
||||||
|
if (jwt) {
|
||||||
|
client.setHeader('Authorization', `Bearer ${jwt}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response: any = await client.request(CategoriesQuery);
|
||||||
|
|
||||||
|
if (response?.settingCategories) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch categories:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
63
lib/fetchers.ts
Normal file
63
lib/fetchers.ts
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import { gql, GraphQLClient } from "graphql-request";
|
||||||
|
import type { PageData } from "@/types/page";
|
||||||
|
import { getBaseUrl } from "./gr-client";
|
||||||
|
|
||||||
|
|
||||||
|
const PageQuery = gql/* GraphQL */ `
|
||||||
|
query PageQuery($slug: String!) {
|
||||||
|
pageBySlug(slug: $slug) {
|
||||||
|
id
|
||||||
|
title
|
||||||
|
description
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const BlockQuery = gql/* GraphQL */ `
|
||||||
|
query BlockQuery($pageId: String!) {
|
||||||
|
pageBlocks(pageId: $pageId) {
|
||||||
|
__typename
|
||||||
|
... on TextBlockType { id markdown }
|
||||||
|
... on ChartBlockType { id title series { x y } }
|
||||||
|
... on SettingsBlockType { id category editable }
|
||||||
|
... on HeroBlockType { id title subtitle backgroundImage ctaText ctaLink }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
|
||||||
|
export async function fetchPage(slug: string, jwt?: string): Promise<PageData | null> {
|
||||||
|
const client = new GraphQLClient(getBaseUrl());
|
||||||
|
|
||||||
|
if (jwt) {
|
||||||
|
client.setHeader('Authorization', `Bearer ${jwt}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 获取页面基本信息
|
||||||
|
const pageResponse: any = await client.request(PageQuery, { slug });
|
||||||
|
|
||||||
|
if (!pageResponse?.pageBySlug) {
|
||||||
|
throw new Error('Page not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取页面块数据
|
||||||
|
const blocksResponse: any = await client.request(BlockQuery, {
|
||||||
|
pageId: pageResponse.pageBySlug.id
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!blocksResponse?.pageBlocks) {
|
||||||
|
throw new Error('Failed to fetch page blocks');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 合并数据
|
||||||
|
return {
|
||||||
|
...pageResponse.pageBySlug,
|
||||||
|
blocks: blocksResponse.pageBlocks,
|
||||||
|
};
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch page:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
34
lib/gql.ts
Normal file
34
lib/gql.ts
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import { ApolloClient, InMemoryCache, createHttpLink } from '@apollo/client';
|
||||||
|
import { setContext } from '@apollo/client/link/context';
|
||||||
|
|
||||||
|
// 创建HTTP链接
|
||||||
|
const httpLink = createHttpLink({
|
||||||
|
uri: process.env.NEXT_PUBLIC_GRAPHQL_URL || 'http://localhost:4000/graphql',
|
||||||
|
});
|
||||||
|
|
||||||
|
// 认证链接
|
||||||
|
const authLink = setContext((_, { headers }) => {
|
||||||
|
// 从localStorage或cookie获取token
|
||||||
|
const token = typeof window !== 'undefined' ? localStorage.getItem('token') : null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
headers: {
|
||||||
|
...headers,
|
||||||
|
authorization: token ? `Bearer ${token}` : "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 创建Apollo客户端
|
||||||
|
export const gqlClient = new ApolloClient({
|
||||||
|
link: authLink.concat(httpLink),
|
||||||
|
cache: new InMemoryCache(),
|
||||||
|
ssrMode: typeof window === 'undefined',
|
||||||
|
});
|
||||||
|
|
||||||
|
// 服务端专用的客户端(无认证)
|
||||||
|
export const serverGqlClient = new ApolloClient({
|
||||||
|
link: httpLink,
|
||||||
|
cache: new InMemoryCache(),
|
||||||
|
ssrMode: true,
|
||||||
|
});
|
||||||
13
lib/gr-client.ts
Normal file
13
lib/gr-client.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { GraphQLClient } from "graphql-request";
|
||||||
|
|
||||||
|
// 获取基础URL,兼容服务端和客户端
|
||||||
|
export const getBaseUrl = () => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
// 客户端:使用相对路径
|
||||||
|
return '/api/bff';
|
||||||
|
} else {
|
||||||
|
// 服务端:使用完整URL
|
||||||
|
return `${process.env.NEXTAUTH_URL || 'http://localhost:3000'}/api/bff`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
3043
package-lock.json
generated
3043
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -43,6 +43,7 @@
|
|||||||
"dnd-kit": "^0.0.2",
|
"dnd-kit": "^0.0.2",
|
||||||
"framer-motion": "^12.23.6",
|
"framer-motion": "^12.23.6",
|
||||||
"graphql": "^16.11.0",
|
"graphql": "^16.11.0",
|
||||||
|
"graphql-request": "^7.2.0",
|
||||||
"graphql-ws": "^6.0.6",
|
"graphql-ws": "^6.0.6",
|
||||||
"lucide-react": "^0.525.0",
|
"lucide-react": "^0.525.0",
|
||||||
"maplibre-gl": "^5.6.1",
|
"maplibre-gl": "^5.6.1",
|
||||||
@ -54,7 +55,12 @@
|
|||||||
"react-day-picker": "^9.8.0",
|
"react-day-picker": "^9.8.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
"react-hook-form": "^7.61.1",
|
"react-hook-form": "^7.61.1",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
"recharts": "^2.15.4",
|
"recharts": "^2.15.4",
|
||||||
|
"rehype-katex": "^7.0.1",
|
||||||
|
"rehype-prism-plus": "^2.0.1",
|
||||||
|
"remark-gfm": "^4.0.1",
|
||||||
|
"remark-math": "^6.0.0",
|
||||||
"sonner": "^2.0.6",
|
"sonner": "^2.0.6",
|
||||||
"swr": "^2.3.4",
|
"swr": "^2.3.4",
|
||||||
"tabler": "^1.0.0",
|
"tabler": "^1.0.0",
|
||||||
|
|||||||
14
types/page.ts
Normal file
14
types/page.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
export type TextBlock = { __typename: "TextBlockType"; id: string; markdown: string };
|
||||||
|
export type DataPoint = { x: number; y: number };
|
||||||
|
export type ChartBlock = { __typename: "ChartBlockType"; id: string; title: string; series: DataPoint[] };
|
||||||
|
export type SettingsBlock = { __typename: "SettingsBlockType"; id: string; category: string; editable: boolean };
|
||||||
|
export type HeroBlock = { __typename: "HeroBlockType"; id: string; title: string; subtitle?: string | null; backgroundImage?: string | null; ctaText?: string | null; ctaLink?: string | null };
|
||||||
|
|
||||||
|
export type Block = TextBlock | ChartBlock | SettingsBlock | HeroBlock;
|
||||||
|
|
||||||
|
export type PageData = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description?: string | null;
|
||||||
|
blocks: Block[];
|
||||||
|
};
|
||||||
Loading…
Reference in New Issue
Block a user