's'
This commit is contained in:
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user