import { CollectionToolbar } from "@/components/layout/collection-toolbar"; import { DeleteButton } from "@/components/ui/base/buttons/delete-button"; import { CachedResourceImage } from "@/components/cached-resource-image"; import { MediaPlaceholder } from "@/components/ui/product/media-placeholder"; import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { useInfiniteQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { App, Button, Form, Input, Modal, Select } from "antd"; import { ArrowRight, BookOpenText, FileText, FolderKanban, Images, LayoutGrid, Palette, Plus, Search, Sparkles } from "lucide-react"; import { Link, useNavigate, useSearchParams } from "react-router"; import { CollectionGrid, PageHeader, WorkspacePage } from "@/components/layout/workspace-page"; import { WorkspaceErrorState, WorkspaceLoadingState, WorkspaceState } from "@/components/layout/workspace-state"; import { CanvasStylePickerModal, resolveCanvasStylePreset, resolveProjectCanvasStyle, type CanvasStylePreset } from "@/components/canvas/canvas-style-picker-modal"; import { resourceFileUrl } from "@/services/api/resources"; import { ModelPicker } from "@/components/model-picker"; import { createStyleProfileSnapshot, parseStyleProfile, serializeStyleProfile } from "@/lib/canvas/style-profile"; import { projectSummaryCompletion, projectSummaryStage } from "@/lib/project-workbench"; import { settingsPath } from "@/lib/settings-navigation"; import { PromptTemplateOperation, parseGeneratedStory, promptTemplateTaskPlaceholder, shortDramaOutlineVariables } from "@/lib/prompts"; import { runBackendGenerationTask } from "@/services/api/generation-task"; import { createProject, deleteProject, importProjectUnits, listProjects, type ProjectSummary } from "@/services/api/projects"; import { modelDisplayName, useEffectiveConfig } from "@/stores/use-config-store"; import { sourceTypeLabel } from "./detail/shared"; type ProjectForm = { name: string; aspectRatio: string; sourceType: string }; export default function ProjectsPage() { const navigate = useNavigate(); const queryClient = useQueryClient(); const { message, modal } = App.useApp(); const effectiveConfig = useEffectiveConfig(); const [createForm] = Form.useForm(); const [searchParams, setSearchParams] = useSearchParams(); const [keyword, setKeyword] = useState(""); const [status, setStatus] = useState<"all" | "active" | "archived">("all"); const [sort, setSort] = useState<"updated" | "progress" | "name">("updated"); const [storyDraft, setStoryDraft] = useState(""); const [createSource, setCreateSource] = useState<"blank" | "novel" | "text">("blank"); const [selectedStyle, setSelectedStyle] = useState(null); const [stylePickerOpen, setStylePickerOpen] = useState(false); const [generateModel, setGenerateModel] = useState(""); const [generateChapterCount, setGenerateChapterCount] = useState("5"); const [generateStructure, setGenerateStructure] = useState("单线推进"); const [generateChapterLength, setGenerateChapterLength] = useState("中"); const [generateWordCount, setGenerateWordCount] = useState("800"); const [generatePerspective, setGeneratePerspective] = useState("第三人称"); const [generateTone, setGenerateTone] = useState("平稳叙事"); const [generateCharacterScale, setGenerateCharacterScale] = useState("3-4 个"); const [generating, setGenerating] = useState(false); const [generationStatus, setGenerationStatus] = useState(""); const [generationPreview, setGenerationPreview] = useState(""); const createOpen = searchParams.get("create") === "1"; const setCreateOpen = (open: boolean) => { const next = new URLSearchParams(searchParams); if (open) next.set("create", "1"); else next.delete("create"); setSearchParams(next, { replace: true }); }; const openCreate = (source: "blank" | "novel" | "text") => { setCreateSource(source); setCreateOpen(true); }; useEffect(() => { if (!createOpen) return; createForm.setFieldsValue({ name: storyDraft.trim().slice(0, 24) || "", sourceType: createSource, aspectRatio: "9:16", }); }, [createForm, createOpen, createSource, storyDraft]); const generateStory = async () => { const story = storyDraft.trim(); if (!story || generating) return; const textModel = generateModel || effectiveConfig.textModel; if (!textModel || !effectiveConfig.textModels.includes(textModel)) { if (!textModel) { modal.warning({ title: "需要先选择文本模型", content: "请在上方“AI 模型”中选择一个已配置的文本模型,或先到设置中完成模型渠道配置。", okText: "去设置", cancelText: "取消", onOk: () => navigate(settingsPath("models")), }); } else { message.error(`模型 ${textModel} 未在文本模型列表中,请重新选择`); } return; } setGenerating(true); setGenerationStatus("正在创建项目…"); setGenerationPreview(""); try { const project = await createUniqueProjectName(story, selectedStyle); setGenerationStatus("AI 正在生成故事大纲与章节…"); const result = await runBackendGenerationTask({ projectId: project.project.id, mode: "text", prompt: promptTemplateTaskPlaceholder("短剧大纲"), config: { ...effectiveConfig, model: textModel, imageModel: textModel, videoModel: textModel, textModel }, metadata: { source: "project-story-generator", projectId: project.project.id, promptTemplateOperation: PromptTemplateOperation.ShortDramaOutline, promptTemplateVariables: shortDramaOutlineVariables({ story, chapterCount: generateChapterCount, structure: generateStructure, wordCount: generateWordCount, perspective: generatePerspective, tone: generateTone, characterScale: generateCharacterScale, chapterLength: generateChapterLength, }), }, onTextDelta: setGenerationPreview, }); const answer = result.text || ""; const parsed = parseGeneratedStory(answer); if (!parsed.chapters.length) throw new Error("AI 没有返回有效的章节内容,请重试"); setGenerationStatus(`正在导入 ${parsed.chapters.length} 个章节…`); await importProjectUnits(project.project.id, parsed.chapters.map((chapter: { title: string; content: string }) => ({ kind: "chapter", title: chapter.title, sourceText: chapter.content }))); await queryClient.invalidateQueries({ queryKey: ["projects"] }); navigate(`/projects/${project.project.id}/overview`); } catch (error) { message.error(error instanceof Error ? error.message : "AI 生成失败,请重试"); } finally { setGenerating(false); setGenerationStatus(""); setGenerationPreview(""); } }; const loadMoreRef = useRef(null); const query = useInfiniteQuery({ // 分页查询和画布页的全量项目查询不能共用缓存形状,否则两个页面会互相覆盖缓存数据。 queryKey: ["projects", "paged"], queryFn: ({ pageParam }) => listProjects({ page: pageParam, pageSize: 50 }), initialPageParam: 1, getNextPageParam: (lastPage) => (lastPage.hasMore ? lastPage.page + 1 : undefined), }); const mutation = useMutation({ mutationFn: createProject, onSuccess: ({ project }) => { setCreateOpen(false); void queryClient.invalidateQueries({ queryKey: ["projects"] }); navigate(`/projects/${project.id}/overview`); }, onError: (error) => message.error(error instanceof Error ? error.message : "项目创建失败"), }); const deleteMutation = useMutation({ mutationFn: deleteProject, onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ["projects"] }); message.success("项目已删除"); }, onError: (error) => message.error(error instanceof Error ? error.message : "项目删除失败"), }); const allProjects = useMemo(() => query.data?.pages.flatMap((page) => page.projects) || [], [query.data]); const rows = useMemo(() => { const normalizedKeyword = keyword.trim().toLowerCase(); return [...allProjects] .filter(({ project }) => status === "all" || project.status === status) .filter(({ project }) => !normalizedKeyword || `${project.name} ${project.description} ${project.stylePresetId} ${parseStyleProfile(project.styleProfileJson)?.title || resolveCanvasStylePreset(project.stylePresetId)?.title || ""}`.toLowerCase().includes(normalizedKeyword)) .sort((left, right) => { if (sort === "name") return left.project.name.localeCompare(right.project.name, "zh-CN"); if (sort === "progress") return projectSummaryCompletion(right) - projectSummaryCompletion(left); return right.project.updatedAt.localeCompare(left.project.updatedAt); }); }, [allProjects, keyword, sort, status]); const totalProjectCount = query.data?.pages[0]?.total ?? allProjects.length; useEffect(() => { const node = loadMoreRef.current; if (!node || !query.hasNextPage || query.isError) return; const observer = new IntersectionObserver( ([entry]) => { if (entry?.isIntersecting && !query.isFetchingNextPage) void query.fetchNextPage(); }, { rootMargin: "600px" }, ); observer.observe(node); return () => observer.disconnect(); }, [query.fetchNextPage, query.hasNextPage, query.isError, query.isFetchingNextPage]); const hasInitialError = query.isError && !query.data; return ( {totalProjectCount} 个项目} />

开始一部新短剧

一句话生成章节,也可以导入小说或从空白开始

展开创作
setStoryDraft(event.target.value)} placeholder="例如:一个失忆的快递员,每天收到十年前寄出的信件……" autoSize={{ minRows: 2, maxRows: 5 }} aria-label="一句话故事" /> {selectedStyle ? : null}
故事设置 · {generateChapterCount} 章 · {generatePerspective} · {generateTone}