import { useStore } from '@nanostores/react'; import * as ContextMenu from '@radix-ui/react-context-menu'; import classNames from 'classnames'; import { type Change, diffLines } from 'diff'; import { memo, type ReactNode, useCallback, useEffect, useMemo, useState } from 'react'; import { toast } from 'sonner'; import { Button } from '~/.client/components/ui/Button'; import { ConfirmationDialog, Dialog, DialogDescription, DialogRoot, DialogTitle } from '~/.client/components/ui/Dialog'; import { webBuilderStore } from '~/.client/stores/web-builder'; import type { PageMap } from '~/types/pages'; import { createScopedLogger, renderLogger } from '~/utils/logger'; const logger = createScopedLogger('PageTree'); interface PageNode { id: string; name: string; title: string; } interface Props { pages?: PageMap; selectedPage?: string; onPageSelect?: (pageName: string) => void; unsavedPages?: Set; className?: string; } export const PageTree = memo(({ pages = {}, onPageSelect, selectedPage, className, unsavedPages }: Props) => { renderLogger.trace('PageTree'); const pageList = useMemo(() => { return buildPageList(pages); }, [pages]); return (

页面列表

{pageList.map((page) => ( { onPageSelect?.(page.name); }} /> ))}
); }); export default PageTree; function ContextMenuItem({ onSelect, children }: { onSelect?: () => void; children: ReactNode }) { return ( {children} ); } function PageContextMenu({ pageName, children }: { pageName: string; children: ReactNode }) { const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); const [isDragging, setIsDragging] = useState(false); const [isLoading, setIsLoading] = useState(false); const handleDragOver = useCallback((e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); setIsDragging(true); }, []); const handleDragLeave = useCallback((e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); setIsDragging(false); }, []); const handleDrop = useCallback(async (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); setIsDragging(false); }, []); const handleCreatePage = async (pageName: string, pageTitle: string) => { setIsLoading(true); try { const success = await webBuilderStore.createPage(pageName, pageTitle); if (success) { toast.success('页面创建成功'); } else { toast.error('页面创建失败'); } } catch (error) { toast.error('页面创建失败'); logger.error(error); } finally { setIsLoading(false); } }; const handleDelete = async () => { setIsLoading(true); try { const success = await webBuilderStore.deletePage(pageName); if (success) { toast.success(`页面删除成功`); setIsDeleteDialogOpen(false); } else { toast.error(`页面删除失败`); } } catch (error) { toast.error(`页面删除失败`); logger.error(error); } finally { setIsLoading(false); } }; return ( <>
{children}
setIsCreateDialogOpen(true)}>
新建页面
setIsDeleteDialogOpen(true)}>
删除页面
setIsCreateDialogOpen(false)} onConfirm={handleCreatePage} /> setIsDeleteDialogOpen(false)} onConfirm={handleDelete} title="删除页面" description={`确定要删除页面 "${pageName}" 吗?此操作不可撤销。`} confirmLabel="删除" cancelLabel="取消" variant="destructive" isLoading={isLoading} /> ); } interface PageProps { page: PageNode; selected: boolean; unsavedChanges?: boolean; onClick: () => void; } function formatSaveTime(timestamp: number): string { const saveDate = new Date(timestamp); return `${saveDate.getHours().toString().padStart(2, '0')}:${saveDate.getMinutes().toString().padStart(2, '0')}`; } function Page({ page, onClick, selected, unsavedChanges = false }: PageProps) { const pageHistory = useStore(webBuilderStore.pagesStore.pageHistory); const { name, title } = page; const pageModifications = pageHistory[name]; const lastSavedTimes = useStore(webBuilderStore.editorStore.documentLastSaved); const lastSavedTime = lastSavedTimes[name]; const { additions, deletions } = useMemo(() => { if (!pageModifications?.originalContent) { return { additions: 0, deletions: 0 }; } const normalizedOriginal = pageModifications.originalContent.replace(/\r\n/g, '\n'); const normalizedCurrent = pageModifications.versions[pageModifications.versions.length - 1]?.content.replace(/\r\n/g, '\n') || ''; if (normalizedOriginal === normalizedCurrent) { return { additions: 0, deletions: 0 }; } const changes = diffLines(normalizedOriginal, normalizedCurrent, { newlineIsToken: false, ignoreWhitespace: true, }); return changes.reduce( (acc: { additions: number; deletions: number }, change: Change) => { if (change.added) { acc.additions += change.value.split('\n').length; } if (change.removed) { acc.deletions += change.value.split('\n').length; } return acc; }, { additions: 0, deletions: 0 }, ); }, [pageModifications]); const showStats = additions > 0 || deletions > 0; return (
{title || name}
{showStats && (
{additions > 0 && +{additions}} {deletions > 0 && -{deletions}}
)} {unsavedChanges && }
{name} {lastSavedTime && !unsavedChanges && ( {formatSaveTime(lastSavedTime)} )}
); } interface ButtonProps { iconClasses: string; children: ReactNode; className?: string; onClick?: () => void; } function NodeButton({ iconClasses, onClick, className, children }: ButtonProps) { return ( ); } function buildPageList(pages: PageMap): PageNode[] { const nodeList: PageNode[] = []; const pageList = Object.values(pages); for (const page of pageList) { if (!page) { continue; } nodeList.push({ id: page.name, name: page.name, title: page.title ?? '未命名页面', }); } return nodeList.sort((a, b) => compareNodes(a, b)); } function compareNodes(a: PageNode, b: PageNode): number { return a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' }); } interface CreatePageDialogProps { isOpen: boolean; onClose: () => void; onConfirm: (pageName: string, pageTitle: string) => void; } function CreatePageDialog({ isOpen, onClose, onConfirm }: CreatePageDialogProps) { const [pageName, setPageName] = useState(''); const [pageTitle, setPageTitle] = useState(''); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); useEffect(() => { if (isOpen) { setPageName(''); setPageTitle(''); setError(null); } }, [isOpen]); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (!pageName.trim()) { setError('页面名称不能为空'); return; } if (!/^[a-zA-Z0-9_-]+$/.test(pageName)) { setError('页面名称只能包含字母、数字、连字符和下划线'); return; } setIsLoading(true); try { await onConfirm(pageName.trim(), pageTitle.trim() || '未命名页面'); onClose(); } finally { setIsLoading(false); } }; return (
新建页面 请输入页面文件名和标题
setPageName(e.target.value)} className="w-full px-3 py-2 border border-upage-elements-borderColor rounded-md bg-upage-elements-background-depth-1 text-upage-elements-textPrimary focus:outline-none focus:ring-2 focus:ring-upage-elements-item-contentAccent" placeholder="例如:about" autoFocus />

只能包含字母、数字、连字符和下划线

setPageTitle(e.target.value)} className="w-full px-3 py-2 border border-upage-elements-borderColor rounded-md bg-upage-elements-background-depth-1 text-upage-elements-textPrimary focus:outline-none focus:ring-2 focus:ring-upage-elements-item-contentAccent" placeholder="例如:关于我们" />

如果不填写,将使用默认标题"未命名页面"

{error &&
{error}
}
); }