diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 28e5260..767ec64 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -1,6 +1,7 @@ 'use client' -import { useQuery, useQueryClient } from '@tanstack/react-query' +import { useEffect, useMemo, useState } from 'react' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { apiFetch } from '@/lib/api' import { useWorkspace } from '@/hooks/use-workspace' import type { Project, ProjectTree } from '@/lib/types' @@ -12,10 +13,21 @@ import { SummaryPanel } from '@/components/summary-panel' import { ExportPanel } from '@/components/export-panel' import { LorePanel } from '@/components/lore-panel' import { KGPanel } from '@/components/kg-panel' -import { useMemo, useState } from 'react' type RightTab = 'generate' | 'summary' | 'bible' | 'lore' | 'kg' | 'export' +interface BibleField { + id: number + key: string + value_md: string + locked: boolean +} + +const GENRE_OPTIONS = ['玄幻', '都市', '科幻', '历史', '言情', '悬疑', '其他'] +const STYLE_OPTIONS = ['严肃文学', '轻小说', '网文', '纯文学'] +const POV_OPTIONS = ['第一人称', '第三人称有限', '第三人称全知', '多视角'] +const TENSE_OPTIONS = ['过去式', '现在式'] + export default function WorkspacePage() { const { projectId, setProjectId, @@ -23,74 +35,154 @@ export default function WorkspacePage() { } = useWorkspace() const queryClient = useQueryClient() const [rightTab, setRightTab] = useState('generate') + const [showCreateModal, setShowCreateModal] = useState(false) - const { data: projects, isLoading: loadingProjects, error: projectsError } = useQuery({ + const { + data: projects, isLoading: loadingProjects, error: projectsError, + } = useQuery({ queryKey: ['projects'], queryFn: () => apiFetch('/api/projects'), }) const { data: tree } = useQuery({ queryKey: ['project-tree', projectId], - queryFn: () => apiFetch(`/api/projects/${projectId}/tree`), + queryFn: () => apiFetch( + `/api/projects/${projectId}/tree` + ), enabled: !!projectId, }) + const deleteMutation = useMutation({ + mutationFn: (id: number) => + apiFetch(`/api/projects/${id}`, { method: 'DELETE' }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['projects'] }) + }, + onError: () => { + alert('删除失败,请重试') + }, + }) + const { bookTitle, chapterTitle } = useMemo(() => { - if (!tree || !selectedBookId) return { bookTitle: undefined, chapterTitle: undefined } + if (!tree || !selectedBookId) + return { bookTitle: undefined, chapterTitle: undefined } const book = tree.books.find((b) => b.id === selectedBookId) - const chapter = book?.chapters.find((c) => c.id === selectedChapterId) + const chapter = book?.chapters.find( + (c) => c.id === selectedChapterId + ) return { bookTitle: book?.title, chapterTitle: chapter?.title } }, [tree, selectedBookId, selectedChapterId]) + const handleDelete = (e: React.MouseEvent, p: Project) => { + e.stopPropagation() + if (!window.confirm(`确定删除「${p.title}」?此操作不可撤销。`)) + return + deleteMutation.mutate(p.id) + } + // ---------- Project selection screen ---------- if (!projectId) { return (
-

Novel Creator

-

中文中长篇小说 AI 写作平台

+

+ Novel Creator +

+

+ 中文中长篇小说 AI 写作平台 +

{loadingProjects && (
-

加载项目列表...

+

+ 加载项目列表... +

)} {projectsError && (

无法连接后端服务

-

请确认 http://localhost:8000 正在运行

+

+ 请确认 http://localhost:8000 正在运行 +

)} {!loadingProjects && !projectsError && (
{projects?.map((p) => ( - + + +
))} + {projects?.length === 0 && (

暂无项目

-

通过 API 创建: POST /api/projects

+

+ 点击下方按钮创建你的第一部小说 +

)} + +
)}
+ + {showCreateModal && ( + setShowCreateModal(false)} + onCreated={(id) => { + setShowCreateModal(false) + queryClient.invalidateQueries({ queryKey: ['projects'] }) + setProjectId(id) + }} + /> + )}
) } @@ -227,3 +319,213 @@ function EmptyHint({ text }: { text: string }) { ) } + +// ---------- Create Project Modal ---------- + +function CreateProjectModal({ + onClose, + onCreated, +}: { + onClose: () => void + onCreated: (projectId: number) => void +}) { + const [title, setTitle] = useState('') + const [description, setDescription] = useState('') + const [genre, setGenre] = useState('') + const [style, setStyle] = useState('') + const [pov, setPov] = useState('') + const [tense, setTense] = useState('') + const [creating, setCreating] = useState(false) + const [error, setError] = useState('') + + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (e.key === 'Escape' && !creating) onClose() + } + window.addEventListener('keydown', handler) + return () => window.removeEventListener('keydown', handler) + }, [onClose, creating]) + + const handleCreate = async () => { + if (!title.trim()) { + setError('请输入小说标题') + return + } + setCreating(true) + setError('') + try { + const project = await apiFetch('/api/projects', { + method: 'POST', + body: JSON.stringify({ + title: title.trim(), + description: description.trim(), + }), + }) + + // Set bible presets if any selected + const presets: Record = {} + if (genre) presets['Genre'] = genre + if (style) presets['Style'] = style + if (pov) presets['POV'] = pov + if (tense) presets['Tense'] = tense + + if (Object.keys(presets).length > 0) { + const fields = await apiFetch( + `/api/bible?project_id=${project.id}` + ) + for (const field of fields) { + const val = presets[field.key] + if (val) { + await apiFetch(`/api/bible/${field.id}`, { + method: 'PUT', + body: JSON.stringify({ value_md: val }), + }) + } + } + } + + onCreated(project.id) + } catch { + setError('创建失败,请重试') + setCreating(false) + } + } + + return ( +
+
e.stopPropagation()} + > +

+ 新建小说 +

+ + {error && ( +
+ {error} +
+ )} + +
+
+ + setTitle(e.target.value)} + autoFocus + /> +
+ +
+ +