diff --git a/frontend/src/components/project-tree.tsx b/frontend/src/components/project-tree.tsx index fddf6b7..298de14 100644 --- a/frontend/src/components/project-tree.tsx +++ b/frontend/src/components/project-tree.tsx @@ -1,14 +1,29 @@ 'use client' -import { useQuery } from '@tanstack/react-query' +import { useEffect, useRef, useState } from 'react' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { apiFetch } from '@/lib/api' import { useWorkspace } from '@/hooks/use-workspace' -import type { ProjectTree } from '@/lib/types' -import { useState } from 'react' +import type { Book, Chapter, ProjectTree, Scene } from '@/lib/types' + +type AddingTarget = + | { type: 'book' } + | { type: 'chapter'; bookId: number } + | { type: 'scene'; chapterId: number } + | null export function ProjectTreePanel() { const { projectId, selectedSceneId, selectScene } = useWorkspace() + const queryClient = useQueryClient() const [collapsed, setCollapsed] = useState>({}) + const [adding, setAdding] = useState(null) + const [newTitle, setNewTitle] = useState('') + const [error, setError] = useState('') + const inputRef = useRef(null) + + useEffect(() => { + if (adding) inputRef.current?.focus() + }, [adding]) const { data: tree } = useQuery({ queryKey: ['project-tree', projectId], @@ -20,6 +35,74 @@ export function ProjectTreePanel() { setCollapsed((prev) => ({ ...prev, [key]: !prev[key] })) } + const invalidateTree = () => { + queryClient.invalidateQueries({ queryKey: ['project-tree', projectId] }) + } + + const resetAdding = () => { + setAdding(null) + setNewTitle('') + } + + const onMutationError = () => { + setError('创建失败,请重试') + setTimeout(() => setError(''), 3000) + } + + const createBook = useMutation({ + mutationFn: (title: string) => + apiFetch('/api/books', { + method: 'POST', + body: JSON.stringify({ project_id: projectId, title }), + }), + onSuccess: () => { invalidateTree(); resetAdding() }, + onError: onMutationError, + }) + + const createChapter = useMutation({ + mutationFn: ({ bookId, title }: { bookId: number; title: string }) => + apiFetch('/api/chapters', { + method: 'POST', + body: JSON.stringify({ book_id: bookId, title }), + }), + onSuccess: () => { invalidateTree(); resetAdding() }, + onError: onMutationError, + }) + + const createScene = useMutation({ + mutationFn: ({ chapterId, title }: { chapterId: number; title: string }) => + apiFetch('/api/scenes', { + method: 'POST', + body: JSON.stringify({ chapter_id: chapterId, title }), + }), + onSuccess: () => { invalidateTree(); resetAdding() }, + onError: onMutationError, + }) + + const handleSubmit = () => { + const t = newTitle.trim() + if (!t || !adding) return + if (adding.type === 'book') createBook.mutate(t) + else if (adding.type === 'chapter') createChapter.mutate({ bookId: adding.bookId, title: t }) + else createScene.mutate({ chapterId: adding.chapterId, title: t }) + } + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') handleSubmit() + else if (e.key === 'Escape') resetAdding() + } + + const handleBlur = () => { + if (newTitle.trim()) handleSubmit() + else resetAdding() + } + + const startAdding = (target: AddingTarget) => { + setAdding(target) + setNewTitle('') + setError('') + } + if (!projectId) { return
请选择项目
} @@ -28,9 +111,17 @@ export function ProjectTreePanel() { return
加载中...
} + const isPending = createBook.isPending || createChapter.isPending || createScene.isPending + return (
+ {error && ( +
+ {error} +
+ )}

{tree.title}

+ {tree.books.map((book) => (
- {!collapsed[`book-${book.id}`] && book.chapters.map((chapter) => ( -
- - {!collapsed[`ch-${chapter.id}`] && chapter.scenes.map((scene) => ( + + {!collapsed[`book-${book.id}`] && ( + <> + {book.chapters.map((chapter) => ( +
+ + + {!collapsed[`ch-${chapter.id}`] && ( + <> + {chapter.scenes.map((scene) => ( + + ))} + + {/* Inline: add scene */} + {adding?.type === 'scene' && adding.chapterId === chapter.id ? ( +
+ setNewTitle(e.target.value)} + onKeyDown={handleKeyDown} + onBlur={handleBlur} + disabled={isPending} + /> +
+ ) : ( + + )} + + )} +
+ ))} + + {/* Inline: add chapter */} + {adding?.type === 'chapter' && adding.bookId === book.id ? ( +
+ setNewTitle(e.target.value)} + onKeyDown={handleKeyDown} + onBlur={() => { if (!newTitle.trim()) resetAdding() }} + disabled={isPending} + /> +
+ ) : ( - ))} -
- ))} + )} + + )}
))} + + {/* Inline: add book */} + {adding?.type === 'book' ? ( +
+ setNewTitle(e.target.value)} + onKeyDown={handleKeyDown} + onBlur={() => { if (!newTitle.trim()) resetAdding() }} + disabled={isPending} + /> +
+ ) : ( + + )}
) }