'use client' export const runtime = 'edge' import { useState, useEffect, useRef } from 'react' import { 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 { Input } from "@/registry/new-york/ui/input" import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, DialogDescription, DialogFooter, } from "@/registry/new-york/ui/dialog" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "@/registry/new-york/ui/table" import { Checkbox } from "@/registry/new-york/ui/checkbox" import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from "@/registry/new-york/ui/alert-dialog" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/registry/new-york/ui/select" import { Label } from "@/registry/new-york/ui/label" import { Textarea } from "@/registry/new-york/ui/textarea" import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@/registry/new-york/ui/tooltip" import { NavigationSubItem } from '@/types/navigation' interface SubCategory { id: string title: string icon?: string items: NavigationSubItem[] } interface Category { id: string title: string icon?: string items: NavigationSubItem[] subCategories?: SubCategory[] } interface Site { id: string name: string url: string description?: string createdAt: string updatedAt: string } export default function SiteListPage() { console.log('Component rendering') const { toast } = useToast() const [sites, setSites] = useState([]) const [searchQuery, setSearchQuery] = useState("") const [isLoading, setIsLoading] = useState(true) const [selectedSites, setSelectedSites] = useState([]) const [showDeleteDialog, setShowDeleteDialog] = useState(false) const [isInitialLoading, setIsInitialLoading] = useState(true) const [showAddDialog, setShowAddDialog] = useState(false) const [showEditDialog, setShowEditDialog] = useState(false) const [editingSite, setEditingSite] = useState(null) const [navigationData, setNavigationData] = useState([]) const [categoryFilter, setCategoryFilter] = useState('all') const [subCategoryFilter, setSubCategoryFilter] = useState('all') const [isAddingSubmitting, setIsAddingSubmitting] = useState(false) const [isEditingSubmitting, setIsEditingSubmitting] = useState(false) const [showDeleteSiteDialog, setShowDeleteSiteDialog] = useState(false) const [deletingSite, setDeletingSite] = useState(null) const [isUploadingAddIcon, setIsUploadingAddIcon] = useState(false) const [isUploadingEditIcon, setIsUploadingEditIcon] = useState(false) const [isBatchDeleting, setIsBatchDeleting] = useState(false) const [isFetchingAddMetadata, setIsFetchingAddMetadata] = useState(false) const [isFetchingEditMetadata, setIsFetchingEditMetadata] = useState(false) const lastFetchedAddUrl = useRef('') const lastFetchedEditUrl = useRef('') const [newSite, setNewSite] = useState({ name: '', url: '', description: '', icon: '', categoryId: '', subCategoryId: '' }) const [editSite, setEditSite] = useState({ name: '', url: '', description: '', icon: '', categoryId: '', subCategoryId: '' }) useEffect(() => { console.log('useEffect triggered') fetchSites() }, []) // 监听添加站点URL变化,自动获取网站信息 useEffect(() => { const timeoutId = setTimeout(() => { if (newSite.url && isValidUrl(newSite.url) && showAddDialog && !isFetchingAddMetadata && newSite.url !== lastFetchedAddUrl.current) { lastFetchedAddUrl.current = newSite.url fetchWebsiteMetadata(newSite.url, false, true) } }, 1000) // 延迟1秒执行,避免频繁请求 return () => clearTimeout(timeoutId) }, [newSite.url, showAddDialog]) // 监听编辑站点URL变化,自动获取网站信息 useEffect(() => { const timeoutId = setTimeout(() => { if (editSite.url && isValidUrl(editSite.url) && showEditDialog && editingSite && !isFetchingEditMetadata && editSite.url !== lastFetchedEditUrl.current) { // 只有当URL与原始URL不同时才自动获取 if (editSite.url !== editingSite.url) { lastFetchedEditUrl.current = editSite.url fetchWebsiteMetadata(editSite.url, true, true) } } }, 1000) return () => clearTimeout(timeoutId) }, [editSite.url, showEditDialog, editingSite]) const extractSites = (navigationItems: Category[]): Site[] => { let allSites: Site[] = []; navigationItems.forEach((category: Category) => { // Add sites from main category items if (category.items && Array.isArray(category.items)) { const sites: Site[] = category.items.map((item: NavigationSubItem): Site => ({ id: item.id, name: item.title, url: item.href, description: item.description, createdAt: '', updatedAt: '', })); allSites = [...allSites, ...sites]; } // Add sites from subcategories if (category.subCategories && Array.isArray(category.subCategories)) { category.subCategories.forEach((subCategory: SubCategory) => { if (subCategory.items && Array.isArray(subCategory.items)) { const subSites: Site[] = subCategory.items.map((item: NavigationSubItem): Site => ({ id: item.id, name: item.title, url: item.href, description: item.description, createdAt: '', updatedAt: '', })); allSites = [...allSites, ...subSites]; } }); } }); return allSites; }; const fetchSites = async () => { if (!isInitialLoading) setIsLoading(true); try { console.log('Making API request'); const response = await fetch('/api/navigation'); console.log('API response received:', response.status); if (!response.ok) throw new Error('Failed to fetch'); const data = await response.json(); console.log('Received data:', data); // Store navigation data for category selection setNavigationData(data.navigationItems); // Extract all sites from the navigation structure const allSites = extractSites(data.navigationItems); console.log('Extracted sites:', allSites); setSites(allSites); } catch (error) { console.error('Fetch error:', error); toast({ title: "错误", description: "获取数据失败", variant: "destructive" }); setSites([]); } finally { setIsLoading(false); setIsInitialLoading(false); } }; // 获取站点所属的分类 const getSiteCategory = (siteId: string): string => { for (const category of navigationData) { // 检查主分类的items if (category.items?.some(item => item.id === siteId)) { return category.id } // 检查子分类的items if (category.subCategories) { for (const subCategory of category.subCategories) { if (subCategory.items?.some(item => item.id === siteId)) { return category.id } } } } return '' } // 获取站点所属的子分类 const getSiteSubCategory = (siteId: string): string => { for (const category of navigationData) { // 检查主分类的items - 如果在主分类中,返回空字符串表示无子分类 if (category.items?.some(item => item.id === siteId)) { return '' } // 检查子分类的items if (category.subCategories) { for (const subCategory of category.subCategories) { if (subCategory.items?.some(item => item.id === siteId)) { return subCategory.id } } } } return '' } // 获取站点的分类信息(用于显示) const getSiteCategoryInfo = (siteId: string): { categoryName: string; subCategoryName: string } => { for (const category of navigationData) { // 检查主分类的items if (category.items?.some(item => item.id === siteId)) { return { categoryName: category.title, subCategoryName: '' } } // 检查子分类的items if (category.subCategories) { for (const subCategory of category.subCategories) { if (subCategory.items?.some(item => item.id === siteId)) { return { categoryName: category.title, subCategoryName: subCategory.title } } } } } return { categoryName: '', subCategoryName: '' } } const filteredSites = sites.filter(site => { const matchesSearch = site.name.toLowerCase().includes(searchQuery.toLowerCase()) || site.url.toLowerCase().includes(searchQuery.toLowerCase()) || site.description?.toLowerCase().includes(searchQuery.toLowerCase()) const matchesCategory = categoryFilter === 'all' || getSiteCategory(site.id) === categoryFilter const matchesSubCategory = subCategoryFilter === 'all' || (subCategoryFilter === 'none' && getSiteSubCategory(site.id) === '') || getSiteSubCategory(site.id) === subCategoryFilter return matchesSearch && matchesCategory && matchesSubCategory }) // 键盘快捷键支持 useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { // Delete 键删除选中的站点 if (event.key === 'Delete' && selectedSites.length > 0 && !showDeleteDialog) { event.preventDefault() setShowDeleteDialog(true) } // Escape 键取消选择 if (event.key === 'Escape' && selectedSites.length > 0) { event.preventDefault() setSelectedSites([]) } // Ctrl+A 处理 if (event.ctrlKey && event.key === 'a') { const activeElement = document.activeElement const isInputFocused = activeElement && ( activeElement.tagName === 'INPUT' || activeElement.tagName === 'TEXTAREA' || activeElement.hasAttribute('contenteditable') ) // 如果有弹窗打开或者输入框有焦点,让浏览器处理默认行为(选中输入框内容) if (showAddDialog || showEditDialog || isInputFocused) { return // 不阻止默认行为,让输入框正常选中内容 } // 只有在主列表区域且有站点时才全选站点 if (filteredSites.length > 0) { event.preventDefault() setSelectedSites(filteredSites.map(site => site.id)) } } } document.addEventListener('keydown', handleKeyDown) return () => { document.removeEventListener('keydown', handleKeyDown) } }, [selectedSites, showDeleteDialog, filteredSites, showAddDialog, showEditDialog]) const handleSelectAll = (checked: boolean | string) => { if (checked === true) { setSelectedSites(filteredSites.map(site => site.id)) } else { setSelectedSites([]) } } const handleSelectOne = (checked: boolean | string, siteId: string) => { if (checked === true) { setSelectedSites([...selectedSites, siteId]) } else { setSelectedSites(selectedSites.filter(id => id !== siteId)) } } const handleBatchDelete = async () => { if (isBatchDeleting) return setIsBatchDeleting(true) try { // Create a copy of navigation data const updatedNavigationData = [...navigationData] // Remove all selected sites from the navigation structure for (const siteId of selectedSites) { let found = false for (let categoryIndex = 0; categoryIndex < updatedNavigationData.length; categoryIndex++) { const category = updatedNavigationData[categoryIndex] // Check main category items if (category.items) { const itemIndex = category.items.findIndex(item => item.id === siteId) if (itemIndex !== -1) { updatedNavigationData[categoryIndex].items!.splice(itemIndex, 1) found = true break } } // Check subcategory items if (category.subCategories && !found) { for (let subIndex = 0; subIndex < category.subCategories.length; subIndex++) { const subCategory = category.subCategories[subIndex] if (subCategory.items) { const itemIndex = subCategory.items.findIndex(item => item.id === siteId) if (itemIndex !== -1) { updatedNavigationData[categoryIndex].subCategories![subIndex].items.splice(itemIndex, 1) found = true break } } } } if (found) break } } // Save to API const response = await fetch('/api/navigation', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ navigationItems: updatedNavigationData }), }) if (!response.ok) throw new Error('Failed to save') toast({ title: "成功", description: `已删除选中的 ${selectedSites.length} 个站点`, }) // Refresh the sites list fetchSites() setSelectedSites([]) } catch (error) { console.error('Batch delete error:', error) toast({ title: "错误", description: "批量删除失败", variant: "destructive" }) } finally { setIsBatchDeleting(false) setShowDeleteDialog(false) } } const handleAddSite = async () => { // 防止重复提交 if (isAddingSubmitting) { return } if (!newSite.name || !newSite.url || !newSite.categoryId) { toast({ title: "错误", description: "请填写必填字段", variant: "destructive" }) return } setIsAddingSubmitting(true) try { // Create a copy of navigation data const updatedNavigationData = [...navigationData] // Find the target category const categoryIndex = updatedNavigationData.findIndex(cat => cat.id === newSite.categoryId) if (categoryIndex === -1) { throw new Error('Category not found') } const newItem: NavigationSubItem = { id: `site_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, title: newSite.name, href: newSite.url, description: newSite.description, icon: newSite.icon, enabled: true } // Add to subcategory if specified, otherwise add to main category if (newSite.subCategoryId) { const subCategoryIndex = updatedNavigationData[categoryIndex].subCategories?.findIndex( sub => sub.id === newSite.subCategoryId ) if (subCategoryIndex !== undefined && subCategoryIndex !== -1) { if (!updatedNavigationData[categoryIndex].subCategories![subCategoryIndex].items) { updatedNavigationData[categoryIndex].subCategories![subCategoryIndex].items = [] } updatedNavigationData[categoryIndex].subCategories![subCategoryIndex].items.push(newItem) } } else { if (!updatedNavigationData[categoryIndex].items) { updatedNavigationData[categoryIndex].items = [] } updatedNavigationData[categoryIndex].items.push(newItem) } // Save to API const response = await fetch('/api/navigation', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ navigationItems: updatedNavigationData }), }) if (!response.ok) throw new Error('Failed to save') toast({ title: "成功", description: "站点添加成功", }) // Reset form and close dialog setNewSite({ name: '', url: '', description: '', icon: '', categoryId: '', subCategoryId: '' }) setShowAddDialog(false) // Refresh the sites list fetchSites() } catch (error) { console.error('Add site error:', error) toast({ title: "错误", description: "添加站点失败", variant: "destructive" }) } finally { setIsAddingSubmitting(false) } } const handleEditSite = async () => { // 防止重复提交 if (isEditingSubmitting) { return } if (!editSite.name || !editSite.url || !editSite.categoryId || !editingSite) { toast({ title: "错误", description: "请填写必填字段", variant: "destructive" }) return } setIsEditingSubmitting(true) try { // Create a copy of navigation data const updatedNavigationData = [...navigationData] // Create updated item const updatedItem: NavigationSubItem = { id: editingSite.id, title: editSite.name, href: editSite.url, description: editSite.description, icon: editSite.icon, enabled: true } // Find current location and target location let currentLocation: { categoryIndex: number; subCategoryIndex?: number; itemIndex: number } | null = null let targetLocation: { categoryIndex: number; subCategoryIndex?: number } | null = null // Find current location for (let categoryIndex = 0; categoryIndex < updatedNavigationData.length; categoryIndex++) { const category = updatedNavigationData[categoryIndex] // Check main category items if (category.items) { const itemIndex = category.items.findIndex(item => item.id === editingSite.id) if (itemIndex !== -1) { currentLocation = { categoryIndex, itemIndex } break } } // Check subcategory items if (category.subCategories) { for (let subIndex = 0; subIndex < category.subCategories.length; subIndex++) { const subCategory = category.subCategories[subIndex] if (subCategory.items) { const itemIndex = subCategory.items.findIndex(item => item.id === editingSite.id) if (itemIndex !== -1) { currentLocation = { categoryIndex, subCategoryIndex: subIndex, itemIndex } break } } } if (currentLocation) break } } // Find target location const targetCategoryIndex = updatedNavigationData.findIndex(cat => cat.id === editSite.categoryId) if (targetCategoryIndex === -1) { throw new Error('Target category not found') } if (editSite.subCategoryId && editSite.subCategoryId !== "none") { const targetSubCategoryIndex = updatedNavigationData[targetCategoryIndex].subCategories?.findIndex( sub => sub.id === editSite.subCategoryId ) if (targetSubCategoryIndex !== undefined && targetSubCategoryIndex !== -1) { targetLocation = { categoryIndex: targetCategoryIndex, subCategoryIndex: targetSubCategoryIndex } } } else { targetLocation = { categoryIndex: targetCategoryIndex } } if (!currentLocation || !targetLocation) { throw new Error('Could not find current or target location') } // Check if location changed const locationChanged = currentLocation.categoryIndex !== targetLocation.categoryIndex || currentLocation.subCategoryIndex !== targetLocation.subCategoryIndex if (locationChanged) { // Remove from current location if (currentLocation.subCategoryIndex !== undefined) { updatedNavigationData[currentLocation.categoryIndex].subCategories![currentLocation.subCategoryIndex].items.splice(currentLocation.itemIndex, 1) } else { updatedNavigationData[currentLocation.categoryIndex].items!.splice(currentLocation.itemIndex, 1) } // Add to target location if (targetLocation.subCategoryIndex !== undefined) { if (!updatedNavigationData[targetLocation.categoryIndex].subCategories![targetLocation.subCategoryIndex].items) { updatedNavigationData[targetLocation.categoryIndex].subCategories![targetLocation.subCategoryIndex].items = [] } updatedNavigationData[targetLocation.categoryIndex].subCategories![targetLocation.subCategoryIndex].items.push(updatedItem) } else { if (!updatedNavigationData[targetLocation.categoryIndex].items) { updatedNavigationData[targetLocation.categoryIndex].items = [] } updatedNavigationData[targetLocation.categoryIndex].items.push(updatedItem) } } else { // Update in place (same location) if (currentLocation.subCategoryIndex !== undefined) { updatedNavigationData[currentLocation.categoryIndex].subCategories![currentLocation.subCategoryIndex].items[currentLocation.itemIndex] = updatedItem } else { updatedNavigationData[currentLocation.categoryIndex].items![currentLocation.itemIndex] = updatedItem } } // Save to API const response = await fetch('/api/navigation', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ navigationItems: updatedNavigationData }), }) if (!response.ok) throw new Error('Failed to save') toast({ title: "成功", description: "站点更新成功", }) // Reset form and close dialog setEditSite({ name: '', url: '', description: '', icon: '', categoryId: '', subCategoryId: '' }) setEditingSite(null) setShowEditDialog(false) // Refresh the sites list fetchSites() } catch (error) { console.error('Edit site error:', error) toast({ title: "错误", description: "更新站点失败", variant: "destructive" }) } finally { setIsEditingSubmitting(false) } } const openEditDialog = (site: Site) => { setEditingSite(site) // Find the category and subcategory for this site, and get the icon let categoryId = '' let subCategoryId = '' let icon = '' for (const category of navigationData) { // Check main category items const mainItem = category.items?.find(item => item.id === site.id) if (mainItem) { categoryId = category.id icon = mainItem.icon || '' break } // Check subcategory items if (category.subCategories) { for (const subCategory of category.subCategories) { const subItem = subCategory.items?.find(item => item.id === site.id) if (subItem) { categoryId = category.id subCategoryId = subCategory.id icon = subItem.icon || '' break } } if (categoryId) break } } setEditSite({ name: site.name, url: site.url, description: site.description || '', icon: icon, categoryId, subCategoryId }) setShowEditDialog(true) } const handleDeleteSite = async () => { if (!deletingSite) return try { // Create a copy of navigation data const updatedNavigationData = [...navigationData] // Find and remove the site let found = false for (let categoryIndex = 0; categoryIndex < updatedNavigationData.length; categoryIndex++) { const category = updatedNavigationData[categoryIndex] // Check main category items if (category.items) { const itemIndex = category.items.findIndex(item => item.id === deletingSite.id) if (itemIndex !== -1) { updatedNavigationData[categoryIndex].items!.splice(itemIndex, 1) found = true break } } // Check subcategory items if (category.subCategories && !found) { for (let subIndex = 0; subIndex < category.subCategories.length; subIndex++) { const subCategory = category.subCategories[subIndex] if (subCategory.items) { const itemIndex = subCategory.items.findIndex(item => item.id === deletingSite.id) if (itemIndex !== -1) { updatedNavigationData[categoryIndex].subCategories![subIndex].items.splice(itemIndex, 1) found = true break } } } } if (found) break } if (!found) { throw new Error('Site not found') } // Save to API const response = await fetch('/api/navigation', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ navigationItems: updatedNavigationData }), }) if (!response.ok) throw new Error('Failed to save') toast({ title: "成功", description: "站点删除成功", }) // Close dialog and refresh setShowDeleteSiteDialog(false) setDeletingSite(null) fetchSites() } catch (error) { console.error('Delete site error:', error) toast({ title: "错误", description: "删除站点失败", variant: "destructive" }) } } const openDeleteDialog = (site: Site) => { setDeletingSite(site) setShowDeleteSiteDialog(true) } // 描述显示组件 const DescriptionCell = ({ description }: { description?: string }) => { if (!description) return - const maxLength = 50 const isLong = description.length > maxLength const truncated = isLong ? description.substring(0, maxLength) + '...' : description if (!isLong) { return {description} } return ( {truncated}

{description}

) } const isValidUrl = (string: string): boolean => { try { new URL(string) return true } catch (_) { return false } } const fetchWebsiteMetadata = async (url: string, isEdit: boolean = false, forceUpdate: boolean = false) => { const setFetching = isEdit ? setIsFetchingEditMetadata : setIsFetchingAddMetadata const setSite = isEdit ? setEditSite : setNewSite const site = isEdit ? editSite : newSite const isFetching = isEdit ? isFetchingEditMetadata : isFetchingAddMetadata if (isFetching) return setFetching(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() // 根据forceUpdate决定是否覆盖已有信息 const updates: any = {} if (forceUpdate || !site.name) { updates.name = metadata.title } if (forceUpdate || !site.description) { updates.description = metadata.description } if ((forceUpdate || !site.icon) && metadata.icon) { updates.icon = metadata.icon } if (Object.keys(updates).length > 0) { setSite({ ...site, ...updates }) toast({ title: "成功", description: "已自动获取网站信息" }) } else { toast({ title: "提示", description: "网站信息已是最新,无需更新" }) } } catch (error) { console.error('Failed to fetch website metadata:', error) toast({ title: "提示", description: "自动获取网站信息失败,请手动填写", variant: "destructive" }) } // 确保在所有操作完成后设置loading状态为false // 使用setTimeout确保状态更新不被批处理影响 setTimeout(() => { setFetching(false) }, 0) } const handleIconUpload = async (file: File, isEdit: boolean = false) => { const setUploading = isEdit ? setIsUploadingEditIcon : setIsUploadingAddIcon const setSite = isEdit ? setEditSite : setNewSite const site = isEdit ? editSite : newSite try { setUploading(true) // 将文件转换为 base64 const base64 = await new Promise((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 }), }) if (!response.ok) { throw new Error(`上传失败: ${response.status} ${response.statusText}`) } const data = await response.json() if (data.imageUrl) { setSite({ ...site, icon: data.imageUrl }) toast({ title: "成功", description: "图标上传成功", }) } else { throw new Error('未获取到上传后的图片URL') } } catch (error) { console.error('上传失败:', error) toast({ title: "错误", description: error instanceof Error ? error.message : '上传失败,请重试', variant: "destructive" }) } finally { setUploading(false) } } return (
共 {sites.length} 个站点 {filteredSites.length !== sites.length && ( ,显示 {filteredSites.length} 个 )}
setSearchQuery(e.target.value)} className="pr-8" /> {searchQuery && ( )}
{/* 子分类筛选器 */}
{selectedSites.length > 0 && ( )} { if (open) { // 重置表单 setNewSite({ name: '', url: '', description: '', icon: '', categoryId: '', subCategoryId: '' }) lastFetchedAddUrl.current = '' setShowAddDialog(true) } else if (!isAddingSubmitting) { setShowAddDialog(false) } }}> 添加站点
setNewSite({ ...newSite, url: e.target.value })} placeholder="输入网站链接,将自动获取网站信息" disabled={isAddingSubmitting} /> {isFetchingAddMetadata && (
)}
输入完整的网站链接后,系统将自动获取网站标题、描述和图标
setNewSite({ ...newSite, name: e.target.value })} placeholder="站点名称(可自动获取)" disabled={isAddingSubmitting} />
setNewSite({ ...newSite, icon: e.target.value })} placeholder="图标URL(可自动获取)" disabled={isAddingSubmitting} /> {newSite.icon && (
图标预览 { const target = e.target as HTMLImageElement target.style.display = 'none' }} />
)}
系统会自动获取网站图标,也可手动输入URL或上传本地图片
{newSite.categoryId && navigationData.find(cat => cat.id === newSite.categoryId)?.subCategories && (
)}