's'
This commit is contained in:
605
app/admin/navigation/[id]/categories/[categoryId]/items/page.tsx
Normal file
605
app/admin/navigation/[id]/categories/[categoryId]/items/page.tsx
Normal file
@@ -0,0 +1,605 @@
|
||||
'use client'
|
||||
|
||||
export const runtime = 'edge'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { Button } from "@/registry/new-york/ui/button"
|
||||
import { useToast } from "@/registry/new-york/hooks/use-toast"
|
||||
import { Icons } from "@/components/icons"
|
||||
import { NavigationItem, NavigationCategory } from '@/types/navigation'
|
||||
import { AddItemForm } from "../../../../components/AddItemForm"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/registry/new-york/ui/dialog"
|
||||
import { Input } from "@/registry/new-york/ui/input"
|
||||
import { DragDropContext, Droppable, Draggable, DropResult } from '@hello-pangea/dnd'
|
||||
import { Skeleton } from "@/registry/new-york/ui/skeleton"
|
||||
import { Badge } from "@/registry/new-york/ui/badge"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/registry/new-york/ui/select"
|
||||
|
||||
interface EditingItem {
|
||||
index: number
|
||||
item: NavigationSubItem
|
||||
}
|
||||
|
||||
interface NavigationSubItem {
|
||||
id: string
|
||||
title: string
|
||||
href: string
|
||||
icon?: string
|
||||
description?: string
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export default function CategoryItemsPage() {
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const { toast } = useToast()
|
||||
const [navigation, setNavigation] = useState<NavigationItem | null>(null)
|
||||
const [category, setCategory] = useState<NavigationCategory | null>(null)
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const [editingItem, setEditingItem] = useState<EditingItem | null>(null)
|
||||
const [deletingItem, setDeletingItem] = useState<EditingItem | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [filter, setFilter] = useState<'all' | 'enabled' | 'disabled'>('all')
|
||||
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!params?.id || !params?.categoryId) {
|
||||
router.push('/admin/navigation')
|
||||
return
|
||||
}
|
||||
fetchData()
|
||||
}, [params?.id, params?.categoryId])
|
||||
|
||||
const fetchData = async () => {
|
||||
if (!params?.id || !params?.categoryId) return
|
||||
|
||||
try {
|
||||
setLoading(true)
|
||||
const response = await fetch(`/api/navigation/${params.id}`)
|
||||
if (!response.ok) throw new Error('Failed to fetch')
|
||||
const data = await response.json()
|
||||
setNavigation(data)
|
||||
|
||||
const foundCategory = data.subCategories?.find(
|
||||
(cat: NavigationCategory) => cat.id === params.categoryId
|
||||
)
|
||||
if (!foundCategory) throw new Error('Category not found')
|
||||
setCategory(foundCategory)
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "错误",
|
||||
description: "加载数据失败",
|
||||
variant: "destructive"
|
||||
})
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const addItem = async (values: NavigationSubItem) => {
|
||||
if (!params?.id || !navigation || !category) return
|
||||
|
||||
try {
|
||||
const updatedCategory = {
|
||||
...category,
|
||||
items: [...(category.items || []), values]
|
||||
}
|
||||
|
||||
const updatedNavigation = {
|
||||
...(navigation as NavigationItem),
|
||||
subCategories: (navigation as NavigationItem).subCategories?.map(cat =>
|
||||
cat.id === category.id ? updatedCategory : cat
|
||||
)
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/navigation/${params.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(updatedNavigation)
|
||||
})
|
||||
|
||||
if (!response.ok) throw new Error('Failed to save')
|
||||
|
||||
const data = await response.json()
|
||||
setNavigation(data)
|
||||
setCategory(updatedCategory)
|
||||
|
||||
toast({
|
||||
title: "成功",
|
||||
description: "添加成功"
|
||||
})
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "错误",
|
||||
description: "保存失败",
|
||||
variant: "destructive"
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const updateItem = async (index: number, values: NavigationSubItem) => {
|
||||
if (!params?.id || !navigation || !category) return
|
||||
|
||||
try {
|
||||
const updatedItems = [...(category.items || [])]
|
||||
updatedItems[index] = values
|
||||
|
||||
const updatedCategory = {
|
||||
...category,
|
||||
items: updatedItems
|
||||
}
|
||||
|
||||
if (!navigation) return;
|
||||
|
||||
const updatedNavigation = {
|
||||
...navigation,
|
||||
subCategories: navigation.subCategories?.map(cat =>
|
||||
cat.id === category.id ? updatedCategory : cat
|
||||
),
|
||||
};
|
||||
|
||||
const response = await fetch(`/api/navigation/${params.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(updatedNavigation)
|
||||
})
|
||||
|
||||
if (!response.ok) throw new Error('Failed to update')
|
||||
|
||||
const data = await response.json()
|
||||
setNavigation(data)
|
||||
setCategory(updatedCategory)
|
||||
setEditingItem(null)
|
||||
|
||||
toast({
|
||||
title: "成功",
|
||||
description: "更新成功"
|
||||
})
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "错误",
|
||||
description: "更新失败",
|
||||
variant: "destructive"
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const deleteItem = async (index: number) => {
|
||||
if (!params?.id || !navigation || !category) return
|
||||
|
||||
try {
|
||||
const updatedItems = [...(category.items || [])]
|
||||
updatedItems.splice(index, 1)
|
||||
|
||||
const updatedCategory = {
|
||||
...category,
|
||||
items: updatedItems
|
||||
}
|
||||
|
||||
const updatedNavigation = {
|
||||
...navigation,
|
||||
subCategories: navigation.subCategories?.map(cat =>
|
||||
cat.id === category.id ? updatedCategory : cat
|
||||
)
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/navigation/${params.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(updatedNavigation)
|
||||
})
|
||||
|
||||
if (!response.ok) throw new Error('Failed to delete')
|
||||
|
||||
const data = await response.json()
|
||||
setNavigation(data)
|
||||
setCategory(updatedCategory)
|
||||
setDeletingItem(null)
|
||||
|
||||
toast({
|
||||
title: "成功",
|
||||
description: "删除成功"
|
||||
})
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "错误",
|
||||
description: "删除失败",
|
||||
variant: "destructive"
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleDragEnd = (result: DropResult) => {
|
||||
if (!result.destination) return;
|
||||
|
||||
const newItems = Array.from(category?.items || []);
|
||||
const [movedItem] = newItems.splice(result.source.index, 1);
|
||||
newItems.splice(result.destination.index, 0, movedItem);
|
||||
|
||||
// Optimistically update the UI
|
||||
setCategory((prevCategory) => prevCategory ? { ...prevCategory, items: newItems } : null);
|
||||
|
||||
// Update the server
|
||||
updateItemsOrder(newItems);
|
||||
};
|
||||
|
||||
const updateItemsOrder = async (newItems: NavigationSubItem[]) => {
|
||||
if (!params?.id || !category) return;
|
||||
|
||||
try {
|
||||
const updatedCategory = {
|
||||
...category,
|
||||
items: newItems,
|
||||
};
|
||||
|
||||
if (!navigation) return;
|
||||
|
||||
const updatedNavigation = {
|
||||
...navigation,
|
||||
subCategories: navigation.subCategories?.map(cat =>
|
||||
cat.id === category.id ? updatedCategory : cat
|
||||
),
|
||||
};
|
||||
|
||||
const response = await fetch(`/api/navigation/${params.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(updatedNavigation),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to update order');
|
||||
|
||||
const data = await response.json();
|
||||
setNavigation(data);
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "错误",
|
||||
description: "更新顺序失败",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const moveItem = async (fromIndex: number, toIndex: number) => {
|
||||
if (!params?.id || !navigation || !category?.items) return
|
||||
|
||||
try {
|
||||
const newItems = [...category.items]
|
||||
const [movedItem] = newItems.splice(fromIndex, 1)
|
||||
newItems.splice(toIndex, 0, movedItem)
|
||||
|
||||
const updatedCategory = {
|
||||
...category,
|
||||
items: newItems
|
||||
}
|
||||
|
||||
const updatedNavigation = {
|
||||
...(navigation as NavigationItem),
|
||||
subCategories: (navigation as NavigationItem).subCategories?.map(cat =>
|
||||
cat.id === category.id ? updatedCategory : cat
|
||||
)
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/navigation/${params.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(updatedNavigation)
|
||||
})
|
||||
|
||||
if (!response.ok) throw new Error('Failed to move item')
|
||||
|
||||
const data = await response.json()
|
||||
setNavigation(data)
|
||||
setCategory(updatedCategory)
|
||||
|
||||
toast({
|
||||
title: "成功",
|
||||
description: "移动成功"
|
||||
})
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "错误",
|
||||
description: "移动失败",
|
||||
variant: "destructive"
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const moveToTop = async (index: number) => {
|
||||
if (!category?.items || index <= 0) return
|
||||
moveItem(index, 0)
|
||||
}
|
||||
|
||||
const moveToBottom = async (index: number) => {
|
||||
if (!category?.items || index >= category.items.length - 1) return
|
||||
moveItem(index, category.items.length - 1)
|
||||
}
|
||||
|
||||
const filteredItems = category?.items?.filter(item => {
|
||||
const matchesSearch = item.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
item.href.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
item.description?.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
const matchesFilter = filter === 'all' ? true : filter === 'enabled' ? item.enabled : !item.enabled
|
||||
return matchesSearch && matchesFilter
|
||||
}) || []
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{loading ? (
|
||||
<>
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<Skeleton className="h-8 w-8" />
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-48" />
|
||||
<Skeleton className="h-6 w-64" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
{[...Array(5)].map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center justify-between py-2 px-4 bg-card rounded-lg border shadow-sm"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="h-8 w-8 rounded-lg" />
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-48" />
|
||||
<Skeleton className="h-3 w-32" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Skeleton className="h-8 w-8" />
|
||||
<Skeleton className="h-8 w-8" />
|
||||
<Skeleton className="h-8 w-8" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => router.back()}
|
||||
className="h-8 w-8"
|
||||
title="返回"
|
||||
>
|
||||
<Icons.arrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground mb-1">
|
||||
{navigation?.title}
|
||||
{category?.parentId && navigation?.subCategories?.find(cat => cat.id === category.parentId)?.title && (
|
||||
<>
|
||||
{' > '}{navigation.subCategories.find(cat => cat.id === category.parentId)?.title}
|
||||
</>
|
||||
)}
|
||||
{' > '}{category?.title}
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative flex-1 max-w-sm">
|
||||
<Icons.search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="搜索站点..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-8"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setSearchQuery("")}
|
||||
className="absolute right-1 top-1 h-7 w-7 p-0"
|
||||
>
|
||||
<Icons.x className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<Select
|
||||
value={filter}
|
||||
onValueChange={(value: 'all' | 'enabled' | 'disabled') => setFilter(value)}
|
||||
>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue placeholder="按状态筛选" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部</SelectItem>
|
||||
<SelectItem value="enabled">已启用</SelectItem>
|
||||
<SelectItem value="disabled">已禁用</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Dialog open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<Icons.plus className="mr-2 h-4 w-4" />
|
||||
添加站点
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>添加站点</DialogTitle>
|
||||
</DialogHeader>
|
||||
<AddItemForm
|
||||
onSubmit={async (values) => {
|
||||
await addItem(values);
|
||||
setIsAddDialogOpen(false);
|
||||
}}
|
||||
onCancel={() => setIsAddDialogOpen(false)}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
<DragDropContext onDragEnd={handleDragEnd}>
|
||||
<Droppable droppableId="droppable">
|
||||
{(provided) => (
|
||||
<div {...provided.droppableProps} ref={provided.innerRef} className="grid gap-2">
|
||||
{filteredItems.map((item, index) => (
|
||||
<Draggable key={item.id} draggableId={item.id} index={index}>
|
||||
{(provided) => (
|
||||
<div
|
||||
{...provided.draggableProps}
|
||||
{...provided.dragHandleProps}
|
||||
ref={provided.innerRef}
|
||||
className="group relative"
|
||||
>
|
||||
<div className="flex items-center justify-between py-2 px-4 bg-card rounded-lg border shadow-sm transition-colors hover:bg-accent/10">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-primary/10">
|
||||
{item.icon ? (
|
||||
<img src={item.icon} alt={item.title} className="w-4 h-4 object-contain" />
|
||||
) : (
|
||||
<Icons.link className="h-4 w-4 text-primary" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium leading-none">{item.title}</span>
|
||||
{!item.enabled && (
|
||||
<Badge variant="secondary" className="text-xs">已禁用</Badge>
|
||||
)}
|
||||
</div>
|
||||
{item.description && (
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
{item.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => window.open(item.href, '_blank')}
|
||||
title="访问链接"
|
||||
>
|
||||
<Icons.globe className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => setEditingItem({ index, item })}
|
||||
title="编辑"
|
||||
>
|
||||
<Icons.pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => setDeletingItem({ index, item })}
|
||||
title="删除"
|
||||
>
|
||||
<Icons.trash className="h-4 w-4" />
|
||||
</Button>
|
||||
{index > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => moveToTop(index)}
|
||||
title="置顶"
|
||||
>
|
||||
<Icons.chevronsUp className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
{index < (category?.items?.length || 0) - 1 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => moveToBottom(index)}
|
||||
title="置底"
|
||||
>
|
||||
<Icons.chevronsDown className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Draggable>
|
||||
))}
|
||||
{filteredItems.length === 0 && (
|
||||
<div className="text-center py-10 text-muted-foreground">
|
||||
{category?.items?.length === 0 ? (
|
||||
<p>暂无站点</p>
|
||||
) : (
|
||||
<p>未找到匹配的站点</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{provided.placeholder}
|
||||
</div>
|
||||
)}
|
||||
</Droppable>
|
||||
</DragDropContext>
|
||||
|
||||
<Dialog open={!!editingItem} onOpenChange={(open) => !open && setEditingItem(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>编辑站点</DialogTitle>
|
||||
</DialogHeader>
|
||||
<AddItemForm
|
||||
defaultValues={editingItem?.item}
|
||||
onSubmit={(values) => {
|
||||
if (editingItem) {
|
||||
return updateItem(editingItem.index, values)
|
||||
}
|
||||
return Promise.resolve()
|
||||
}}
|
||||
onCancel={() => setEditingItem(null)}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={!!deletingItem} onOpenChange={(open) => !open && setDeletingItem(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>删除确认</DialogTitle>
|
||||
<DialogDescription>
|
||||
确定要删除站点 “{deletingItem?.item.title}” 吗?此操作无法撤销。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setDeletingItem(null)}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
if (deletingItem) {
|
||||
deleteItem(deletingItem.index)
|
||||
}
|
||||
}}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
553
app/admin/navigation/[id]/categories/page.tsx
Normal file
553
app/admin/navigation/[id]/categories/page.tsx
Normal file
@@ -0,0 +1,553 @@
|
||||
'use client'
|
||||
|
||||
export const runtime = 'edge'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { Button } from "@/registry/new-york/ui/button"
|
||||
import { useToast } from "@/registry/new-york/hooks/use-toast"
|
||||
import {
|
||||
Plus,
|
||||
Folder,
|
||||
Search,
|
||||
X,
|
||||
ArrowLeft,
|
||||
List,
|
||||
Pencil,
|
||||
Trash,
|
||||
ChevronsUp,
|
||||
ChevronsDown
|
||||
} from 'lucide-react'
|
||||
import { NavigationItem, NavigationCategory } from '@/types/navigation'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
DialogFooter,
|
||||
DialogDescription,
|
||||
} from "@/registry/new-york/ui/dialog"
|
||||
import { AddCategoryForm } from '../../components/AddCategoryForm'
|
||||
import { Input } from "@/registry/new-york/ui/input"
|
||||
import { DragDropContext, Droppable, Draggable, DropResult } from '@hello-pangea/dnd'
|
||||
|
||||
import { Badge } from "@/registry/new-york/ui/badge"
|
||||
import { Skeleton } from "@/registry/new-york/ui/skeleton"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/registry/new-york/ui/select"
|
||||
|
||||
export default function CategoriesPage() {
|
||||
const params = useParams<{ id: string }>()
|
||||
const router = useRouter()
|
||||
const { toast } = useToast()
|
||||
const [navigation, setNavigation] = useState<NavigationItem | null>(null)
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const [editingCategory, setEditingCategory] = useState<{ index: number; category: NavigationCategory } | null>(null)
|
||||
const [deletingCategory, setDeletingCategory] = useState<{ index: number; category: NavigationCategory } | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false)
|
||||
const [statusFilter, setStatusFilter] = useState<'all' | 'enabled' | 'disabled'>('all')
|
||||
|
||||
useEffect(() => {
|
||||
if (!params?.id) {
|
||||
router.push('/admin/navigation')
|
||||
return
|
||||
}
|
||||
fetchNavigation()
|
||||
}, [params?.id])
|
||||
|
||||
const fetchNavigation = async () => {
|
||||
if (!params?.id) {
|
||||
throw new Error('Navigation ID not found')
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoading(true)
|
||||
const response = await fetch(`/api/navigation/${params.id}`)
|
||||
if (!response.ok) throw new Error('Failed to fetch')
|
||||
const data = await response.json()
|
||||
setNavigation(data)
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "错误",
|
||||
description: "加载数据失败",
|
||||
variant: "destructive"
|
||||
})
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const addCategory = async (values: {
|
||||
title: string,
|
||||
icon: string,
|
||||
description?: string,
|
||||
enabled: boolean
|
||||
}) => {
|
||||
if (!params?.id || !navigation) return
|
||||
|
||||
try {
|
||||
const newCategory: NavigationCategory = {
|
||||
id: crypto.randomUUID(),
|
||||
title: values.title,
|
||||
icon: values.icon,
|
||||
description: values.description,
|
||||
enabled: values.enabled,
|
||||
items: []
|
||||
}
|
||||
|
||||
const updatedNavigation: NavigationItem = {
|
||||
...navigation,
|
||||
subCategories: [...(navigation.subCategories || []), newCategory]
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/navigation/${params.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(updatedNavigation)
|
||||
})
|
||||
|
||||
if (!response.ok) throw new Error('Failed to save')
|
||||
|
||||
await fetchNavigation()
|
||||
toast({
|
||||
title: "成功",
|
||||
description: "添加成功"
|
||||
})
|
||||
setIsAddDialogOpen(false)
|
||||
} catch (error) {
|
||||
console.error('Save error:', error)
|
||||
toast({
|
||||
title: "错误",
|
||||
description: error instanceof Error ? error.message : "保存失败",
|
||||
variant: "destructive"
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const editCategory = async (values: {
|
||||
title: string,
|
||||
icon: string,
|
||||
description?: string,
|
||||
enabled: boolean
|
||||
}) => {
|
||||
if (!params?.id || !navigation || !editingCategory) return
|
||||
|
||||
try {
|
||||
const updatedCategories = navigation.subCategories?.map((cat, index) =>
|
||||
index === editingCategory.index
|
||||
? {
|
||||
...cat,
|
||||
title: values.title,
|
||||
icon: values.icon,
|
||||
description: values.description,
|
||||
enabled: values.enabled
|
||||
}
|
||||
: cat
|
||||
) || []
|
||||
|
||||
const updatedNavigation: NavigationItem = {
|
||||
...navigation,
|
||||
subCategories: updatedCategories
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/navigation/${params.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(updatedNavigation)
|
||||
})
|
||||
|
||||
if (!response.ok) throw new Error('Failed to save')
|
||||
|
||||
const updatedData = await response.json()
|
||||
setNavigation(updatedData)
|
||||
setEditingCategory(null)
|
||||
|
||||
toast({
|
||||
title: "成功",
|
||||
description: "更新成功"
|
||||
})
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "错误",
|
||||
description: "保存失败",
|
||||
variant: "destructive"
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const deleteCategory = async (categoryId: string) => {
|
||||
if (!params?.id) {
|
||||
throw new Error('Navigation ID not found')
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/navigation/${params.id}/categories`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ categoryId })
|
||||
})
|
||||
|
||||
if (!response.ok) throw new Error('Failed to delete')
|
||||
|
||||
await fetchNavigation()
|
||||
toast({
|
||||
title: "成功",
|
||||
description: "删除成功"
|
||||
})
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "错误",
|
||||
description: "删除失败",
|
||||
variant: "destructive"
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const moveCategory = async (fromIndex: number, toIndex: number) => {
|
||||
if (!params?.id) {
|
||||
throw new Error('Navigation ID not found')
|
||||
}
|
||||
|
||||
if (!navigation?.subCategories) return
|
||||
|
||||
const newCategories = [...navigation.subCategories]
|
||||
const [removed] = newCategories.splice(fromIndex, 1)
|
||||
newCategories.splice(toIndex, 0, removed)
|
||||
|
||||
const updatedNavigation = {
|
||||
...navigation,
|
||||
subCategories: newCategories
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/navigation/${params.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(updatedNavigation)
|
||||
})
|
||||
|
||||
if (!response.ok) throw new Error('Failed to save order')
|
||||
|
||||
const updatedData = await response.json()
|
||||
setNavigation(updatedData)
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "错误",
|
||||
description: "保存顺序失败",
|
||||
variant: "destructive"
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleDragEnd = (result: DropResult) => {
|
||||
const { destination, source } = result
|
||||
|
||||
if (!destination || destination.index === source.index || !navigation?.subCategories) return
|
||||
|
||||
moveCategory(source.index, destination.index)
|
||||
}
|
||||
|
||||
const moveToTop = async (id: string) => {
|
||||
if (!params?.id) {
|
||||
throw new Error('Navigation ID not found')
|
||||
}
|
||||
|
||||
if (!navigation?.subCategories) return
|
||||
const index = navigation.subCategories.findIndex(cat => cat.id === id)
|
||||
if (index > 0) {
|
||||
moveCategory(index, 0)
|
||||
}
|
||||
}
|
||||
|
||||
const moveToBottom = async (id: string) => {
|
||||
if (!params?.id) {
|
||||
throw new Error('Navigation ID not found')
|
||||
}
|
||||
|
||||
if (!navigation?.subCategories) return
|
||||
const index = navigation.subCategories.findIndex(cat => cat.id === id)
|
||||
if (index < (navigation.subCategories.length - 1)) {
|
||||
moveCategory(index, navigation.subCategories.length - 1)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
const filteredCategories = navigation?.subCategories?.filter(category => {
|
||||
const matchesSearch = category.title.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
const matchesStatus = statusFilter === 'all'
|
||||
? true
|
||||
: statusFilter === 'enabled'
|
||||
? category.enabled
|
||||
: !category.enabled
|
||||
return matchesSearch && matchesStatus
|
||||
}) || []
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => router.back()}
|
||||
className="h-8 w-8"
|
||||
title="返回"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
{isLoading ? (
|
||||
<Skeleton className="h-7 w-32" />
|
||||
) : (
|
||||
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||
{navigation?.title || '未命名导航'}
|
||||
</h2>
|
||||
)}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative flex-1 max-w-sm">
|
||||
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="搜索分类..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-8"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
{searchQuery && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setSearchQuery("")}
|
||||
className="absolute right-1 top-1 h-7 w-7 p-0"
|
||||
disabled={isLoading}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<Select
|
||||
value={statusFilter}
|
||||
onValueChange={(value: 'all' | 'enabled' | 'disabled') => setStatusFilter(value)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue placeholder="按状态筛选" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部</SelectItem>
|
||||
<SelectItem value="enabled">已启用</SelectItem>
|
||||
<SelectItem value="disabled">已禁用</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<Dialog open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button disabled={isLoading}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
添加分类
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>添加分类</DialogTitle>
|
||||
</DialogHeader>
|
||||
<AddCategoryForm
|
||||
onSubmit={addCategory}
|
||||
onCancel={() => setIsAddDialogOpen(false)}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="grid gap-2">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="flex items-center gap-4 p-4 rounded-lg border">
|
||||
<Skeleton className="h-8 w-8 rounded-lg" />
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-48" />
|
||||
<Skeleton className="h-3 w-32" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<DragDropContext onDragEnd={handleDragEnd}>
|
||||
<Droppable droppableId="droppable">
|
||||
{(provided) => (
|
||||
<div {...provided.droppableProps} ref={provided.innerRef} className="grid gap-2">
|
||||
{filteredCategories.map((category, index) => (
|
||||
<Draggable key={category.id} draggableId={category.id} index={index}>
|
||||
{(provided) => (
|
||||
<div
|
||||
{...provided.draggableProps}
|
||||
{...provided.dragHandleProps}
|
||||
ref={provided.innerRef}
|
||||
className="group relative"
|
||||
>
|
||||
<div
|
||||
className="flex items-center justify-between py-2 px-4 bg-card rounded-lg border shadow-sm transition-colors hover:bg-accent/10"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-primary/10">
|
||||
<Folder className="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium leading-none">{category.title}</span>
|
||||
<Badge
|
||||
variant={(category.enabled ?? true) ? "default" : "secondary"}
|
||||
className={
|
||||
(category.enabled ?? true)
|
||||
? "text-xs bg-green-100 text-green-800 hover:bg-green-100"
|
||||
: "text-xs bg-gray-100 text-gray-600 hover:bg-gray-100"
|
||||
}
|
||||
>
|
||||
{(category.enabled ?? true) ? "已启用" : "已禁用"}
|
||||
</Badge>
|
||||
</div>
|
||||
{category.description && (
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
{category.description}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
{category.items?.length || 0} 个项目
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{/* 置顶置底按钮 - 在hover时显示 */}
|
||||
<div className="hidden group-hover:flex items-center gap-1 mr-2">
|
||||
{index > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={() => moveToTop(category.id)}
|
||||
title="置顶"
|
||||
>
|
||||
<ChevronsUp className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
{index < (navigation?.subCategories?.length || 0) - 1 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={() => moveToBottom(category.id)}
|
||||
title="置底"
|
||||
>
|
||||
<ChevronsDown className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{/* 常规操作按钮 */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
if (params?.id) {
|
||||
router.push(`/admin/navigation/${params.id}/categories/${category.id}/items`)
|
||||
}
|
||||
}}
|
||||
title="管理子项目"
|
||||
>
|
||||
<List className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setEditingCategory({ index, category })}
|
||||
title="编辑"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setDeletingCategory({ index, category })}
|
||||
title="删除"
|
||||
>
|
||||
<Trash className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Draggable>
|
||||
))}
|
||||
{filteredCategories.length === 0 && (
|
||||
<div className="text-center py-10 text-muted-foreground">
|
||||
{navigation?.subCategories?.length === 0 ? (
|
||||
<p>暂无分类</p>
|
||||
) : (
|
||||
<p>未找到匹配的分类</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{provided.placeholder}
|
||||
</div>
|
||||
)}
|
||||
</Droppable>
|
||||
</DragDropContext>
|
||||
)}
|
||||
|
||||
<Dialog open={!!editingCategory} onOpenChange={(open) => !open && setEditingCategory(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>编辑分类</DialogTitle>
|
||||
</DialogHeader>
|
||||
<AddCategoryForm
|
||||
defaultValues={{
|
||||
title: editingCategory?.category.title || '',
|
||||
icon: editingCategory?.category.icon || '',
|
||||
description: editingCategory?.category.description || '',
|
||||
enabled: editingCategory?.category.enabled ?? true
|
||||
}}
|
||||
onSubmit={editCategory}
|
||||
onCancel={() => setEditingCategory(null)}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={!!deletingCategory} onOpenChange={(open) => !open && setDeletingCategory(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>删除确认</DialogTitle>
|
||||
<DialogDescription>
|
||||
确定要删除分类 “{deletingCategory?.category.title}” 吗?此操作无法撤销,分类下的所有项目也将被删除。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setDeletingCategory(null)}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
if (deletingCategory) {
|
||||
deleteCategory(deletingCategory.category.id)
|
||||
setDeletingCategory(null)
|
||||
}
|
||||
}}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
576
app/admin/navigation/[id]/items/page.tsx
Normal file
576
app/admin/navigation/[id]/items/page.tsx
Normal file
@@ -0,0 +1,576 @@
|
||||
'use client'
|
||||
|
||||
export const runtime = 'edge'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { Button } from "@/registry/new-york/ui/button"
|
||||
import { useToast } from "@/registry/new-york/hooks/use-toast"
|
||||
import { Icons } from "@/components/icons"
|
||||
import { NavigationItem, NavigationSubItem } from '@/types/navigation'
|
||||
import { Input } from "@/registry/new-york/ui/input"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/registry/new-york/ui/dialog"
|
||||
import { AddItemForm } from '../../components/AddItemForm'
|
||||
import { DragDropContext, Droppable, Draggable, DropResult } from '@hello-pangea/dnd';
|
||||
import { Skeleton } from "@/registry/new-york/ui/skeleton"
|
||||
|
||||
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/registry/new-york/ui/select"
|
||||
import { Badge } from "@/registry/new-york/ui/badge"
|
||||
|
||||
interface EditingItem {
|
||||
index: number
|
||||
item: NavigationSubItem
|
||||
}
|
||||
|
||||
function LoadingSkeleton() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-[200px]" />
|
||||
<Skeleton className="h-4 w-[300px]" />
|
||||
</div>
|
||||
<Skeleton className="h-10 w-[100px]" />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center justify-between p-4 rounded-lg border"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="h-8 w-8 rounded-lg" />
|
||||
<div>
|
||||
<Skeleton className="h-4 w-[200px] mb-2" />
|
||||
<Skeleton className="h-3 w-[150px]" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Skeleton className="h-8 w-8 rounded-md" />
|
||||
<Skeleton className="h-8 w-8 rounded-md" />
|
||||
<Skeleton className="h-8 w-8 rounded-md" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ItemsPage() {
|
||||
const params = useParams<{ id: string }>()
|
||||
const router = useRouter()
|
||||
const { toast } = useToast()
|
||||
const [navigation, setNavigation] = useState<NavigationItem | null>(null)
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const [enabledFilter, setEnabledFilter] = useState<'all' | 'enabled' | 'disabled'>('all')
|
||||
const [editingItem, setEditingItem] = useState<EditingItem | null>(null)
|
||||
const [deletingItem, setDeletingItem] = useState<EditingItem | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!params?.id) {
|
||||
router.push('/admin/navigation')
|
||||
return
|
||||
}
|
||||
fetchNavigation()
|
||||
}, [params?.id, router])
|
||||
|
||||
const fetchNavigation = async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
if (!params?.id) {
|
||||
throw new Error('Navigation ID not found')
|
||||
}
|
||||
|
||||
const navigationId = params?.id
|
||||
if (!navigationId) {
|
||||
throw new Error('Navigation ID is missing')
|
||||
}
|
||||
const response = await fetch(`/api/navigation/${navigationId}`)
|
||||
if (!response.ok) throw new Error('Failed to fetch')
|
||||
|
||||
const data = await response.json()
|
||||
setNavigation(data)
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "错误",
|
||||
description: "获取数据失败",
|
||||
variant: "destructive"
|
||||
})
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const addItem = async (values: NavigationSubItem) => {
|
||||
try {
|
||||
if (!params?.id || !navigation) {
|
||||
throw new Error('Navigation ID or data not found')
|
||||
}
|
||||
|
||||
const navigationId = params?.id
|
||||
if (!navigationId) {
|
||||
throw new Error('Navigation ID is missing')
|
||||
}
|
||||
const response = await fetch(`/api/navigation/${navigationId}/items`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(values)
|
||||
})
|
||||
|
||||
if (!response.ok) throw new Error('Failed to save')
|
||||
|
||||
await fetchNavigation()
|
||||
toast({
|
||||
title: "成功",
|
||||
description: "添加成功"
|
||||
})
|
||||
setIsAddDialogOpen(false)
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "错误",
|
||||
description: "保存失败",
|
||||
variant: "destructive"
|
||||
})
|
||||
} finally {
|
||||
setIsAddDialogOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
const updateItem = async (index: number, values: NavigationSubItem) => {
|
||||
try {
|
||||
if (!params?.id || !navigation) {
|
||||
throw new Error('Navigation ID or data not found')
|
||||
}
|
||||
|
||||
const navigationId = params?.id
|
||||
if (!navigationId) {
|
||||
throw new Error('Navigation ID is missing')
|
||||
}
|
||||
const items = [...(navigation.items || [])]
|
||||
items[index] = values
|
||||
|
||||
const updatedNavigation: NavigationItem = {
|
||||
id: navigation.id || navigationId,
|
||||
title: navigation.title || '',
|
||||
description: navigation.description || '',
|
||||
items,
|
||||
subCategories: navigation.subCategories || []
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/navigation/${navigationId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(updatedNavigation)
|
||||
})
|
||||
|
||||
if (!response.ok) throw new Error('Failed to save')
|
||||
|
||||
setNavigation(updatedNavigation)
|
||||
toast({
|
||||
title: "成功",
|
||||
description: "保存成功"
|
||||
})
|
||||
setEditingItem(null)
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "错误",
|
||||
description: "保存失败",
|
||||
variant: "destructive"
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const deleteItem = async (index: number) => {
|
||||
try {
|
||||
if (!params?.id) {
|
||||
throw new Error('Navigation ID not found')
|
||||
}
|
||||
|
||||
const navigationId = params?.id
|
||||
if (!navigationId) {
|
||||
throw new Error('Navigation ID is missing')
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/navigation/${navigationId}/items`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ index })
|
||||
})
|
||||
|
||||
if (!response.ok) throw new Error('Failed to delete')
|
||||
|
||||
await fetchNavigation()
|
||||
toast({
|
||||
title: "成功",
|
||||
description: "删除成功"
|
||||
})
|
||||
setDeletingItem(null)
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "错误",
|
||||
description: "删除失败",
|
||||
variant: "destructive"
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const onDragEnd = async (result: DropResult) => {
|
||||
if (!result.destination) return
|
||||
|
||||
const items = Array.from(navigation?.items || [])
|
||||
const [reorderedItem] = items.splice(result.source.index, 1)
|
||||
items.splice(result.destination.index, 0, reorderedItem)
|
||||
|
||||
try {
|
||||
const navigationId = params?.id
|
||||
if (!navigationId) {
|
||||
throw new Error('Navigation ID is missing')
|
||||
}
|
||||
const response = await fetch(`/api/navigation/${navigationId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
...navigation,
|
||||
items
|
||||
})
|
||||
})
|
||||
|
||||
if (!response.ok) throw new Error('Failed to save order')
|
||||
|
||||
await fetchNavigation() // Refresh data
|
||||
toast({
|
||||
title: "成功",
|
||||
description: "项目顺序已更新"
|
||||
})
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "错误",
|
||||
description: "保存顺序失败",
|
||||
variant: "destructive"
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const moveToTop = async (index: number) => {
|
||||
if (index > 0) {
|
||||
const items = Array.from(navigation?.items || [])
|
||||
const [reorderedItem] = items.splice(index, 1)
|
||||
items.unshift(reorderedItem)
|
||||
|
||||
try {
|
||||
const navigationId = params?.id
|
||||
if (!navigationId) {
|
||||
throw new Error('Navigation ID is missing')
|
||||
}
|
||||
const response = await fetch(`/api/navigation/${navigationId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
...navigation,
|
||||
items
|
||||
})
|
||||
})
|
||||
|
||||
if (!response.ok) throw new Error('Failed to save order')
|
||||
|
||||
await fetchNavigation() // Refresh data
|
||||
toast({
|
||||
title: "成功",
|
||||
description: "项目顺序已更新"
|
||||
})
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "错误",
|
||||
description: "保存顺序失败",
|
||||
variant: "destructive"
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const moveToBottom = async (index: number) => {
|
||||
if (!navigation?.items) return
|
||||
if (index < navigation.items.length - 1) {
|
||||
const items = Array.from(navigation?.items || [])
|
||||
const [reorderedItem] = items.splice(index, 1)
|
||||
items.push(reorderedItem)
|
||||
|
||||
try {
|
||||
const navigationId = params?.id
|
||||
if (!navigationId) {
|
||||
throw new Error('Navigation ID is missing')
|
||||
}
|
||||
const response = await fetch(`/api/navigation/${navigationId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
...navigation,
|
||||
items
|
||||
})
|
||||
})
|
||||
|
||||
if (!response.ok) throw new Error('Failed to save order')
|
||||
|
||||
await fetchNavigation() // Refresh data
|
||||
toast({
|
||||
title: "成功",
|
||||
description: "项目顺序已更新"
|
||||
})
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "错误",
|
||||
description: "保存顺序失败",
|
||||
variant: "destructive"
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const filteredItems = navigation?.items?.filter(item => {
|
||||
const matchesSearch =
|
||||
item.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
item.href.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
item.description?.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
|
||||
const matchesEnabled =
|
||||
enabledFilter === "all" ? true :
|
||||
enabledFilter === "enabled" ? item.enabled :
|
||||
enabledFilter === "disabled" ? !item.enabled :
|
||||
true
|
||||
|
||||
return matchesSearch && matchesEnabled
|
||||
}) || []
|
||||
|
||||
if (isLoading) {
|
||||
return <LoadingSkeleton />
|
||||
}
|
||||
|
||||
if (!navigation) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-[50vh]">
|
||||
<p className="text-muted-foreground">导航不存在</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => router.back()}
|
||||
className="h-8 w-8"
|
||||
title="返回"
|
||||
>
|
||||
<Icons.arrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground mb-1">
|
||||
{navigation?.title}
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative flex-1 max-w-sm">
|
||||
<Icons.search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="搜索站点..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-8"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setSearchQuery("")}
|
||||
className="absolute right-1 top-1 h-7 w-7 p-0"
|
||||
>
|
||||
<Icons.x className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<Select
|
||||
value={enabledFilter}
|
||||
onValueChange={(value: 'all' | 'enabled' | 'disabled') => setEnabledFilter(value)}
|
||||
>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue placeholder="按状态筛选" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部</SelectItem>
|
||||
<SelectItem value="enabled">已启用</SelectItem>
|
||||
<SelectItem value="disabled">已禁用</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Dialog open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<Icons.plus className="mr-2 h-4 w-4" />
|
||||
添加站点
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>添加站点</DialogTitle>
|
||||
</DialogHeader>
|
||||
<AddItemForm
|
||||
onSubmit={async (values) => {
|
||||
await addItem(values)
|
||||
setIsAddDialogOpen(false)
|
||||
}}
|
||||
onCancel={() => setIsAddDialogOpen(false)}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
{navigation?.items && navigation.items.length > 0 ? (
|
||||
<DragDropContext onDragEnd={onDragEnd}>
|
||||
<Droppable droppableId="droppable">
|
||||
{(provided) => (
|
||||
<div {...provided.droppableProps} ref={provided.innerRef} className="grid gap-2">
|
||||
{filteredItems.map((item, index) => (
|
||||
<Draggable key={item.id} draggableId={item.id} index={index}>
|
||||
{(provided) => (
|
||||
<div
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
{...provided.dragHandleProps}
|
||||
className="flex items-center justify-between p-4 rounded-lg border bg-card text-card-foreground shadow-sm hover:border-primary/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-primary/10">
|
||||
{item.icon ? (
|
||||
<img src={item.icon} alt={item.title} className="w-4 h-4 object-contain" />
|
||||
) : (
|
||||
<Icons.link className="h-4 w-4 text-primary" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium leading-none">{item.title}</span>
|
||||
{!item.enabled && (
|
||||
<Badge variant="secondary" className="text-xs">已禁用</Badge>
|
||||
)}
|
||||
</div>
|
||||
{item.description && (
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
{item.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => window.open(item.href, '_blank')}
|
||||
title="访问链接"
|
||||
>
|
||||
<Icons.globe className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => setEditingItem({ index, item })}
|
||||
title="编辑"
|
||||
>
|
||||
<Icons.pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => setDeletingItem({ index, item })}
|
||||
title="删除"
|
||||
>
|
||||
<Icons.trash className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Draggable>
|
||||
))}
|
||||
{filteredItems.length === 0 && (
|
||||
<div className="text-center py-10 text-muted-foreground">
|
||||
{navigation?.items?.length === 0 ? (
|
||||
<p>暂无站点</p>
|
||||
) : (
|
||||
<p>未找到匹配的站点</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{provided.placeholder}
|
||||
</div>
|
||||
)}
|
||||
</Droppable>
|
||||
</DragDropContext>
|
||||
) : (
|
||||
<div className="text-center py-10 text-muted-foreground">
|
||||
暂无站点
|
||||
</div>
|
||||
)}
|
||||
<Dialog open={!!editingItem} onOpenChange={(open) => !open && setEditingItem(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>编辑站点</DialogTitle>
|
||||
</DialogHeader>
|
||||
<AddItemForm
|
||||
defaultValues={editingItem?.item}
|
||||
onSubmit={(values) => {
|
||||
if (editingItem) {
|
||||
return updateItem(editingItem.index, values)
|
||||
}
|
||||
return Promise.resolve()
|
||||
}}
|
||||
onCancel={() => setEditingItem(null)}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={!!deletingItem} onOpenChange={(open) => !open && setDeletingItem(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>删除确认</DialogTitle>
|
||||
<DialogDescription>
|
||||
确定要删除站点 “{deletingItem?.item.title}” 吗?此操作无法撤销。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setDeletingItem(null)}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
if (deletingItem) {
|
||||
deleteItem(deletingItem.index)
|
||||
}
|
||||
}}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
217
app/admin/navigation/[id]/page.tsx
Normal file
217
app/admin/navigation/[id]/page.tsx
Normal file
@@ -0,0 +1,217 @@
|
||||
'use client'
|
||||
|
||||
export const runtime = 'edge'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { Button } from "@/registry/new-york/ui/button"
|
||||
import { useToast } from "@/registry/new-york/hooks/use-toast"
|
||||
import { NavigationItem } from '@/types/navigation'
|
||||
import { Icons } from '@/components/icons'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/registry/new-york/ui/table"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/registry/new-york/ui/dropdown-menu"
|
||||
import { Skeleton } from "@/registry/new-york/ui/skeleton"
|
||||
import {
|
||||
MoreHorizontal,
|
||||
Search,
|
||||
Inbox,
|
||||
FolderTree,
|
||||
Edit,
|
||||
Trash2
|
||||
} from "lucide-react"
|
||||
|
||||
export default function NavigationPage() {
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const { toast } = useToast()
|
||||
const [items, setItems] = useState<NavigationItem[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
|
||||
useEffect(() => {
|
||||
if (!params?.id) {
|
||||
router.push('/admin/navigation')
|
||||
return
|
||||
}
|
||||
fetchItems()
|
||||
}, [params?.id, router])
|
||||
|
||||
const fetchItems = async () => {
|
||||
try {
|
||||
setIsLoading(true)
|
||||
const response = await fetch(`/api/navigation/${params!.id}/items`)
|
||||
if (!response.ok) throw new Error('Failed to fetch')
|
||||
const data = await response.json()
|
||||
setItems(data)
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "错误",
|
||||
description: "加载数据失败",
|
||||
variant: "destructive"
|
||||
})
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleItemsManage = (itemId: string) => {
|
||||
router.push(`/admin/navigation/${params!.id}/items/${itemId}`)
|
||||
}
|
||||
|
||||
const handleCategoryManage = (itemId: string) => {
|
||||
router.push(`/admin/navigation/${params!.id}/categories/${itemId}`)
|
||||
}
|
||||
|
||||
const handleEdit = async (item: NavigationItem) => {
|
||||
try {
|
||||
const response = await fetch(`/api/navigation/${params!.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(item)
|
||||
})
|
||||
|
||||
if (!response.ok) throw new Error('Failed to update')
|
||||
|
||||
await fetchItems()
|
||||
toast({
|
||||
title: "成功",
|
||||
description: "更新成功"
|
||||
})
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "错误",
|
||||
description: "更新失败",
|
||||
variant: "destructive"
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (itemId: string) => {
|
||||
if (!confirm('确定要删除这个导航吗?')) return
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/navigation/${params!.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id: itemId })
|
||||
})
|
||||
|
||||
if (!response.ok) throw new Error('Failed to delete')
|
||||
|
||||
await fetchItems()
|
||||
toast({
|
||||
title: "成功",
|
||||
description: "删除成功"
|
||||
})
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "错误",
|
||||
description: "删除失败",
|
||||
variant: "destructive"
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const filteredItems = items.filter(item =>
|
||||
item.title.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>ID</TableHead>
|
||||
<TableHead>标题</TableHead>
|
||||
<TableHead>子分类数</TableHead>
|
||||
<TableHead className="text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredItems.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="text-center py-8">
|
||||
{searchQuery ? (
|
||||
<div className="text-muted-foreground">
|
||||
<Search className="mx-auto h-12 w-12 opacity-50" />
|
||||
<p className="mt-2">没有找到匹配的导航</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-muted-foreground">
|
||||
<Inbox className="mx-auto h-12 w-12 opacity-50" />
|
||||
<p className="mt-2">暂无导航数据</p>
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredItems.map((item, index) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell>{index + 1}</TableCell>
|
||||
<TableCell>{item.title}</TableCell>
|
||||
<TableCell>{item.subCategories?.length || 0}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0 hover:bg-muted"
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
<span className="sr-only">打开菜单</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-[160px]">
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleItemsManage(item.id)}
|
||||
>
|
||||
<Icons.list className="mr-2 h-4 w-4" />
|
||||
子项目管理
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleCategoryManage(item.id)}
|
||||
>
|
||||
<FolderTree className="mr-2 h-4 w-4" />
|
||||
分类管理
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleEdit(item)}
|
||||
>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
编辑导航
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleDelete(item.id)}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
删除导航
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
84
app/admin/navigation/[id]/subcategory/[subId]/page.tsx
Normal file
84
app/admin/navigation/[id]/subcategory/[subId]/page.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
'use client'
|
||||
export const runtime = 'edge'
|
||||
|
||||
import * as React from "react"
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { Icons } from '@/components/icons'
|
||||
import { NavigationItem, NavigationSubItem } from '@/types/navigation'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table"
|
||||
|
||||
export default function SubCategoryItemsPage() {
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const { toast } = useToast()
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [category, setCategory] = useState<NavigationItem | null>(null)
|
||||
const [subCategory, setSubCategory] = useState<NavigationSubItem | null>(null)
|
||||
const [editingId, setEditingId] = useState<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
}, [])
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
if (!params?.id) {
|
||||
throw new Error('Navigation ID not found')
|
||||
}
|
||||
|
||||
const navigationId = Array.isArray(params.id) ? params.id[0] : params.id
|
||||
const response = await fetch(`/api/navigation/${navigationId}`)
|
||||
if (!response.ok) throw new Error('Failed to fetch')
|
||||
const data = await response.json()
|
||||
setCategory(data)
|
||||
|
||||
const sub = data.subCategories?.find((s: NavigationSubItem) => String(s.id) === params.subId)
|
||||
if (sub) {
|
||||
setSubCategory(sub)
|
||||
} else {
|
||||
throw new Error('Subcategory not found')
|
||||
}
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: '错误',
|
||||
description: '加载数据失败',
|
||||
variant: 'destructive'
|
||||
})
|
||||
router.back()
|
||||
}
|
||||
}
|
||||
|
||||
// ... 添加其他必要的函数
|
||||
|
||||
if (!category || !subCategory) return null
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h3 className="text-lg font-medium">
|
||||
{category.title} - {subCategory.title} - 子项目管理
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
管理子分类的子项目
|
||||
</p>
|
||||
</div>
|
||||
{/* ... 添加操作按钮 */}
|
||||
</div>
|
||||
|
||||
{/* ... 添加表格和其他内容 */}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
160
app/admin/navigation/components/AddCategoryForm.tsx
Normal file
160
app/admin/navigation/components/AddCategoryForm.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
'use client'
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useForm } from "react-hook-form"
|
||||
import * as z from "zod"
|
||||
import { Button } from "@/registry/new-york/ui/button"
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage, FormDescription } from "@/registry/new-york/ui/form"
|
||||
import { Input } from "@/registry/new-york/ui/input"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/registry/new-york/ui/select"
|
||||
import { navigationIcons } from "@/lib/icons"
|
||||
import { Switch } from "@/registry/new-york/ui/switch"
|
||||
import { useToast } from "@/registry/new-york/hooks/use-toast"
|
||||
|
||||
const formSchema = z.object({
|
||||
title: z.string().min(1, "标题不能为空"),
|
||||
description: z.string().optional(),
|
||||
icon: z.string().min(1, "请选择图标"),
|
||||
enabled: z.boolean().default(true)
|
||||
})
|
||||
|
||||
interface AddCategoryFormProps {
|
||||
defaultValues?: {
|
||||
title: string
|
||||
description?: string
|
||||
icon: string
|
||||
enabled: boolean
|
||||
}
|
||||
onSubmit: (values: z.infer<typeof formSchema>) => Promise<void>
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
export function AddCategoryForm({ defaultValues, onSubmit, onCancel }: AddCategoryFormProps) {
|
||||
const { toast } = useToast()
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: defaultValues || {
|
||||
title: "",
|
||||
description: "",
|
||||
icon: "",
|
||||
enabled: true
|
||||
}
|
||||
})
|
||||
|
||||
const handleSubmit = async (values: z.infer<typeof formSchema>) => {
|
||||
try {
|
||||
await onSubmit(values);
|
||||
toast({
|
||||
title: "保存成功",
|
||||
});
|
||||
setTimeout(onCancel, 0);
|
||||
} catch (error) {
|
||||
console.error('Failed to submit:', error);
|
||||
toast({
|
||||
title: "保存失败",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="title"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>标题</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="输入分类标题" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>描述</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="输入分类描述" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="icon"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>图标</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="选择图标" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{Object.entries(navigationIcons).map(([key, Icon]) => (
|
||||
<SelectItem key={key} value={key}>
|
||||
<div className="flex items-center">
|
||||
<Icon className="mr-2 h-4 w-4" />
|
||||
{key}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enabled"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-start space-x-3 space-y-0">
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="space-y-1 leading-none">
|
||||
<FormLabel>
|
||||
启用状态
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
控制该分类是否在导航中显示
|
||||
</FormDescription>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onCancel}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={form.formState.isSubmitting}
|
||||
>
|
||||
{form.formState.isSubmitting ? "保存中..." : "保存"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
364
app/admin/navigation/components/AddItemForm.tsx
Normal file
364
app/admin/navigation/components/AddItemForm.tsx
Normal file
@@ -0,0 +1,364 @@
|
||||
'use client'
|
||||
|
||||
import { useForm } from "react-hook-form"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import * as z from "zod"
|
||||
import { Button } from "@/registry/new-york/ui/button"
|
||||
import { Input } from "@/registry/new-york/ui/input"
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
FormDescription,
|
||||
} from "@/registry/new-york/ui/form"
|
||||
import { NavigationSubItem } from "@/types/navigation"
|
||||
import { Icons } from "@/components/icons"
|
||||
import { Textarea } from "@/registry/new-york/ui/textarea"
|
||||
import { Switch } from "@/registry/new-york/ui/switch"
|
||||
import { useState, useEffect } from "react"
|
||||
import { useToast } from "@/registry/new-york/hooks/use-toast"
|
||||
|
||||
const formSchema = z.object({
|
||||
id: z.string().optional(),
|
||||
title: z.string().min(2, { message: "网站标题至少需要2个字符" }),
|
||||
href: z.string().url({ message: "请输入有效的网站链接" }),
|
||||
icon: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
enabled: z.boolean().default(true),
|
||||
})
|
||||
|
||||
interface AddItemFormProps {
|
||||
onSubmit: (values: NavigationSubItem) => Promise<void>
|
||||
onCancel: () => void
|
||||
defaultValues?: NavigationSubItem
|
||||
}
|
||||
|
||||
export function AddItemForm({ onSubmit, onCancel, defaultValues }: AddItemFormProps) {
|
||||
const { toast } = useToast()
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: defaultValues || {
|
||||
id: String(Date.now()),
|
||||
title: "",
|
||||
href: "",
|
||||
icon: "",
|
||||
description: "",
|
||||
enabled: true,
|
||||
}
|
||||
})
|
||||
|
||||
const isSubmitting = form.formState.isSubmitting
|
||||
const [isUploading, setIsUploading] = useState(false)
|
||||
const [isFetchingMetadata, setIsFetchingMetadata] = useState(false)
|
||||
|
||||
// 监听 href 字段变化,自动获取网站信息
|
||||
const hrefValue = form.watch("href")
|
||||
|
||||
useEffect(() => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (hrefValue && isValidUrl(hrefValue) && !defaultValues) {
|
||||
fetchWebsiteMetadata(hrefValue)
|
||||
}
|
||||
}, 1000) // 延迟1秒执行,避免频繁请求
|
||||
|
||||
return () => clearTimeout(timeoutId)
|
||||
}, [hrefValue, defaultValues])
|
||||
|
||||
const isValidUrl = (string: string): boolean => {
|
||||
try {
|
||||
new URL(string)
|
||||
return true
|
||||
} catch (_) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const fetchWebsiteMetadata = async (url: string) => {
|
||||
if (isFetchingMetadata) return
|
||||
|
||||
setIsFetchingMetadata(true)
|
||||
try {
|
||||
const response = await fetch('/api/website-metadata', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ url }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('获取网站信息失败')
|
||||
}
|
||||
|
||||
const metadata = await response.json()
|
||||
|
||||
// 只在字段为空时自动填充
|
||||
if (!form.getValues('title')) {
|
||||
form.setValue('title', metadata.title)
|
||||
}
|
||||
if (!form.getValues('description')) {
|
||||
form.setValue('description', metadata.description)
|
||||
}
|
||||
if (!form.getValues('icon') && metadata.icon) {
|
||||
form.setValue('icon', metadata.icon)
|
||||
}
|
||||
|
||||
toast({
|
||||
title: "成功",
|
||||
description: "已自动获取网站信息"
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch website metadata:', error)
|
||||
toast({
|
||||
title: "提示",
|
||||
description: "自动获取网站信息失败,请手动填写",
|
||||
variant: "destructive"
|
||||
})
|
||||
} finally {
|
||||
setIsFetchingMetadata(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(async (data) => {
|
||||
try {
|
||||
const values: NavigationSubItem = {
|
||||
id: data.id || crypto.randomUUID(),
|
||||
title: data.title,
|
||||
href: data.href,
|
||||
description: data.description,
|
||||
icon: data.icon,
|
||||
enabled: data.enabled
|
||||
}
|
||||
await onSubmit(values)
|
||||
} catch (error) {
|
||||
console.error('保存失败:', error)
|
||||
}
|
||||
})} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="href"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>网站链接</FormLabel>
|
||||
<FormControl>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="relative flex-1">
|
||||
<Input placeholder="输入网站链接,将自动获取网站信息" {...field} />
|
||||
{isFetchingMetadata && (
|
||||
<div className="absolute right-3 top-1/2 transform -translate-y-1/2">
|
||||
<Icons.loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!field.value || !isValidUrl(field.value) || isFetchingMetadata}
|
||||
onClick={() => fetchWebsiteMetadata(field.value)}
|
||||
>
|
||||
{isFetchingMetadata ? (
|
||||
<Icons.loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Icons.refresh className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
输入完整的网站链接后,系统将自动获取网站标题、描述和图标
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="title"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>网站标题</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="网站标题(可自动获取)" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="icon"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>图标</FormLabel>
|
||||
<FormControl>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex-1 relative">
|
||||
<Input
|
||||
placeholder="图标URL(可自动获取)"
|
||||
{...field}
|
||||
/>
|
||||
{field.value && (
|
||||
<div className="absolute right-3 top-1/2 transform -translate-y-1/2">
|
||||
<img
|
||||
src={field.value}
|
||||
alt="图标预览"
|
||||
className="w-4 h-4 object-contain"
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement
|
||||
target.style.display = 'none'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="relative"
|
||||
disabled={isUploading}
|
||||
onClick={() => {
|
||||
const fileInput = document.getElementById('icon-upload');
|
||||
fileInput?.click();
|
||||
}}
|
||||
>
|
||||
{isUploading ? (
|
||||
<>
|
||||
<Icons.loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
上传中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Icons.upload className="mr-2 h-4 w-4" />
|
||||
上传图片
|
||||
</>
|
||||
)}
|
||||
<input
|
||||
id="icon-upload"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
try {
|
||||
setIsUploading(true);
|
||||
|
||||
// 将文件转换为 base64
|
||||
const base64 = await new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result as string);
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
|
||||
const response = await fetch('/api/resource', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
image: base64 // 直接发送 base64 字符串
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`上传失败: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.imageUrl) {
|
||||
field.onChange(`${data.imageUrl}`); // 使用返回的图片URL
|
||||
} else {
|
||||
throw new Error('未获取到上传后的图片URL');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('上传失败:', error);
|
||||
alert(error instanceof Error ? error.message : '上传失败,请重试');
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
// 清空文件输入
|
||||
const fileInput = document.getElementById('icon-upload') as HTMLInputElement;
|
||||
if (fileInput) {
|
||||
fileInput.value = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
className="hidden"
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
系统会自动获取网站图标,也可手动输入URL或上传本地图片
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>网站描述</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="输入网站描述"
|
||||
className="resize-none"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enabled"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg p-4">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel className="text-base">
|
||||
启用状态
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
设置该导航项是否启用
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="flex justify-end space-x-2">
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting && (
|
||||
<Icons.loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
{isSubmitting ? "保存中..." : "保存"}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onCancel}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
179
app/admin/navigation/components/AddNavigationForm.tsx
Normal file
179
app/admin/navigation/components/AddNavigationForm.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
'use client'
|
||||
|
||||
import { useForm } from "react-hook-form"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import * as z from "zod"
|
||||
import { Button } from "@/registry/new-york/ui/button"
|
||||
import { Input } from "@/registry/new-york/ui/input"
|
||||
import { Switch } from "@/registry/new-york/ui/switch"
|
||||
import { Textarea } from "@/registry/new-york/ui/textarea"
|
||||
|
||||
import { IconSelector } from './IconSelector'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
FormDescription,
|
||||
} from "@/registry/new-york/ui/form"
|
||||
import { useToast } from "@/registry/new-york/hooks/use-toast"
|
||||
|
||||
const formSchema = z.object({
|
||||
title: z.string().min(2, { message: "标题至少需要2个字符" }),
|
||||
icon: z.string().min(1, { message: "请选择图标" }),
|
||||
description: z.string().optional(),
|
||||
enabled: z.boolean().default(true)
|
||||
})
|
||||
|
||||
interface AddNavigationFormProps {
|
||||
onSubmit: (values: {
|
||||
title: string;
|
||||
icon: string;
|
||||
description?: string;
|
||||
enabled: boolean;
|
||||
}) => void
|
||||
defaultValues?: {
|
||||
title: string
|
||||
icon: string
|
||||
description?: string
|
||||
enabled: boolean
|
||||
}
|
||||
onCancel?: () => void
|
||||
}
|
||||
|
||||
export function AddNavigationForm({
|
||||
onSubmit,
|
||||
defaultValues,
|
||||
onCancel
|
||||
}: AddNavigationFormProps) {
|
||||
const { toast } = useToast()
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: defaultValues || {
|
||||
title: "",
|
||||
icon: "FolderKanban",
|
||||
description: "",
|
||||
enabled: true
|
||||
}
|
||||
})
|
||||
|
||||
const { isSubmitting } = form.formState
|
||||
|
||||
const onSubmitHandler = async (values: z.infer<typeof formSchema>) => {
|
||||
try {
|
||||
await onSubmit({
|
||||
title: values.title,
|
||||
icon: values.icon,
|
||||
description: values.description,
|
||||
enabled: values.enabled
|
||||
})
|
||||
|
||||
toast({
|
||||
title: defaultValues ? "更新成功" : "添加成功",
|
||||
description: `导航项 "${values.title}" 已${defaultValues ? "更新" : "添加"}`,
|
||||
})
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "操作失败",
|
||||
description: "请稍后重试",
|
||||
variant: "destructive",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmitHandler)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="title"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>标题</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="输入导航标题" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="icon"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>图标</FormLabel>
|
||||
<FormControl>
|
||||
<IconSelector value={field.value} onChange={field.onChange} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
从 Lucide 图标库中选择一个图标
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>描述</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="输入分类描述(可选)"
|
||||
className="resize-none"
|
||||
rows={3}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
简短描述该分类项的用途
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enabled"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg p-4">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel className="text-base">启用状态</FormLabel>
|
||||
<FormDescription>
|
||||
设置该项是否启用
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="flex gap-4">
|
||||
<Button
|
||||
type="submit"
|
||||
className="flex-1"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? "提交中..." : defaultValues ? "更新" : "添加"}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
onClick={onCancel}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
181
app/admin/navigation/components/IconPicker.tsx
Normal file
181
app/admin/navigation/components/IconPicker.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Button } from "@/registry/new-york/ui/button"
|
||||
import { Icons } from "@/components/icons"
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
} from "@/registry/new-york/ui/command"
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/registry/new-york/ui/popover"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Check } from "lucide-react"
|
||||
|
||||
// 按分类组织图标
|
||||
const iconGroups = [
|
||||
{
|
||||
label: '常用',
|
||||
icons: [
|
||||
{ name: 'home', icon: Icons.home },
|
||||
{ name: 'folder', icon: Icons.folder },
|
||||
{ name: 'fileText', icon: Icons.fileText },
|
||||
{ name: 'list', icon: Icons.list },
|
||||
{ name: 'search', icon: Icons.search },
|
||||
{ name: 'plus', icon: Icons.plus },
|
||||
{ name: 'bookmark', icon: Icons.bookmark },
|
||||
{ name: 'link', icon: Icons.link }
|
||||
]
|
||||
},
|
||||
{
|
||||
label: '导航',
|
||||
icons: [
|
||||
{ name: 'menu', icon: Icons.menu },
|
||||
{ name: 'arrowLeft', icon: Icons.arrowLeft },
|
||||
{ name: 'chevronLeft', icon: Icons.chevronLeft },
|
||||
{ name: 'chevronRight', icon: Icons.chevronRight },
|
||||
{ name: 'moreVertical', icon: Icons.moreVertical },
|
||||
{ name: 'navigation', icon: Icons.navigation },
|
||||
{ name: 'compass', icon: Icons.compass },
|
||||
{ name: 'map', icon: Icons.map }
|
||||
]
|
||||
},
|
||||
{
|
||||
label: '状态',
|
||||
icons: [
|
||||
{ name: 'check', icon: Icons.check },
|
||||
{ name: 'x', icon: Icons.x },
|
||||
{ name: 'loader2', icon: Icons.loader2 },
|
||||
{ name: 'plus', icon: Icons.plus }
|
||||
]
|
||||
},
|
||||
{
|
||||
label: '系统',
|
||||
icons: [
|
||||
{ name: 'layoutDashboard', icon: Icons.layoutDashboard },
|
||||
{ name: 'database', icon: Icons.database },
|
||||
{ name: 'command', icon: Icons.command },
|
||||
{ name: 'monitor', icon: Icons.monitor }
|
||||
]
|
||||
},
|
||||
{
|
||||
label: '用户',
|
||||
icons: [
|
||||
{ name: 'user', icon: Icons.user },
|
||||
{ name: 'logOut', icon: Icons.logOut },
|
||||
{ name: 'github', icon: Icons.github },
|
||||
{ name: 'mail', icon: Icons.mail }
|
||||
]
|
||||
},
|
||||
{
|
||||
label: '内容',
|
||||
icons: [
|
||||
{ name: 'bookOpen', icon: Icons.bookOpen },
|
||||
{ name: 'bookmark', icon: Icons.bookmark },
|
||||
{ name: 'library', icon: Icons.library },
|
||||
{ name: 'newspaper', icon: Icons.newspaper },
|
||||
{ name: 'mail', icon: Icons.mail },
|
||||
{ name: 'messageSquare', icon: Icons.messageSquare },
|
||||
{ name: 'calendar', icon: Icons.calendar }
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
interface IconPickerProps {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
}
|
||||
|
||||
export function IconPicker({ value, onChange }: IconPickerProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [search, setSearch] = useState("")
|
||||
|
||||
// 查找当前选中的图标
|
||||
const selectedIcon = iconGroups
|
||||
.flatMap(group => group.icons)
|
||||
.find(icon => icon.name === value)
|
||||
const Icon = selectedIcon?.icon || Icons.folder
|
||||
|
||||
// 过滤图标
|
||||
const filteredGroups = search
|
||||
? [{
|
||||
label: '搜索结果',
|
||||
icons: iconGroups
|
||||
.flatMap(group => group.icons)
|
||||
.filter(icon =>
|
||||
icon.name.toLowerCase().includes(search.toLowerCase())
|
||||
)
|
||||
}]
|
||||
: iconGroups
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className="w-full justify-between"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{Icon && <Icon className="h-4 w-4" />}
|
||||
<span>{value || "选择图标"}</span>
|
||||
</div>
|
||||
<Icons.chevronRight className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-[240px] p-0"
|
||||
align="start"
|
||||
side="bottom"
|
||||
sideOffset={5}
|
||||
>
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder="搜索图标..."
|
||||
value={search}
|
||||
onValueChange={setSearch}
|
||||
/>
|
||||
<div className="max-h-[300px] overflow-y-auto">
|
||||
<CommandEmpty>未找到图标</CommandEmpty>
|
||||
{filteredGroups.map((group, index) => (
|
||||
<div key={group.label}>
|
||||
{index > 0 && <CommandSeparator />}
|
||||
<CommandGroup heading={group.label}>
|
||||
{group.icons.map(icon => (
|
||||
<CommandItem
|
||||
key={icon.name}
|
||||
value={icon.name}
|
||||
onSelect={(currentValue) => {
|
||||
onChange(currentValue)
|
||||
setOpen(false)
|
||||
setSearch("")
|
||||
}}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
{icon.icon && <icon.icon className="h-4 w-4" />}
|
||||
{icon.name}
|
||||
</div>
|
||||
{value === icon.name && (
|
||||
<Check className="h-4 w-4" />
|
||||
)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
82
app/admin/navigation/components/IconSelector.tsx
Normal file
82
app/admin/navigation/components/IconSelector.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { Button } from "@/registry/new-york/ui/button"
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem } from "@/registry/new-york/ui/command"
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/registry/new-york/ui/popover"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Check, ChevronsUpDown } from "lucide-react"
|
||||
import { navigationIcons, type IconType } from '@/lib/icons'
|
||||
|
||||
interface IconSelectorProps {
|
||||
value?: string
|
||||
onChange: (value: string) => void
|
||||
}
|
||||
|
||||
export function IconSelector({ value, onChange }: IconSelectorProps) {
|
||||
const [open, setOpen] = React.useState(false)
|
||||
const [searchQuery, setSearchQuery] = React.useState("")
|
||||
|
||||
// 获取当前选中的图标组件
|
||||
const SelectedIcon = value && navigationIcons[value as IconType]
|
||||
? navigationIcons[value as IconType]
|
||||
: null
|
||||
|
||||
// 过滤图标
|
||||
const filteredIcons = Object.entries(navigationIcons)
|
||||
.filter(([name]) =>
|
||||
name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className="w-full justify-between"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{SelectedIcon && <SelectedIcon className="h-4 w-4" />}
|
||||
<span>{value || "选择图标"}</span>
|
||||
</div>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[300px] p-0">
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder="搜索图标..."
|
||||
value={searchQuery}
|
||||
onValueChange={setSearchQuery}
|
||||
/>
|
||||
<CommandEmpty>没有找到图标</CommandEmpty>
|
||||
<div className="max-h-[200px] overflow-y-scroll">
|
||||
<CommandGroup>
|
||||
{filteredIcons.map(([name]) => (
|
||||
<CommandItem
|
||||
key={name}
|
||||
value={name}
|
||||
onSelect={(currentValue) => {
|
||||
onChange(currentValue)
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
value === name ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
{React.createElement(navigationIcons[name as IconType], { className: "mr-2 h-4 w-4" })}
|
||||
{name}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</div>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
133
app/admin/navigation/components/IconUploader.tsx
Normal file
133
app/admin/navigation/components/IconUploader.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import { useFormContext } from "react-hook-form"
|
||||
import { FormControl, FormItem, FormLabel, FormMessage, FormDescription } from "@/registry/new-york/ui/form"
|
||||
import { Button } from "@/registry/new-york/ui/button"
|
||||
import { Input } from "@/registry/new-york/ui/input"
|
||||
const GITHUB_API_URL = 'https://api.github.com/repos/YOUR_USERNAME/YOUR_REPO/contents'
|
||||
const GITHUB_TOKEN = process.env.NEXT_PUBLIC_GITHUB_TOKEN
|
||||
const ICONS_PATH = 'public/icons' // GitHub 仓库中存储图标的路径
|
||||
|
||||
interface IconUploaderProps {
|
||||
onChange: (icon: string) => void;
|
||||
value?: string;
|
||||
}
|
||||
|
||||
export function IconUploader({ onChange, value }: IconUploaderProps) {
|
||||
const { setValue, getValues } = useFormContext()
|
||||
|
||||
const fetchFavicon = async (url: string) => {
|
||||
console.log('Fetching favicon for URL:', url);
|
||||
if (!url) {
|
||||
console.error('URL 不能为空')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const urlObj = new URL(url)
|
||||
const baseUrl = `${urlObj.protocol}//${urlObj.hostname}`
|
||||
|
||||
// 尝试获取网页内容
|
||||
const response = await fetch(url)
|
||||
const html = await response.text()
|
||||
|
||||
// 使用正则表达式查找 favicon 链接
|
||||
const faviconMatch = html.match(/<link[^>]+rel=["'](?:icon|shortcut icon)["'][^>]+href=["']([^"']+)["']/i)
|
||||
let faviconUrl = faviconMatch ? faviconMatch[1] : `${baseUrl}/favicon.ico`
|
||||
|
||||
// 处理相对路径
|
||||
if (!faviconUrl.startsWith('http')) {
|
||||
faviconUrl = new URL(faviconUrl, baseUrl).href
|
||||
}
|
||||
|
||||
// 检查 favicon 是否存在
|
||||
const faviconResponse = await fetch(faviconUrl, { method: 'HEAD' })
|
||||
if (!faviconResponse.ok) {
|
||||
throw new Error('未找到 favicon')
|
||||
}
|
||||
|
||||
setValue('icon', faviconUrl)
|
||||
} catch (error) {
|
||||
console.error('获取网站图标失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const uploadIconToGithub = async (file: File) => {
|
||||
try {
|
||||
const reader = new FileReader()
|
||||
reader.readAsDataURL(file)
|
||||
|
||||
reader.onload = async () => {
|
||||
const base64Content = (reader.result as string).split(',')[1]
|
||||
const fileName = `icon-${Date.now()}-${file.name}`
|
||||
|
||||
const response = await fetch(`${GITHUB_API_URL}/${ICONS_PATH}/${fileName}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${GITHUB_TOKEN}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
message: `Upload icon: ${fileName}`,
|
||||
content: base64Content,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('上传失败')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const iconUrl = data.content.download_url
|
||||
setValue('icon', iconUrl)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('上传图标失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>图标</FormLabel>
|
||||
<div className="flex space-x-2">
|
||||
<FormControl className="flex-1">
|
||||
<Input placeholder="输入图标URL" {...{ name: 'icon' }} />
|
||||
</FormControl>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
const href = getValues('href');
|
||||
if (href) {
|
||||
fetchFavicon(href);
|
||||
} else {
|
||||
console.error('请提供有效的 URL');
|
||||
}
|
||||
}}
|
||||
>
|
||||
获取图标
|
||||
</Button>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type="file"
|
||||
className="absolute inset-0 opacity-0 cursor-pointer"
|
||||
accept="image/*"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) {
|
||||
uploadIconToGithub(file)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
上传图标
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<FormDescription>
|
||||
支持 URL、Base64 格式或上传本地图片
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)
|
||||
}
|
||||
288
app/admin/navigation/components/NavigationCard.tsx
Normal file
288
app/admin/navigation/components/NavigationCard.tsx
Normal file
@@ -0,0 +1,288 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Button } from "@/registry/new-york/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
DialogDescription,
|
||||
} from "@/registry/new-york/ui/dialog"
|
||||
import { useToast } from "@/registry/new-york/hooks/use-toast"
|
||||
import { AddNavigationForm } from './AddNavigationForm'
|
||||
import { Draggable } from "@hello-pangea/dnd"
|
||||
import { Tooltip, TooltipContent, TooltipTrigger, TooltipProvider } from "@/registry/new-york/ui/tooltip"
|
||||
import { NavigationItem } from '@/types/navigation'
|
||||
import { navigationIcons, type IconType } from '@/lib/icons'
|
||||
import {
|
||||
Folder,
|
||||
FolderOpen,
|
||||
List,
|
||||
Image,
|
||||
Pencil,
|
||||
Trash,
|
||||
ChevronsUp,
|
||||
ChevronsDown
|
||||
} from 'lucide-react'
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Badge } from "@/registry/new-york/ui/badge"
|
||||
|
||||
interface NavigationCardProps {
|
||||
item: NavigationItem
|
||||
index: number
|
||||
onUpdate: () => void
|
||||
onMoveToTop?: () => void
|
||||
onMoveToBottom?: () => void
|
||||
showMoveToTop?: boolean
|
||||
showMoveToBottom?: boolean
|
||||
}
|
||||
|
||||
export function NavigationCard({
|
||||
item,
|
||||
index,
|
||||
onUpdate,
|
||||
onMoveToTop,
|
||||
onMoveToBottom,
|
||||
showMoveToTop,
|
||||
showMoveToBottom
|
||||
}: NavigationCardProps) {
|
||||
const router = useRouter()
|
||||
const { toast } = useToast()
|
||||
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false)
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
|
||||
|
||||
const Icon = item.icon && navigationIcons[item.icon as IconType] ? navigationIcons[item.icon as IconType] : navigationIcons.Folder
|
||||
|
||||
const handleEdit = async (values: {
|
||||
title: string;
|
||||
description?: string;
|
||||
icon: string;
|
||||
enabled: boolean;
|
||||
}) => {
|
||||
try {
|
||||
const response = await fetch(`/api/navigation/${item.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
...item,
|
||||
title: values.title,
|
||||
description: values.description,
|
||||
icon: values.icon,
|
||||
enabled: values.enabled
|
||||
})
|
||||
})
|
||||
|
||||
if (!response.ok) throw new Error('Failed to save')
|
||||
|
||||
setIsEditDialogOpen(false)
|
||||
onUpdate()
|
||||
toast({
|
||||
title: "成功",
|
||||
description: "保存成功"
|
||||
})
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "错误",
|
||||
description: "保存失败",
|
||||
variant: "destructive"
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/navigation/${item.id}`, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
|
||||
if (!response.ok) throw new Error('Failed to delete')
|
||||
|
||||
setIsDeleteDialogOpen(false)
|
||||
onUpdate()
|
||||
toast({
|
||||
title: "成功",
|
||||
description: "删除成功"
|
||||
})
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "错误",
|
||||
description: "删除失败",
|
||||
variant: "destructive"
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Draggable draggableId={item.id} index={index}>
|
||||
{(provided, snapshot) => (
|
||||
<div
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
{...provided.dragHandleProps}
|
||||
className={`flex items-center justify-between p-4 rounded-lg border bg-card text-card-foreground shadow-sm hover:border-primary/50 transition-colors ${
|
||||
snapshot.isDragging ? 'bg-gray-50' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center space-x-4">
|
||||
<Icon className="h-6 w-6 text-muted-foreground" />
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-sm font-medium">{item.title}</h3>
|
||||
<Badge
|
||||
variant={(item.enabled ?? true) ? "default" : "secondary"}
|
||||
className={cn(
|
||||
"text-xs",
|
||||
(item.enabled ?? true)
|
||||
? "bg-green-100 text-green-800 hover:bg-green-100"
|
||||
: "bg-gray-100 text-gray-600 hover:bg-gray-100"
|
||||
)}
|
||||
>
|
||||
{(item.enabled ?? true) ? "已启用" : "已禁用"}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{item.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => router.push(`/admin/navigation/${item.id}/categories`)}
|
||||
className="h-8 w-8"
|
||||
>
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>分类管理</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => router.push(`/admin/navigation/${item.id}/items`)}
|
||||
className="h-8 w-8"
|
||||
>
|
||||
<List className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>站点管理</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{showMoveToTop && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onMoveToTop}
|
||||
className="h-8 w-8"
|
||||
>
|
||||
<ChevronsUp className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>置顶</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{showMoveToBottom && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onMoveToBottom}
|
||||
className="h-8 w-8"
|
||||
>
|
||||
<ChevronsDown className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>置底</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setIsEditDialogOpen(true)}
|
||||
className="h-8 w-8"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>编辑</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setIsDeleteDialogOpen(true)}
|
||||
className="h-8 w-8"
|
||||
>
|
||||
<Trash className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>删除</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
|
||||
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>编辑分类</DialogTitle>
|
||||
</DialogHeader>
|
||||
<AddNavigationForm
|
||||
defaultValues={{
|
||||
title: item.title,
|
||||
description: item.description || '',
|
||||
icon: item.icon || '',
|
||||
enabled: item.enabled ?? true
|
||||
}}
|
||||
onSubmit={handleEdit}
|
||||
onCancel={() => setIsEditDialogOpen(false)}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>确认删除</DialogTitle>
|
||||
<DialogDescription>
|
||||
确定要删除这个导航吗?此操作无法撤消,所有相关的分类和子项目都将被删除。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button variant="outline" onClick={() => setIsDeleteDialogOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleDelete}>
|
||||
删除
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)}
|
||||
</Draggable>
|
||||
)
|
||||
}
|
||||
325
app/admin/navigation/page.tsx
Normal file
325
app/admin/navigation/page.tsx
Normal file
@@ -0,0 +1,325 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user