Files
gitlab-instance-0a899031_na…/app/admin/navigation/components/AddItemForm.tsx
2025-11-13 05:05:53 +08:00

364 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
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>
)
}