Files
2025-11-13 05:05:53 +08:00

325 lines
10 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
export const runtime = 'edge'
import { useState } from "react"
import { Button } from "@/registry/new-york/ui/button"
import { NavigationCard } from "./components/NavigationCard"
import { AddNavigationForm } from "./components/AddNavigationForm"
import { Input } from "@/registry/new-york/ui/input"
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/registry/new-york/ui/dialog"
import { useToast } from "@/registry/new-york/hooks/use-toast"
import { Skeleton } from "@/registry/new-york/ui/skeleton"
import useSWR from 'swr'
import { NavigationItem } from "@/types/navigation"
import { DragDropContext, Droppable } from '@hello-pangea/dnd'
import { Plus, AlertTriangle, Inbox } from 'lucide-react'
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/registry/new-york/ui/select"
async function fetcher(url: string): Promise<NavigationItem[]> {
const res = await fetch(url)
if (!res.ok) throw new Error('Failed to fetch navigation items')
const data = await res.json()
return data.navigationItems || []
}
export default function NavigationPage() {
const [isDialogOpen, setIsDialogOpen] = useState(false)
const [searchQuery, setSearchQuery] = useState("")
const [showEnabled, setShowEnabled] = useState<boolean | null>(null)
const { toast } = useToast()
const { data: items = [], error, isLoading, mutate } = useSWR<NavigationItem[]>(
'/api/navigation',
fetcher,
{
fallbackData: [],
revalidateOnFocus: false,
}
)
const handleAdd = async (values: {
title: string;
icon: string;
description?: string;
enabled?: boolean
}) => {
try {
// 生成唯一ID
const newItem: NavigationItem = {
id: Date.now().toString(),
title: values.title,
icon: values.icon,
description: values.description || '',
enabled: values.enabled ?? true,
items: [],
subCategories: []
}
const response = await fetch('/api/navigation', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
navigationItems: [
...items, // 保留现有的导航项
newItem // 添加新的导航项
]
})
})
if (!response.ok) {
const errorText = await response.text()
console.error('添加失败:', errorText)
throw new Error('Failed to add')
}
setIsDialogOpen(false)
mutate()
toast({
title: "成功",
description: "添加成功"
})
} catch (error) {
console.error('添加导航项错误:', error)
toast({
title: "错误",
description: "添加失败:" + (error as Error).message,
variant: "destructive"
})
}
}
const handleMoveToTop = async (id: string) => {
try {
const response = await fetch(`/api/navigation/${id}/move-to-top`, {
method: 'POST'
})
if (!response.ok) throw new Error('Failed to move')
mutate()
toast({
title: "成功",
description: "移动成功"
})
} catch (error) {
toast({
title: "错误",
description: "移动失败",
variant: "destructive"
})
}
}
const handleMoveToBottom = async (id: string) => {
try {
const response = await fetch(`/api/navigation/${id}/move-to-bottom`, {
method: 'POST'
})
if (!response.ok) throw new Error('Failed to move')
mutate()
toast({
title: "成功",
description: "移动成功"
})
} catch (error) {
toast({
title: "错误",
description: "移动失败",
variant: "destructive"
})
}
}
const handleDragEnd = async (result: any) => {
if (!result.destination) return
const sourceIndex = result.source.index
const destinationIndex = result.destination.index
if (sourceIndex === destinationIndex) return
// 创建一个新的数组副本
const currentItems = Array.isArray(items) ? [...items] : []
// 记录原始顺序,用于可能的回滚
const originalItems = [...currentItems]
// 移动元素
const [movedItem] = currentItems.splice(sourceIndex, 1)
currentItems.splice(destinationIndex, 0, movedItem)
// 乐观更新:立即更新本地状态
await mutate(currentItems, {
revalidate: false // 阻止重新获取数据
})
try {
const response = await fetch('/api/navigation/reorder', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sourceIndex,
destinationIndex,
itemId: result.draggableId
})
})
if (!response.ok) {
// 如果服务器请求失败,回滚到原始状态
await mutate(originalItems, { revalidate: false })
throw new Error('排序失败')
}
// 成功后重新获取最新数据以确保一致性
await mutate()
toast({
title: "成功",
description: "排序已更新"
})
} catch (error) {
// 回滚到原始状态
await mutate(originalItems, { revalidate: false })
toast({
title: "错误",
description: "排序失败,已恢复原状",
variant: "destructive"
})
}
}
const filteredItems = items
.filter(item =>
item.title.toLowerCase().includes(searchQuery.toLowerCase()) &&
(showEnabled === null || item.enabled === showEnabled)
)
return (
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
<div className="flex items-center justify-between space-x-4">
<div className="flex items-center gap-4 flex-1">
<Input
placeholder="搜索分类..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="max-w-[300px]"
/>
<Select
value={showEnabled === null ? "all" : String(showEnabled)}
onValueChange={(value) => {
if (value === "all") {
setShowEnabled(null)
} else {
setShowEnabled(value === "true")
}
}}
>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="状态筛选" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="true"></SelectItem>
<SelectItem value="false"></SelectItem>
</SelectContent>
</Select>
</div>
<Button onClick={() => setIsDialogOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
</Button>
</div>
<div className="space-y-4">
{error ? (
<div className="flex h-[450px] shrink-0 items-center justify-center rounded-md border border-dashed">
<div className="mx-auto flex max-w-[420px] flex-col items-center justify-center text-center">
<AlertTriangle className="h-10 w-10 text-muted-foreground" />
<h3 className="mt-4 text-lg font-semibold"></h3>
<p className="mb-4 mt-2 text-sm text-muted-foreground">
</p>
<Button
variant="outline"
onClick={() => mutate()}
>
</Button>
</div>
</div>
) : isLoading ? (
Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="p-4 rounded-lg border">
<div className="flex items-center space-x-4">
<Skeleton className="h-6 w-6 rounded" />
<div className="space-y-2">
<Skeleton className="h-4 w-[200px]" />
</div>
</div>
</div>
))
) : filteredItems.length === 0 ? (
<div className="flex h-[450px] shrink-0 items-center justify-center rounded-md border border-dashed">
<div className="mx-auto flex max-w-[420px] flex-col items-center justify-center text-center">
<Inbox className="h-10 w-10 text-muted-foreground" />
<h3 className="mt-4 text-lg font-semibold"></h3>
<p className="mb-4 mt-2 text-sm text-muted-foreground">
{searchQuery ? "没有找到匹配的分类。" : "还没有添加任何分类点击上方的添加按钮开<E992AE><E5BC80><EFBFBD>创建。"}
</p>
{searchQuery && (
<Button
variant="outline"
onClick={() => setSearchQuery("")}
>
</Button>
)}
</div>
</div>
) : (
<DragDropContext onDragEnd={handleDragEnd}>
<Droppable droppableId="navigation-list">
{(provided) => (
<div
{...provided.droppableProps}
ref={provided.innerRef}
className="space-y-4"
>
{filteredItems.map((item, index) => (
<NavigationCard
key={item.id}
item={item}
index={index}
onUpdate={mutate}
showMoveToTop={index > 0}
showMoveToBottom={index < filteredItems.length - 1}
onMoveToTop={() => handleMoveToTop(item.id)}
onMoveToBottom={() => handleMoveToBottom(item.id)}
/>
))}
{provided.placeholder}
</div>
)}
</Droppable>
</DragDropContext>
)}
</div>
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<AddNavigationForm
onSubmit={handleAdd}
onCancel={() => setIsDialogOpen(false)}
/>
</DialogContent>
</Dialog>
</div>
)
}