This commit is contained in:
tsuki 2025-08-11 21:26:46 +08:00
parent 8d456dacf7
commit bf02608d93
35 changed files with 4159 additions and 185 deletions

36
app/[slug]/not-found.tsx Normal file
View 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
View 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>
);
}

View 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
View 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>
);
}

View File

@ -8,8 +8,7 @@ import {
import { redirect } from "next/navigation"
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) {
redirect('/login')

View File

@ -1,12 +1,11 @@
"use client"
import {
IconDots,
IconFolder,
IconShare3,
IconTrash,
type Icon,
} from "@tabler/icons-react"
import { cookies } from "next/headers"
import { fetchCategories } from "@/lib/admin-fetchers"
import {
DropdownMenu,
@ -22,30 +21,22 @@ import {
SidebarMenuAction,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from "@/components/ui/sidebar"
export function NavDocuments({
items,
}: {
items: {
name: string
url: string
icon: Icon
}[]
}) {
const { isMobile } = useSidebar()
export async function NavDocuments() {
const jwt = (await cookies()).get('jwt')?.value;
const categoriesData = await fetchCategories(jwt);
return (
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
<SidebarGroupLabel>Documents</SidebarGroupLabel>
<SidebarGroupLabel>Categories</SidebarGroupLabel>
<SidebarMenu>
{items.map((item) => (
<SidebarMenuItem key={item.name}>
{categoriesData?.settingCategories?.map((item) => (
<SidebarMenuItem key={item.page.slug}>
<SidebarMenuButton asChild>
<a href={item.url}>
<item.icon />
<span>{item.name}</span>
<a href={`/admin/${item.page.slug}`}>
<IconFolder />
<span>{item.page.title}</span>
</a>
</SidebarMenuButton>
<DropdownMenu>
@ -60,8 +51,8 @@ export function NavDocuments({
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-24 rounded-lg"
side={isMobile ? "bottom" : "right"}
align={isMobile ? "end" : "start"}
side="right"
align="start"
>
<DropdownMenuItem>
<IconFolder />

View 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>
)
}

View File

@ -1,7 +1,4 @@
"use client"
import { IconCirclePlusFilled, IconMail, type Icon } from "@tabler/icons-react"
import { Button } from "@/components/ui/button"
import {
SidebarGroup,
@ -10,10 +7,8 @@ import {
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar"
import { useEffect, useState } from "react"
import { usePathname } from "next/navigation"
import Link from "next/link"
// import { NavMainClient } from "./nav-main-client"
import { NavMainClient } from "./nav-main-client"
export function NavMain({
items,
@ -21,11 +16,9 @@ export function NavMain({
items: {
title: string
url: string
icon?: Icon
iconName: string
}[]
}) {
const pathname = usePathname()
return (
<SidebarGroup>
<SidebarGroupContent className="flex flex-col gap-2">
@ -48,20 +41,8 @@ export function NavMain({
</Button>
</SidebarMenuItem>
</SidebarMenu>
<SidebarMenu>
{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>
<NavMainClient items={items} />
</SidebarGroupContent>
</SidebarGroup >
</SidebarGroup>
)
}

View 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>
)
}

View File

@ -1,5 +1,3 @@
"use client"
import * as React from "react"
import { type Icon } from "@tabler/icons-react"
@ -10,6 +8,8 @@ import {
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar"
// import { NavSecondaryClient } from "./nav-secondary-client"
import { NavSecondaryClient } from "./nav-secondary-client"
export function NavSecondary({
items,
@ -18,24 +18,13 @@ export function NavSecondary({
items: {
title: string
url: string
icon: Icon
iconName: string
}[]
} & React.ComponentPropsWithoutRef<typeof SidebarGroup>) {
return (
<SidebarGroup {...props}>
<SidebarGroupContent>
<SidebarMenu>
{items.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton asChild>
<a href={item.url}>
<item.icon />
<span>{item.title}</span>
</a>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
<NavSecondaryClient items={items} />
</SidebarGroupContent>
</SidebarGroup>
)

View 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>
)
}

View File

@ -1,5 +1,3 @@
"use client"
import {
IconCreditCard,
IconDotsVertical,
@ -26,8 +24,8 @@ import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from "@/components/ui/sidebar"
import { NavUserClient } from "./nav-user-client"
export function NavUser({
user,
@ -38,72 +36,10 @@ export function NavUser({
avatar: string
}
}) {
const { isMobile } = useSidebar()
return (
<SidebarMenu>
<SidebarMenuItem>
<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>
<NavUserClient user={user} />
</SidebarMenuItem>
</SidebarMenu>
)

View File

@ -1,5 +1,3 @@
"use client"
import * as React from "react"
import {
IconCamera,
@ -43,23 +41,23 @@ const data = {
{
title: "Dashboard",
url: "/admin/dashboard",
icon: IconDashboard,
iconName: "dashboard",
},
{
title: "Analytics",
url: "/admin/analytics",
icon: IconChartBar,
iconName: "chartBar",
},
{
title: "Users",
url: "/admin/users",
icon: IconUsers,
iconName: "users",
}
],
navClouds: [
{
title: "Capture",
icon: IconCamera,
iconName: "camera",
isActive: true,
url: "#",
items: [
@ -75,7 +73,7 @@ const data = {
},
{
title: "Proposal",
icon: IconFileDescription,
iconName: "fileDescription",
url: "#",
items: [
{
@ -90,7 +88,7 @@ const data = {
},
{
title: "Prompts",
icon: IconFileAi,
iconName: "fileAi",
url: "#",
items: [
{
@ -108,37 +106,35 @@ const data = {
{
title: "Settings",
url: "#",
icon: IconSettings,
iconName: "settings",
},
{
title: "Get Help",
url: "#",
icon: IconHelp,
iconName: "help",
},
{
title: "Search",
url: "#",
icon: IconSearch,
iconName: "search",
},
],
documents: [
{
name: "Data Library",
url: "#",
icon: IconDatabase,
iconName: "database",
},
{
name: "Reports",
url: "#",
icon: IconReport,
iconName: "report",
},
],
}
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
const [nav, setNav] = React.useState(data.navMain)
export async function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
return (
<Sidebar collapsible="offcanvas" {...props}>
<SidebarHeader>
@ -158,7 +154,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
</SidebarHeader>
<SidebarContent>
<NavMain items={data.navMain} />
<NavDocuments items={data.documents} />
<NavDocuments />
<NavSecondary items={data.navSecondary} className="mt-auto" />
</SidebarContent>
<SidebarFooter>

60
app/api/bff/route.ts Normal file
View 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
View 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)'
}
}
});
}

View File

View 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)'
}
}
});
}

View File

@ -26,7 +26,7 @@ export default function RootLayout({
}>) {
return (
<html lang="en" suppressHydrationWarning>
<html lang="en" className="dark" suppressHydrationWarning>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>

View File

@ -7,7 +7,7 @@ import { redirect } from "next/navigation"
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) {
redirect('/')

View File

@ -8,7 +8,7 @@ import { redirect } from "next/navigation"
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)

View File

@ -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`
mutation Register($username: String!, $password: String!) {
@ -62,8 +55,6 @@ export function UserProvider({ children }: UserProviderProps) {
isLoading: true
})
// 将 useMutation hooks 移到组件顶层
const [loginMutation, { loading: loginLoading, error: loginError }] = useMutation(LOGIN_MUTATION)
const [registerMutation, { loading: registerLoading, error: registerError }] = useMutation(REGISTER_MUTATION)
// 定期查询用户信息的 hook
@ -105,11 +96,10 @@ export function UserProvider({ children }: UserProviderProps) {
}
}
const initializeAuth = () => {
const initializeAuth = async () => {
try {
const token = localStorage.getItem(TOKEN_KEY)
if (token && isTokenValid(token)) {
const payload = parseJWT(token)
if (payload) {
@ -126,7 +116,16 @@ export function UserProvider({ children }: UserProviderProps) {
isAuthenticated: true,
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
}
}
@ -138,7 +137,6 @@ export function UserProvider({ children }: UserProviderProps) {
isAuthenticated: false,
isLoading: false
})
document.cookie = 'is_logged_in=false; path=/; max-age=3600'
} catch (error) {
console.error('Auth initialization error:', error)
setAuthState({
@ -147,7 +145,6 @@ export function UserProvider({ children }: UserProviderProps) {
isAuthenticated: false,
isLoading: false
})
document.cookie = 'is_logged_in=false; path=/; max-age=3600'
}
}
@ -155,20 +152,24 @@ export function UserProvider({ children }: UserProviderProps) {
try {
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')
}
const { token } = response.data.login
if (!isTokenValid(token)) {
throw new Error('Invalid token received')
}
const payload = parseJWT(token)
if (!payload) {
throw new Error('Failed to parse token')
@ -188,11 +189,6 @@ export function UserProvider({ children }: UserProviderProps) {
isAuthenticated: true,
isLoading: false
})
document.cookie = 'is_logged_in=true; path=/; max-age=3600'
// 登录成功后重置 Apollo 缓存
resetApolloCache()
} catch (error) {
console.error('Login error:', error)
setAuthState({
@ -264,7 +260,6 @@ export function UserProvider({ children }: UserProviderProps) {
isLoading: false
})
document.cookie = 'is_logged_in=false; path=/; max-age=3600'
// 登出后重置 Apollo 缓存
resetApolloCache()
@ -308,7 +303,6 @@ export function UserProvider({ children }: UserProviderProps) {
}
localStorage.setItem(TOKEN_KEY, token)
document.cookie = 'is_logged_in=true; path=/; max-age=3600'
setAuthState({
user,
token,
@ -320,7 +314,6 @@ export function UserProvider({ children }: UserProviderProps) {
resetApolloCache()
} catch (error) {
console.error('Token refresh error:', error)
document.cookie = 'is_logged_in=false; path=/; max-age=3600'
logout()
throw error
}
@ -350,8 +343,8 @@ export function UserProvider({ children }: UserProviderProps) {
// 监听登录和注册的 loading 状态
useEffect(() => {
setAuthState(prev => ({ ...prev, isLoading: loginLoading || registerLoading || userLoading }))
}, [loginLoading, registerLoading, userLoading])
setAuthState(prev => ({ ...prev, isLoading: registerLoading || userLoading }))
}, [registerLoading, userLoading])
// 监听用户数据变化,定期更新用户信息
useEffect(() => {

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@ -142,6 +142,11 @@ export function MapComponent({
}
vec4 texColor = texture(u_tex, uv);
if (texColor.r <= 0.0196 || texColor.r > 0.29411) {
discard;
}
float value = texColor.r * 3.4;
value = clamp(value, 0.0, 1.0);

View 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>
);
}
}

View File

@ -16,7 +16,7 @@ type ThemeProviderState = {
}
const initialState: ThemeProviderState = {
theme: 'system',
theme: 'dark',
setTheme: () => null,
}

49
lib/admin-fetchers.ts Normal file
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

View File

@ -43,6 +43,7 @@
"dnd-kit": "^0.0.2",
"framer-motion": "^12.23.6",
"graphql": "^16.11.0",
"graphql-request": "^7.2.0",
"graphql-ws": "^6.0.6",
"lucide-react": "^0.525.0",
"maplibre-gl": "^5.6.1",
@ -54,7 +55,12 @@
"react-day-picker": "^9.8.0",
"react-dom": "19.1.0",
"react-hook-form": "^7.61.1",
"react-markdown": "^10.1.0",
"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",
"swr": "^2.3.4",
"tabler": "^1.0.0",

14
types/page.ts Normal file
View 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[];
};