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"
|
||||
|
||||
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')
|
||||
|
||||
@ -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 />
|
||||
|
||||
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 { 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>
|
||||
)
|
||||
}
|
||||
|
||||
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 { 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>
|
||||
)
|
||||
|
||||
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 {
|
||||
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>
|
||||
)
|
||||
|
||||
@ -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
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 (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<html lang="en" className="dark" suppressHydrationWarning>
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
|
||||
@ -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('/')
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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(() => {
|
||||
|
||||
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);
|
||||
|
||||
if (texColor.r <= 0.0196 || texColor.r > 0.29411) {
|
||||
discard;
|
||||
}
|
||||
|
||||
float value = texColor.r * 3.4;
|
||||
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 = {
|
||||
theme: 'system',
|
||||
theme: 'dark',
|
||||
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",
|
||||
"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
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