feat(canvas): 前端重构与渠道兼容 - 重构工作区、任务中心等多页面,修复智能体对话与节点生成
Co-authored-by: sikongyue <3155157671@qq.com> Original-PR: https://github.com/ddcat-ai/open-ai-canvas/pull/217
This commit is contained in:
@@ -7,7 +7,6 @@ import { createCanvasProjectWithRemoteSync } from "@/services/user-data-sync";
|
||||
import { getProject, linkCanvasUnit } from "@/services/api/projects";
|
||||
import { WorkspacePage } from "@/components/layout/workspace-page";
|
||||
import { WorkspaceErrorState, WorkspaceLoadingState } from "@/components/layout/workspace-state";
|
||||
import { WorkspaceSignalIcon } from "@/components/ui/aceternity/workspace-signal-icon";
|
||||
import { upsertProjectChapterStoryboard } from "@/lib/canvas/project-chapter-storyboard";
|
||||
|
||||
import ProjectAssetsView from "./detail/assets";
|
||||
@@ -72,11 +71,10 @@ export default function ProjectDetailPage() {
|
||||
return (
|
||||
<WorkspacePage className="project-workbench-page !overflow-hidden" fluid>
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<header className="project-workbench-header shrink-0 border-b border-border/65 bg-background/80 px-3 py-2 backdrop-blur-md sm:px-4 lg:px-5 lg:py-0">
|
||||
<header className="project-workbench-header shrink-0 border-b border-border/65 bg-workspace-glass px-3 py-2 backdrop-blur-md sm:px-4 lg:px-5 lg:py-0">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-x-3 lg:min-h-16 lg:flex-nowrap">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2.5 lg:w-[250px] lg:flex-none xl:w-[290px]">
|
||||
<button type="button" onClick={() => navigate("/projects")} className="grid size-9 shrink-0 place-items-center rounded-md text-foreground/42 transition-colors hover:bg-foreground/[.055] hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" aria-label="返回项目" title="返回项目"><ArrowLeft className="size-4" /></button>
|
||||
<WorkspaceSignalIcon variant="projects" size="sm" />
|
||||
<button type="button" onClick={() => navigate("/projects")} className="grid size-9 shrink-0 place-items-center rounded-md text-foreground/42 transition-colors hover:bg-surface-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" aria-label="返回项目" title="返回项目"><ArrowLeft className="size-4" /></button>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<h1 className="min-w-0 truncate text-[var(--fs-body)] font-semibold text-foreground/90">{detail.data.project.name}</h1>
|
||||
<span className={`size-1.5 shrink-0 rounded-full ${detail.data.project.status === "archived" ? "bg-foreground/30" : "bg-[var(--workspace-accent)]"}`} />
|
||||
@@ -84,7 +82,7 @@ export default function ProjectDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
<nav className="thin-scrollbar order-last mt-1 flex h-11 w-full min-w-0 items-center gap-0.5 overflow-x-auto lg:order-none lg:mt-0 lg:h-16 lg:flex-1 lg:border-l lg:border-border/55 lg:pl-3" aria-label="项目导航">
|
||||
{views.map((item) => { const Icon = item.icon; const active = item.key === activeView; const href = item.key === "chapters" ? chapterHref : `/projects/${projectId}/${item.key}`; return <Link key={item.key} to={href} className={`relative flex h-11 shrink-0 items-center gap-2 rounded-md px-2.5 text-[var(--fs-body)] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring sm:px-3 ${active ? "bg-[var(--workspace-accent-soft)] font-medium text-foreground lg:after:absolute lg:after:inset-x-3 lg:after:bottom-0 lg:after:h-0.5 lg:after:rounded-full lg:after:bg-[var(--workspace-accent)]" : "text-foreground/52 hover:bg-foreground/[.045] hover:text-foreground"}`} aria-current={active ? "page" : undefined}><Icon className={`size-4 shrink-0 ${active ? "text-[var(--workspace-accent)]" : "text-foreground/45"}`} /><span className="sm:hidden">{item.shortLabel}</span><span className="hidden sm:inline">{item.label}</span></Link>; })}
|
||||
{views.map((item) => { const Icon = item.icon; const active = item.key === activeView; const href = item.key === "chapters" ? chapterHref : `/projects/${projectId}/${item.key}`; return <Link key={item.key} to={href} className={`relative flex h-11 shrink-0 items-center gap-2 rounded-md px-2.5 text-[var(--fs-body)] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring sm:px-3 ${active ? "bg-[var(--workspace-accent-soft)] font-medium text-foreground lg:after:absolute lg:after:inset-x-3 lg:after:bottom-0 lg:after:h-0.5 lg:after:rounded-full lg:after:bg-[var(--workspace-accent)]" : "text-foreground/52 hover:bg-surface-hover hover:text-foreground"}`} aria-current={active ? "page" : undefined}><Icon className={`size-4 shrink-0 ${active ? "text-[var(--workspace-accent)]" : "text-foreground/45"}`} /><span className="sm:hidden">{item.shortLabel}</span><span className="hidden sm:inline">{item.label}</span></Link>; })}
|
||||
</nav>
|
||||
<Tooltip title={activeView === "chapters" && detail.data.units.length ? "新建当前章节画布" : "新建项目画布"}><Button size="small" className="!h-9 !shrink-0 !px-2 sm:!px-3" icon={<Plus className="size-4" />} onClick={createCanvas} aria-label={activeView === "chapters" && detail.data.units.length ? "新建当前章节画布" : "新建项目画布"}><span className="hidden sm:inline">新建画布</span></Button></Tooltip>
|
||||
</div>
|
||||
@@ -92,7 +90,7 @@ export default function ProjectDetailPage() {
|
||||
{detail.data.project.status === "archived" ? <Alert type="warning" showIcon banner message="项目已归档,恢复后才能创建画布和生成任务" className="!border-x-0 !border-t-0" /> : null}
|
||||
<main className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<div className={activeView === "chapters" ? "min-h-0 flex-1" : "thin-scrollbar min-h-0 flex-1 overflow-y-auto px-3 py-5 sm:px-5 lg:px-8 lg:py-7"}>
|
||||
<div className={activeView === "overview" ? "mx-auto w-full max-w-[1200px]" : activeView === "chapters" ? "h-full w-full" : "w-full"}>
|
||||
<div className={activeView === "overview" ? "w-full" : activeView === "chapters" ? "h-full w-full" : "w-full"}>
|
||||
{activeView === "overview" ? <ProjectOverviewView detail={detail.data} refreshProject={refreshProject} onCreateCanvas={createCanvas} /> : null}
|
||||
{activeView === "chapters" ? <ProjectChaptersView detail={detail.data} refreshProject={refreshProject} onCreateCanvas={createCanvas} /> : null}
|
||||
{activeView === "canvases" ? <ProjectCanvasesView detail={detail.data} refreshProject={refreshProject} onCreateCanvas={createCanvas} /> : null}
|
||||
|
||||
@@ -6,7 +6,6 @@ import { Box, Check, ChevronDown, Download, FileText, Image as ImageIcon, Link2,
|
||||
import { WorkspaceState } from "@/components/layout/workspace-state";
|
||||
import { AssetMediaPreview } from "@/components/asset-media-preview";
|
||||
import { AssetLibraryCard, AssetLibraryCardMedia } from "@/components/assets/asset-library-card";
|
||||
import { WorkspaceSignalIcon } from "@/components/ui/aceternity/workspace-signal-icon";
|
||||
import { resolveProjectCanvasStyle } from "@/components/canvas/canvas-style-picker-modal";
|
||||
import { resourceFileUrl, resourceIdFromStorageKey } from "@/services/api/resources";
|
||||
import {
|
||||
@@ -168,7 +167,6 @@ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDet
|
||||
<div>
|
||||
<header className="flex min-h-[72px] flex-col gap-4 border-b border-border/70 pb-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<WorkspaceSignalIcon variant="assets" size="sm" />
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2.5">
|
||||
<h2 className="text-[var(--fs-heading-lg)] font-semibold leading-6">角色与资产</h2>
|
||||
@@ -187,7 +185,7 @@ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDet
|
||||
</div>
|
||||
</header>
|
||||
<div className="mt-3 grid gap-3 lg:grid-cols-[156px_minmax(0,1fr)]">
|
||||
<nav className="space-y-0.5 border-r border-border/70 pr-2" aria-label="资产分类">{categoryCounts.map((item) => <button key={item.value} type="button" onClick={() => setCategory(item.value)} className={`flex h-11 w-full items-center justify-between rounded-md px-2 text-left text-xs ${category === item.value ? "bg-foreground/[.08] font-medium" : "text-foreground/55 hover:bg-foreground/[.04]"}`}><span>{item.value === "all" ? "全部资产" : categoryLabels[item.value]}</span><span className="min-w-5 rounded bg-foreground/[.05] px-1 text-center text-[var(--fs-tiny)] tabular-nums">{item.count}</span></button>)}</nav>
|
||||
<nav className="space-y-0.5 border-r border-border/70 pr-2" aria-label="资产分类">{categoryCounts.map((item) => <button key={item.value} type="button" onClick={() => setCategory(item.value)} className={`flex h-11 w-full items-center justify-between rounded-md px-2 text-left text-xs ${category === item.value ? "bg-surface-active font-medium" : "text-foreground/55 hover:bg-surface-hover"}`}><span>{item.value === "all" ? "全部资产" : categoryLabels[item.value]}</span><span className="min-w-5 rounded bg-foreground/[.05] px-1 text-center text-[var(--fs-tiny)] tabular-nums">{item.count}</span></button>)}</nav>
|
||||
<div className="min-w-0">
|
||||
{(category === "all" || category === "character") && pendingCandidates.length ? (
|
||||
<section className="mb-4" aria-label="待确认角色">
|
||||
@@ -242,7 +240,7 @@ function CharacterEditorModal({ open, editing, form, loading, onClose, onSave }:
|
||||
type CharacterFormKey = (typeof characterFields)[number][0];
|
||||
|
||||
function MediaAssetCard({ asset, personalAsset, onOpen, onCategoryChange, onVersion, onRemove, loading }: { asset: ProjectAsset; personalAsset?: Asset; onOpen: () => void; onCategoryChange: (category: string) => void; onVersion: () => void; onRemove: () => void; loading: boolean }) {
|
||||
return <AssetLibraryCard className="project-asset-library-card"><AssetLibraryCardMedia className="relative aspect-[4/3] overflow-hidden bg-foreground/[.05]"><button type="button" className="project-asset-media-button" onClick={onOpen} aria-label={`查看资产:${asset.title}`}><AssetMediaPreview asset={personalAsset} alt={asset.title} className="h-full w-full bg-black object-cover" fallback={<div className="grid h-full place-items-center text-foreground/25"><MediaIcon kind={asset.mediaType} /></div>} /><div className="absolute inset-x-2 top-2 flex items-center justify-between"><StatusPill status={asset.status} /><span className="rounded bg-black/50 px-1.5 py-0.5 text-[var(--fs-micro)] text-white">{mediaLabel(asset.mediaType)}</span></div></button></AssetLibraryCardMedia><div className="p-2.5"><button type="button" className="project-asset-title-button" onClick={onOpen}><span className="min-w-0 truncate text-xs font-medium">{asset.title}</span><span className="shrink-0 text-[var(--fs-micro)] text-foreground/38">{formatTime(asset.updatedAt)}</span></button><div className="mt-1 flex items-center gap-1.5 text-[var(--fs-tiny)] text-foreground/42"><Dropdown trigger={["click"]} menu={{ selectedKeys: [asset.category], items: Object.entries(categoryLabels).filter(([value]) => value !== "character").map(([value, label]) => ({ key: value, label })), onClick: ({ key }) => onCategoryChange(key) }}><button type="button" disabled={loading} className="inline-flex h-6 items-center gap-1 rounded px-1.5 text-[var(--fs-tiny)] text-foreground/50 hover:bg-foreground/[.055]"><span>{categoryLabel(asset.category)}</span><ChevronDown className="size-3" /></button></Dropdown><span>·</span><span>v{Math.max(1, asset.versionCount)}</span><Link2 className="ml-auto size-3.5 shrink-0 text-[var(--workspace-accent)]" /></div><div className="mt-2 flex items-center justify-between gap-2 border-t border-border/60 pt-1.5"><span className="text-[var(--fs-micro)] text-foreground/38">{mediaLabel(asset.mediaType)}</span><div className="flex items-center"><Button type="text" size="small" icon={<RefreshCw className="size-3.5" />} loading={loading} onClick={onVersion} /><Popconfirm title="移出项目资产?" okText="移出" cancelText="取消" onConfirm={onRemove}><Button type="text" danger size="small" icon={<Trash2 className="size-3.5" />} loading={loading} /></Popconfirm></div></div></div></AssetLibraryCard>;
|
||||
return <AssetLibraryCard className="project-asset-library-card"><AssetLibraryCardMedia className="relative aspect-[4/3] overflow-hidden bg-foreground/[.05]"><button type="button" className="project-asset-media-button" onClick={onOpen} aria-label={`查看资产:${asset.title}`}><AssetMediaPreview asset={personalAsset} alt={asset.title} className="h-full w-full bg-black object-cover" fallback={<div className="grid h-full place-items-center text-foreground/25"><MediaIcon kind={asset.mediaType} /></div>} /><div className="absolute inset-x-2 top-2 flex items-center justify-between"><StatusPill status={asset.status} /><span className="rounded bg-black/50 px-1.5 py-0.5 text-[var(--fs-micro)] text-white">{mediaLabel(asset.mediaType)}</span></div></button></AssetLibraryCardMedia><div className="p-2.5"><button type="button" className="project-asset-title-button" onClick={onOpen}><span className="min-w-0 truncate text-xs font-medium">{asset.title}</span><span className="shrink-0 text-[var(--fs-micro)] text-foreground/38">{formatTime(asset.updatedAt)}</span></button><div className="mt-1 flex items-center gap-1.5 text-[var(--fs-tiny)] text-foreground/42"><Dropdown trigger={["click"]} menu={{ selectedKeys: [asset.category], items: Object.entries(categoryLabels).filter(([value]) => value !== "character").map(([value, label]) => ({ key: value, label })), onClick: ({ key }) => onCategoryChange(key) }}><button type="button" disabled={loading} className="inline-flex h-6 items-center gap-1 rounded px-1.5 text-[var(--fs-tiny)] text-foreground/50 hover:bg-surface-hover"><span>{categoryLabel(asset.category)}</span><ChevronDown className="size-3" /></button></Dropdown><span>·</span><span>v{Math.max(1, asset.versionCount)}</span><Link2 className="ml-auto size-3.5 shrink-0 text-[var(--workspace-accent)]" /></div><div className="mt-2 flex items-center justify-between gap-2 border-t border-border/60 pt-1.5"><span className="text-[var(--fs-micro)] text-foreground/38">{mediaLabel(asset.mediaType)}</span><div className="flex items-center"><Button type="text" size="small" icon={<RefreshCw className="size-3.5" />} loading={loading} onClick={onVersion} /><Popconfirm title="移出项目资产?" okText="移出" cancelText="取消" onConfirm={onRemove}><Button type="text" danger size="small" icon={<Trash2 className="size-3.5" />} loading={loading} /></Popconfirm></div></div></div></AssetLibraryCard>;
|
||||
}
|
||||
|
||||
function ProjectAssetPreviewModal({ asset, personalAsset, onClose, onDownload, onReplaceImage }: { asset: ProjectAsset | null; personalAsset?: Asset; onClose: () => void; onDownload: () => void; onReplaceImage: () => void }) {
|
||||
|
||||
@@ -61,7 +61,7 @@ export default function ProjectCanvasesView({ detail, refreshProject }: ProjectD
|
||||
<div className="flex items-center justify-between"><span className="text-[var(--fs-tiny)] font-medium text-foreground/48">关联章节</span><span className="text-[var(--fs-micro)] tabular-nums text-foreground/38">{linkedUnits.length} 个</span></div>
|
||||
<div className="mt-1.5 flex min-h-6 max-h-12 flex-wrap gap-1 overflow-y-auto">
|
||||
{linkedUnits.length ? linkedUnits.map((unit) => (
|
||||
<span key={unit!.id} className="inline-flex h-5 max-w-full items-center gap-1 rounded bg-[var(--workspace-accent-soft)] pl-1.5 pr-0.5 text-[var(--fs-micro)] text-[var(--workspace-accent)]"><span className="truncate">{String(unit!.position + 1).padStart(2, "0")} · {unit!.title}</span><Tooltip title="解除章节关联"><button type="button" className="grid size-4 shrink-0 place-items-center rounded hover:bg-foreground/[.07]" aria-label={`解除${unit!.title}关联`} onClick={() => unlinkUnitMutation.mutate({ canvasId: canvas.id, unitId: unit!.id })}><X className="size-3" /></button></Tooltip></span>
|
||||
<span key={unit!.id} className="inline-flex h-5 max-w-full items-center gap-1 rounded bg-[var(--workspace-accent-soft)] pl-1.5 pr-0.5 text-[var(--fs-micro)] text-[var(--workspace-accent)]"><span className="truncate">{String(unit!.position + 1).padStart(2, "0")} · {unit!.title}</span><Tooltip title="解除章节关联"><button type="button" className="grid size-4 shrink-0 place-items-center rounded hover:bg-surface-hover" aria-label={`解除${unit!.title}关联`} onClick={() => unlinkUnitMutation.mutate({ canvasId: canvas.id, unitId: unit!.id })}><X className="size-3" /></button></Tooltip></span>
|
||||
)) : <span className="py-0.5 text-[var(--fs-tiny)] text-foreground/38">尚未关联章节</span>}
|
||||
</div>
|
||||
<div className="mt-1.5 flex items-center gap-1.5">
|
||||
|
||||
@@ -377,7 +377,7 @@ export default function ProjectChaptersView({ detail, refreshProject, onCreateCa
|
||||
<label className="flex h-8 items-center gap-1.5 rounded-md border border-border/75 bg-background/55 px-2 focus-within:border-[var(--workspace-accent)] focus-within:ring-2 focus-within:ring-[var(--workspace-accent-soft)]">
|
||||
<Search className="size-3.5 shrink-0 text-foreground/32" />
|
||||
<input value={searchQuery} onChange={(event) => setSearchQuery(event.target.value)} onKeyDown={(event) => { if (event.key === "Enter" && visibleUnits[0]) selectChapter(visibleUnits[0].id); }} className="min-w-0 flex-1 bg-transparent text-xs outline-none placeholder:text-foreground/28" placeholder="搜索标题或章节序号" aria-label="搜索章节" />
|
||||
{searchQuery ? <button type="button" onClick={() => setSearchQuery("")} className="grid size-5 shrink-0 place-items-center rounded text-foreground/32 hover:bg-foreground/[.06]" aria-label="清空章节搜索"><X className="size-3" /></button> : null}
|
||||
{searchQuery ? <button type="button" onClick={() => setSearchQuery("")} className="grid size-5 shrink-0 place-items-center rounded text-foreground/32 hover:bg-surface-hover" aria-label="清空章节搜索"><X className="size-3" /></button> : null}
|
||||
</label>
|
||||
{deferredSearchQuery ? <div className="mt-1 px-0.5 text-[var(--fs-micro)] tabular-nums text-foreground/35">找到 {visibleUnits.length.toLocaleString("zh-CN")} 章 · 搜索时使用“移动到”调整顺序</div> : null}
|
||||
</div>
|
||||
@@ -390,14 +390,14 @@ export default function ProjectChaptersView({ detail, refreshProject, onCreateCa
|
||||
const chapterNumber = chapterNumberById.get(unit.id) || virtualItem.index + 1;
|
||||
return (
|
||||
<div key={unit.id} className="absolute left-0 top-0 w-full" style={{ height: virtualItem.size, transform: `translateY(${virtualItem.start}px)` }}>
|
||||
<div draggable={!dirty && !reorderMutation.isPending && !deferredSearchQuery} onDragStart={(event) => { setDraggedId(unit.id); event.dataTransfer.effectAllowed = "move"; }} onDragOver={(event) => { if (deferredSearchQuery) return; event.preventDefault(); event.dataTransfer.dropEffect = "move"; }} onDrop={() => moveChapter(unit.id)} onDragEnd={() => setDraggedId("")} className={`group flex h-[50px] items-start rounded-md transition-colors duration-100 ${unit.id === selectedUnit?.id ? "bg-foreground/[.08]" : "hover:bg-foreground/[.04]"} ${draggedId === unit.id ? "opacity-45" : ""}`}>
|
||||
<div draggable={!dirty && !reorderMutation.isPending && !deferredSearchQuery} onDragStart={(event) => { setDraggedId(unit.id); event.dataTransfer.effectAllowed = "move"; }} onDragOver={(event) => { if (deferredSearchQuery) return; event.preventDefault(); event.dataTransfer.dropEffect = "move"; }} onDrop={() => moveChapter(unit.id)} onDragEnd={() => setDraggedId("")} className={`group flex h-[50px] items-start rounded-md transition-colors duration-100 ${unit.id === selectedUnit?.id ? "bg-surface-active" : "hover:bg-surface-hover"} ${draggedId === unit.id ? "opacity-45" : ""}`}>
|
||||
<button type="button" disabled={Boolean(deferredSearchQuery)} className="mt-2 grid size-6 shrink-0 cursor-grab place-items-center text-foreground/25 active:cursor-grabbing disabled:cursor-default disabled:opacity-35" aria-label={`拖动第 ${chapterNumber} 章排序`}><GripVertical className="size-3.5" /></button>
|
||||
<button type="button" onClick={() => selectChapter(unit.id)} className="flex min-w-0 flex-1 items-start gap-2 px-0 py-2 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--workspace-accent)]">
|
||||
<span className={`w-8 shrink-0 pt-0.5 text-[var(--fs-tiny)] tabular-nums ${unit.id === selectedUnit?.id ? "font-semibold text-[var(--workspace-accent)]" : "text-foreground/35"}`}>{String(chapterNumber).padStart(Math.max(2, String(orderedUnits.length).length), "0")}</span>
|
||||
<span className="min-w-0 flex-1"><span className={`block truncate text-[var(--fs-body)] ${unit.id === selectedUnit?.id ? "font-medium text-foreground" : "text-foreground/65"}`}>{unit.title}</span><span className="mt-0.5 flex items-center gap-1 text-[var(--fs-tiny)] text-foreground/38"><span>{statusLabel(unit.status)}</span>{chapterCanvasCount(unit.id) ? <><span>·</span><span>{chapterCanvasCount(unit.id)} 画布</span></> : null}</span></span>
|
||||
</button>
|
||||
<Dropdown trigger={["click"]} placement="bottomRight" menu={{ items: [{ key: "move", icon: <MoveVertical className="size-3.5" />, label: "移动到…" }], onClick: () => { setMoveTargetId(unit.id); setMovePosition(chapterNumber); } }}>
|
||||
<button type="button" className="mt-2 grid size-6 shrink-0 place-items-center rounded text-foreground/28 opacity-0 hover:bg-foreground/[.06] hover:text-foreground group-hover:opacity-100 focus-visible:opacity-100" aria-label={`${unit.title}更多操作`}><MoreHorizontal className="size-3.5" /></button>
|
||||
<button type="button" className="mt-2 grid size-6 shrink-0 place-items-center rounded text-foreground/28 opacity-0 hover:bg-surface-hover hover:text-foreground group-hover:opacity-100 focus-visible:opacity-100" aria-label={`${unit.title}更多操作`}><MoreHorizontal className="size-3.5" /></button>
|
||||
</Dropdown>
|
||||
<Popconfirm title="删除此章节?" description="章节内容及关联制作记录将被删除,已关联的画布不会删除。" okText="删除" cancelText="取消" okButtonProps={{ danger: true, loading: deleteMutation.isPending }} onConfirm={() => deleteMutation.mutate(unit.id)}>
|
||||
<button type="button" className="mr-1 mt-2 grid size-6 shrink-0 place-items-center rounded text-foreground/28 opacity-0 hover:bg-red-500/10 hover:text-red-500 group-hover:opacity-100 focus-visible:opacity-100" aria-label={`删除${unit.title}`}><Trash2 className="size-3.5" /></button>
|
||||
@@ -469,7 +469,7 @@ function EditorToolbar({ editor }: { editor: Editor | null }) {
|
||||
<EditorTool editor={editor} label="重做" icon={<Redo2 className="size-3.5" />} onClick={() => editor?.chain().focus().redo().run()} />
|
||||
<ToolbarDivider />
|
||||
<Dropdown trigger={["click"]} menu={{ selectedKeys: [blockLabel], items: ["正文", "标题 1", "标题 2", "标题 3"].map((key) => ({ key, label: key })), onClick: ({ key }) => key === "正文" ? editor?.chain().focus().setParagraph().run() : editor?.chain().focus().toggleHeading({ level: Number(key.slice(-1)) as 1 | 2 | 3 }).run() }}>
|
||||
<button type="button" className="flex h-7 items-center gap-1 rounded px-2 text-xs text-foreground/60 hover:bg-foreground/[.06]" aria-label="段落格式">{blockLabel}<ChevronDown className="size-3" /></button>
|
||||
<button type="button" className="flex h-7 items-center gap-1 rounded px-2 text-xs text-foreground/60 hover:bg-surface-hover" aria-label="段落格式">{blockLabel}<ChevronDown className="size-3" /></button>
|
||||
</Dropdown>
|
||||
<EditorTool editor={editor} label="粗体" icon={<Bold className="size-3.5" />} onClick={() => editor?.chain().focus().toggleBold().run()} active={Boolean(editor?.isActive("bold"))} />
|
||||
<EditorTool editor={editor} label="斜体" icon={<Italic className="size-3.5" />} onClick={() => editor?.chain().focus().toggleItalic().run()} active={Boolean(editor?.isActive("italic"))} />
|
||||
@@ -477,21 +477,21 @@ function EditorToolbar({ editor }: { editor: Editor | null }) {
|
||||
<EditorTool editor={editor} label="删除线" icon={<Strikethrough className="size-3.5" />} onClick={() => editor?.chain().focus().toggleStrike().run()} active={Boolean(editor?.isActive("strike"))} />
|
||||
<ToolbarDivider />
|
||||
<Dropdown trigger={["click"]} menu={{ selectedKeys: [alignment], items: [{ key: "left", icon: <AlignLeft className="size-3.5" />, label: "左对齐" }, { key: "center", icon: <AlignCenter className="size-3.5" />, label: "居中" }, { key: "right", icon: <AlignRight className="size-3.5" />, label: "右对齐" }, { key: "justify", icon: <AlignJustify className="size-3.5" />, label: "两端对齐" }], onClick: ({ key }) => editor?.chain().focus().setTextAlign(key).run() }}>
|
||||
<button type="button" className="grid size-7 place-items-center rounded text-foreground/60 hover:bg-foreground/[.06]" aria-label="文字对齐">{alignmentIcon}</button>
|
||||
<button type="button" className="grid size-7 place-items-center rounded text-foreground/60 hover:bg-surface-hover" aria-label="文字对齐">{alignmentIcon}</button>
|
||||
</Dropdown>
|
||||
<EditorTool editor={editor} label="项目符号" icon={<List className="size-3.5" />} onClick={() => editor?.chain().focus().toggleBulletList().run()} active={Boolean(editor?.isActive("bulletList"))} />
|
||||
<EditorTool editor={editor} label="编号列表" icon={<ListOrdered className="size-3.5" />} onClick={() => editor?.chain().focus().toggleOrderedList().run()} active={Boolean(editor?.isActive("orderedList"))} />
|
||||
<EditorTool editor={editor} label="引用" icon={<Quote className="size-3.5" />} onClick={() => editor?.chain().focus().toggleBlockquote().run()} active={Boolean(editor?.isActive("blockquote"))} />
|
||||
<EditorTool editor={editor} label="链接" icon={<Link2 className="size-3.5" />} onClick={setLink} active={Boolean(editor?.isActive("link"))} />
|
||||
<Dropdown trigger={["click"]} placement="bottomRight" menu={{ items: [{ key: "color", icon: <span className="text-[var(--fs-label)] font-bold text-amber-600">A</span>, label: "文字颜色" }, { key: "highlight", icon: <Highlighter className="size-3.5" />, label: "高亮颜色" }, { type: "divider" }, { key: "code", icon: <Code2 className="size-3.5" />, label: "行内代码" }, { key: "rule", icon: <Minus className="size-3.5" />, label: "分隔线" }, { key: "clear", icon: <Eraser className="size-3.5" />, label: "清除格式" }], onClick: ({ key }) => { if (key === "color") setColor(); else if (key === "highlight") setHighlight(); else if (key === "code") editor?.chain().focus().toggleCode().run(); else if (key === "rule") editor?.chain().focus().setHorizontalRule().run(); else if (key === "clear") editor?.chain().focus().clearNodes().unsetAllMarks().run(); } }}>
|
||||
<button type="button" className="grid size-7 place-items-center rounded text-foreground/60 hover:bg-foreground/[.06]" aria-label="更多格式"><MoreHorizontal className="size-4" /></button>
|
||||
<button type="button" className="grid size-7 place-items-center rounded text-foreground/60 hover:bg-surface-hover" aria-label="更多格式"><MoreHorizontal className="size-4" /></button>
|
||||
</Dropdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EditorTool({ editor, label, icon, active = false, onClick }: { editor: Editor | null; label: string; icon: ReactNode; active?: boolean; onClick: () => void }) {
|
||||
return <Tooltip title={label}><button type="button" aria-label={label} className={`grid size-7 shrink-0 place-items-center rounded ${active ? "bg-foreground/[.08] text-[var(--workspace-accent)]" : "text-foreground/55 hover:bg-foreground/[.06] hover:text-foreground"}`} disabled={!editor} onClick={onClick}>{icon}</button></Tooltip>;
|
||||
return <Tooltip title={label}><button type="button" aria-label={label} className={`grid size-7 shrink-0 place-items-center rounded ${active ? "bg-surface-active text-[var(--workspace-accent)]" : "text-foreground/55 hover:bg-surface-hover hover:text-foreground"}`} disabled={!editor} onClick={onClick}>{icon}</button></Tooltip>;
|
||||
}
|
||||
|
||||
function ToolbarDivider() {
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import { useMemo, useState, type ReactNode } from "react";
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { App, Button, Form, Input, Modal, Select } from "antd";
|
||||
import { ArrowRight, BookOpenText, FolderKanban, Images, LayoutGrid, Plus, Search } from "lucide-react";
|
||||
import { ArrowRight, BookOpenText, FileText, FolderKanban, Images, LayoutGrid, Palette, Plus, Search, Sparkles, Trash2 } from "lucide-react";
|
||||
import { Link, useNavigate, useSearchParams } from "react-router";
|
||||
|
||||
import { CollectionGrid, ListToolbar, PageHeader, WorkspacePage } from "@/components/layout/workspace-page";
|
||||
import { WorkspaceErrorState, WorkspaceLoadingState, WorkspaceState } from "@/components/layout/workspace-state";
|
||||
import { resolveCanvasStylePreset } from "@/components/canvas/canvas-style-picker-modal";
|
||||
import { parseStyleProfile } from "@/lib/canvas/style-profile";
|
||||
import { CanvasStylePickerModal, resolveCanvasStylePreset, resolveProjectCanvasStyle, type CanvasStylePreset } from "@/components/canvas/canvas-style-picker-modal";
|
||||
import { ModelPicker } from "@/components/model-picker";
|
||||
import { createStyleProfileSnapshot, parseStyleProfile, serializeStyleProfile } from "@/lib/canvas/style-profile";
|
||||
import { projectSummaryCompletion, projectSummaryStage } from "@/lib/project-workbench";
|
||||
import { createProject, listProjects, type ProjectSummary } from "@/services/api/projects";
|
||||
import { settingsPath } from "@/lib/settings-navigation";
|
||||
import { requestImageQuestion } from "@/services/api/image";
|
||||
import { createProject, deleteProject, importProjectUnits, listProjects, type ProjectSummary } from "@/services/api/projects";
|
||||
import { modelDisplayName, useEffectiveConfig } from "@/stores/use-config-store";
|
||||
|
||||
import { sourceTypeLabel } from "./detail/shared";
|
||||
|
||||
@@ -18,11 +22,29 @@ type ProjectForm = { name: string; aspectRatio: string; sourceType: string };
|
||||
export default function ProjectsPage() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const { message } = App.useApp();
|
||||
const { message, modal } = App.useApp();
|
||||
const effectiveConfig = useEffectiveConfig();
|
||||
const [createForm] = Form.useForm<ProjectForm>();
|
||||
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<CanvasStylePreset | null>(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 generationAbortRef = useRef<AbortController | null>(null);
|
||||
const createOpen = searchParams.get("create") === "1";
|
||||
const setCreateOpen = (open: boolean) => {
|
||||
const next = new URLSearchParams(searchParams);
|
||||
@@ -30,6 +52,75 @@ export default function ProjectsPage() {
|
||||
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("");
|
||||
const controller = new AbortController();
|
||||
generationAbortRef.current = controller;
|
||||
try {
|
||||
const project = await createUniqueProjectName(story, selectedStyle);
|
||||
setGenerationStatus("AI 正在生成故事大纲与章节…");
|
||||
const answer = await requestImageQuestion(
|
||||
{ ...effectiveConfig, model: textModel, imageModel: textModel, videoModel: textModel, textModel },
|
||||
[
|
||||
{ role: "system", content: `你是短剧编剧。根据用户的一句话故事,生成一部短剧的标题、一句话简介和 ${generateChapterCount} 个章节。生成要求:叙事采用${generateStructure}结构,每章约 ${generateWordCount} 字,使用${generatePerspective}视角,整体基调${generateTone},主要角色约 ${generateCharacterScale},章节篇幅${generateChapterLength}。只输出一个 JSON 对象,不要输出 markdown 代码块或其他文字。JSON 结构:{"title":"剧名","synopsis":"一句话简介","chapters":[{"title":"章节标题","content":"本章情节"}]}` },
|
||||
{ role: "user", content: story },
|
||||
],
|
||||
(text) => {
|
||||
setGenerationPreview(text);
|
||||
},
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
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) {
|
||||
if (controller.signal.aborted) {
|
||||
message.info("已停止生成");
|
||||
} else {
|
||||
message.error(error instanceof Error ? error.message : "AI 生成失败,请重试");
|
||||
}
|
||||
} finally {
|
||||
generationAbortRef.current = null;
|
||||
setGenerating(false);
|
||||
setGenerationStatus("");
|
||||
setGenerationPreview("");
|
||||
}
|
||||
};
|
||||
const query = useQuery({ queryKey: ["projects"], queryFn: listProjects });
|
||||
const mutation = useMutation({
|
||||
mutationFn: createProject,
|
||||
@@ -40,6 +131,24 @@ export default function ProjectsPage() {
|
||||
},
|
||||
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 confirmDeleteProject = (projectId: string, name: string) => {
|
||||
modal.confirm({
|
||||
title: "删除项目",
|
||||
content: `确定删除「${name}」吗?项目内的章节、画布与资产将一并删除,此操作不可撤销。`,
|
||||
okText: "删除",
|
||||
okButtonProps: { danger: true, loading: deleteMutation.isPending },
|
||||
cancelText: "取消",
|
||||
onOk: () => deleteMutation.mutate(projectId),
|
||||
});
|
||||
};
|
||||
const rows = useMemo(() => {
|
||||
const normalizedKeyword = keyword.trim().toLowerCase();
|
||||
return [...(query.data?.projects || [])]
|
||||
@@ -51,17 +160,66 @@ export default function ProjectsPage() {
|
||||
return right.project.updatedAt.localeCompare(left.project.updatedAt);
|
||||
});
|
||||
}, [keyword, query.data, sort, status]);
|
||||
const canCreateProject = !keyword.trim() && status === "all";
|
||||
|
||||
return (
|
||||
<WorkspacePage className="library-page" grid>
|
||||
<PageHeader
|
||||
icon="projects"
|
||||
title="短剧创作"
|
||||
description="按时间浏览故事项目,继续最近的章节、画布与镜头。"
|
||||
meta={<span className="text-xs text-foreground/45">{rows.length} 个</span>}
|
||||
actions={null}
|
||||
/>
|
||||
<div className="studio-band">
|
||||
<PageHeader
|
||||
title="短剧创作"
|
||||
description="从故事出发,推进章节、画布与镜头。"
|
||||
meta={<span className="app-projects-header-meta">{rows.length} 个</span>}
|
||||
actions={null}
|
||||
/>
|
||||
</div>
|
||||
<section className="app-story-create-panel mt-4" aria-label="开始一部新短剧">
|
||||
<div className="app-story-create-head">
|
||||
<div className="app-story-create-title">
|
||||
<span className="app-story-create-mark"><Sparkles className="size-4" /></span>
|
||||
<div>
|
||||
<h2>开始一部新短剧</h2>
|
||||
<p>写下一句话故事,或选择一个创建方式。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="app-story-create-actions">
|
||||
<button type="button" className="app-story-create-shortcut" onClick={() => openCreate("blank")}><FolderKanban className="size-4" />空白项目</button>
|
||||
<button type="button" className="app-story-create-shortcut" onClick={() => openCreate("novel")}><FileText className="size-4" />导入小说</button>
|
||||
<button type="button" className="app-story-create-shortcut" onClick={() => setStylePickerOpen(true)}><Palette className="size-4" />{selectedStyle ? "更换画风" : "选画风"}</button>
|
||||
<ModelPicker
|
||||
config={effectiveConfig}
|
||||
value={generateModel || effectiveConfig.textModel}
|
||||
onChange={setGenerateModel}
|
||||
capability="text"
|
||||
variant="creation"
|
||||
placeholder="选择文本模型"
|
||||
showSelectedPrice={false}
|
||||
/>
|
||||
<Button type="default" icon={<Sparkles className="size-3.5" />} disabled={!storyDraft.trim() || generating} loading={generating} onClick={() => void generateStory()}>AI 生成章节</Button>
|
||||
<Button type="primary" icon={<Plus className="size-3.5" />} onClick={() => openCreate(createSource)}>开始创作</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="app-story-create-main">
|
||||
<Input.TextArea
|
||||
className="app-story-create-input"
|
||||
value={storyDraft}
|
||||
onChange={(event) => setStoryDraft(event.target.value)}
|
||||
placeholder="例如:一个失忆的快递员,每天收到十年前寄出的信件……"
|
||||
autoSize={{ minRows: 1, maxRows: 3 }}
|
||||
aria-label="一句话故事"
|
||||
/>
|
||||
{selectedStyle ? <button type="button" className="app-story-create-style-chip" onClick={() => setStylePickerOpen(true)} title={selectedStyle.title}>
|
||||
<img src={selectedStyle.imageUrl} alt="" />
|
||||
<span>{selectedStyle.title}</span>
|
||||
</button> : null}
|
||||
</div>
|
||||
<div className="app-story-create-controls">
|
||||
<label><span>章节数量</span><Select size="small" className="min-w-28" value={generateChapterCount} onChange={setGenerateChapterCount} options={[{ label: "3 章", value: "3" }, { label: "5 章", value: "5" }, { label: "8 章", value: "8" }, { label: "10 章", value: "10" }]} /></label>
|
||||
<label><span>叙事结构</span><Select size="small" className="min-w-32" value={generateStructure} onChange={setGenerateStructure} options={[{ label: "单线推进", value: "单线推进" }, { label: "双线并行", value: "双线并行" }, { label: "群像多线", value: "群像多线" }, { label: "反转嵌套", value: "反转嵌套" }]} /></label>
|
||||
<label><span>章节篇幅</span><Select size="small" className="min-w-28" value={generateChapterLength} onChange={setGenerateChapterLength} options={[{ label: "精炼", value: "短" }, { label: "均衡", value: "中" }, { label: "丰满", value: "长" }]} /></label>
|
||||
<label><span>单章字数</span><Select size="small" className="min-w-28" value={generateWordCount} onChange={setGenerateWordCount} options={[{ label: "500 字", value: "500" }, { label: "800 字", value: "800" }, { label: "1200 字", value: "1200" }, { label: "2000 字", value: "2000" }]} /></label>
|
||||
<label><span>叙述视角</span><Select size="small" className="min-w-28" value={generatePerspective} onChange={setGeneratePerspective} options={[{ label: "第三人称", value: "第三人称" }, { label: "第一人称", value: "第一人称" }, { label: "多视角", value: "多视角" }]} /></label>
|
||||
<label><span>故事基调</span><Select size="small" className="min-w-32" value={generateTone} onChange={setGenerateTone} options={[{ label: "平稳叙事", value: "平稳叙事" }, { label: "轻松喜剧", value: "轻松喜剧" }, { label: "紧张悬疑", value: "紧张悬疑" }, { label: "热血成长", value: "热血成长" }, { label: "甜宠治愈", value: "甜宠治愈" }]} /></label>
|
||||
<label><span>角色规模</span><Select size="small" className="min-w-28" value={generateCharacterScale} onChange={setGenerateCharacterScale} options={[{ label: "2 个", value: "2 个" }, { label: "3-4 个", value: "3-4 个" }, { label: "5-6 个", value: "5-6 个" }]} /></label>
|
||||
</div>
|
||||
</section>
|
||||
<ListToolbar className="library-toolbar" active={Boolean(keyword || status !== "all" || sort !== "updated")} onReset={() => { setKeyword(""); setStatus("all"); setSort("updated"); }}>
|
||||
<Input allowClear className="app-list-search" prefix={<Search className="size-4 text-foreground/40" />} value={keyword} placeholder="搜索项目、简介或画风" onChange={(event) => setKeyword(event.target.value)} />
|
||||
<Select className="w-32" value={status} onChange={setStatus} options={[{ label: "全部状态", value: "all" }, { label: "进行中", value: "active" }, { label: "已归档", value: "archived" }]} />
|
||||
@@ -70,14 +228,9 @@ export default function ProjectsPage() {
|
||||
|
||||
{query.isError ? <WorkspaceErrorState description={query.error instanceof Error ? query.error.message : "项目列表加载失败"} onRetry={() => void query.refetch()} /> : null}
|
||||
{query.isLoading ? <WorkspaceLoadingState label="正在整理项目" detail="读取章节、画布与资产进度" /> : null}
|
||||
{!query.isLoading && !query.isError && (rows.length || canCreateProject) ? (
|
||||
{!query.isLoading && !query.isError && rows.length ? (
|
||||
<CollectionGrid className="library-grid project-library-grid">
|
||||
{canCreateProject ? <button type="button" className="library-create-card" onClick={() => setCreateOpen(true)}>
|
||||
<span className="library-create-cover"><Plus className="size-8" /></span>
|
||||
<span className="library-create-title">创建短剧项目</span>
|
||||
<span className="library-create-meta">从故事、小说或空白开始</span>
|
||||
</button> : null}
|
||||
{rows.map((row) => <ProjectRow key={row.project.id} row={row} />)}
|
||||
{rows.map((row) => <ProjectRow key={row.project.id} row={row} onDelete={() => confirmDeleteProject(row.project.id, row.project.name)} />)}
|
||||
</CollectionGrid>
|
||||
) : null}
|
||||
{!query.isLoading && !rows.length && !query.isError && (keyword || status !== "all") ? (
|
||||
@@ -90,27 +243,151 @@ export default function ProjectsPage() {
|
||||
) : null}
|
||||
|
||||
<Modal className="library-modal" title="创建短剧项目" open={createOpen} footer={null} destroyOnHidden onCancel={() => setCreateOpen(false)} width={560} styles={{ body: { paddingTop: 12 } }}>
|
||||
<Form<ProjectForm> layout="vertical" initialValues={{ aspectRatio: "9:16", sourceType: "blank" }} onFinish={(values) => mutation.mutate({ ...values, type: "short-drama" })}>
|
||||
<Form<ProjectForm> form={createForm} layout="vertical" initialValues={{ aspectRatio: "9:16", sourceType: "blank" }} onFinish={(values) => mutation.mutate({ ...values, type: "short-drama", ...(selectedStyle ? { stylePresetId: selectedStyle.id, styleProfileJson: serializeStyleProfile(selectedStyle.profile || createStyleProfileSnapshot(selectedStyle)) } : {}) })}>
|
||||
<div className="mb-4 grid grid-cols-3 gap-2">
|
||||
<button type="button" className={createSource === "blank" ? "app-story-source is-active" : "app-story-source"} onClick={() => { setCreateSource("blank"); createForm.setFieldValue("sourceType", "blank"); }}><FolderKanban className="size-4" /><span>空白开始</span></button>
|
||||
<button type="button" className={createSource === "novel" ? "app-story-source is-active" : "app-story-source"} onClick={() => { setCreateSource("novel"); createForm.setFieldValue("sourceType", "novel"); }}><FileText className="size-4" /><span>导入小说</span></button>
|
||||
<button type="button" className={createSource === "text" ? "app-story-source is-active" : "app-story-source"} onClick={() => { setCreateSource("text"); createForm.setFieldValue("sourceType", "text"); }}><BookOpenText className="size-4" /><span>粘贴文本</span></button>
|
||||
</div>
|
||||
<Form.Item name="name" label="项目名称" rules={[{ required: true, whitespace: true, message: "请输入项目名称" }]}><Input autoFocus placeholder="例如:长安夜行" /></Form.Item>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Form.Item name="aspectRatio" label="默认画幅"><Select options={[{ label: "9:16 竖屏", value: "9:16" }, { label: "16:9 横屏", value: "16:9" }, { label: "1:1 方形", value: "1:1" }]} /></Form.Item>
|
||||
<Form.Item name="sourceType" label="内容来源"><Select options={[{ label: "空白开始", value: "blank" }, { label: "导入小说", value: "novel" }, { label: "粘贴文本", value: "text" }]} /></Form.Item>
|
||||
</div>
|
||||
<Form.Item label="项目画风"><button type="button" className="app-story-modal-style" onClick={() => setStylePickerOpen(true)}>{selectedStyle ? <><img src={selectedStyle.imageUrl} alt="" /><span>{selectedStyle.title}</span><em>更换</em></> : <><Palette className="size-4" /><span>选择项目画风(可选)</span></>}</button></Form.Item>
|
||||
<p className="-mt-1 mb-5 text-xs leading-5 text-foreground/48">创建后先进入项目概览。章节、画风和参考资产可以逐步补充。</p>
|
||||
<div className="flex justify-end gap-2"><Button onClick={() => setCreateOpen(false)}>取消</Button><Button type="primary" htmlType="submit" loading={mutation.isPending}>创建项目</Button></div>
|
||||
</Form>
|
||||
</Modal>
|
||||
<CanvasStylePickerModal
|
||||
open={stylePickerOpen}
|
||||
value={selectedStyle?.id}
|
||||
onClose={() => setStylePickerOpen(false)}
|
||||
onSelect={(preset) => { setSelectedStyle(preset); setStylePickerOpen(false); }}
|
||||
/>
|
||||
<Modal className="library-modal" title="AI 生成章节" open={generating} footer={null} closable={false} maskClosable={false} keyboard={false} width={760}>
|
||||
<div className="app-story-generating">
|
||||
<div className="app-story-generating-head">
|
||||
<span className="app-story-generating-mark"><Sparkles className="size-4" /></span>
|
||||
<div className="min-w-0">
|
||||
<p>AI 正在创作</p>
|
||||
<span className="block text-[var(--fs-tiny)] text-foreground/45">正在生成剧名、简介与章节</span>
|
||||
</div>
|
||||
{generateModel || effectiveConfig.textModel ? <span className="app-story-generating-model">{modelDisplayName(effectiveConfig, generateModel || effectiveConfig.textModel)}</span> : null}
|
||||
</div>
|
||||
<div className="app-story-generating-progress" aria-hidden="true" />
|
||||
<div className="app-story-generating-grid">
|
||||
<div className="app-story-generating-story">
|
||||
<span className="app-story-generating-caption">故事起点</span>
|
||||
<p>{storyDraft.trim() || "等待故事输入"}</p>
|
||||
<span className="app-story-generating-meta">{generateChapterCount} 章 · 每章约 {generateWordCount} 字 · {generateStructure} · {generatePerspective}</span>
|
||||
{selectedStyle ? <span className="app-story-generating-style"><img src={selectedStyle.imageUrl} alt="" /><span>{selectedStyle.title}</span></span> : null}
|
||||
</div>
|
||||
<ol className="app-story-generating-steps">
|
||||
{generationSteps.map((step) => {
|
||||
const state = generationStatus.startsWith(step.label) ? "is-active" : generationStepDone(step.label, generationStatus) ? "is-done" : "";
|
||||
return <li key={step.label} className={state}><span className="app-story-generating-step-dot" /><span>{step.label}</span><em>{state === "is-active" ? "进行中" : state === "is-done" ? "完成" : "等待"}</em></li>;
|
||||
})}
|
||||
</ol>
|
||||
</div>
|
||||
<div className="app-story-generating-preview">
|
||||
<div className="app-story-generating-preview-head"><span>实时草稿</span><span className="app-story-generating-live" /><em>{generationPreview ? "正在输出" : "等待模型输出"}</em></div>
|
||||
<pre>{generationPreview}</pre>
|
||||
</div>
|
||||
<div className="app-story-generating-actions"><Button onClick={() => generationAbortRef.current?.abort()}>停止</Button></div>
|
||||
</div>
|
||||
</Modal>
|
||||
</WorkspacePage>
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectRow({ row }: { row: ProjectSummary }) {
|
||||
function parseGeneratedStory(answer: string) {
|
||||
const cleaned = answer
|
||||
.replace(/```json/gi, "")
|
||||
.replace(/```/g, "")
|
||||
.trim();
|
||||
const match = cleaned.match(/\{[\s\S]*\}/);
|
||||
const payload = match ? JSON.parse(match[0]) : {};
|
||||
const title = String(payload.title || "").trim();
|
||||
const synopsis = String(payload.synopsis || "").trim();
|
||||
const chapters = Array.isArray(payload.chapters)
|
||||
? payload.chapters
|
||||
.map((chapter: unknown) => {
|
||||
const item = typeof chapter === "object" && chapter ? chapter as Record<string, unknown> : {};
|
||||
return { title: String(item.title || "").trim(), content: String(item.content || "").trim() };
|
||||
})
|
||||
.filter((chapter: { title: string; content: string }) => chapter.title && chapter.content)
|
||||
: [];
|
||||
return { title: title || storyTitleFromAnswer(answer), synopsis, chapters };
|
||||
}
|
||||
|
||||
async function createUniqueProjectName(story: string, selectedStyle: CanvasStylePreset | null) {
|
||||
const base = story.trim().slice(0, 24);
|
||||
const buildInput = (name: string) => ({
|
||||
name,
|
||||
type: "short-drama" as const,
|
||||
aspectRatio: "9:16",
|
||||
sourceType: "blank",
|
||||
description: story.trim(),
|
||||
...(selectedStyle ? { stylePresetId: selectedStyle.id, styleProfileJson: serializeStyleProfile(selectedStyle.profile || createStyleProfileSnapshot(selectedStyle)) } : {}),
|
||||
});
|
||||
let attempt = 0;
|
||||
for (;;) {
|
||||
try {
|
||||
return await createProject(buildInput(attempt === 0 ? base : `${base}(${attempt + 1})`));
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "";
|
||||
const uniqueConflict = message.includes("UNIQUE") || message.includes("projects.user_id") || message.includes("projects.name");
|
||||
if (!uniqueConflict || attempt >= 5) throw error;
|
||||
attempt += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function storyTitleFromAnswer(answer: string) {
|
||||
const line = answer.split(/\r?\n/).find((item) => item.trim());
|
||||
return line ? line.trim().slice(0, 24) : "AI 生成短剧";
|
||||
}
|
||||
|
||||
const generationSteps = [
|
||||
{ label: "正在创建项目" },
|
||||
{ label: "AI 正在生成故事大纲与章节" },
|
||||
{ label: "正在导入章节" },
|
||||
];
|
||||
|
||||
function generationStepDone(label: string, status: string) {
|
||||
if (label === "正在创建项目") return status.startsWith("AI 正在生成") || status.startsWith("正在导入");
|
||||
if (label === "AI 正在生成故事大纲与章节") return status.startsWith("正在导入");
|
||||
return false;
|
||||
}
|
||||
|
||||
function ProjectRow({ row, onDelete }: { row: ProjectSummary; onDelete: () => void }) {
|
||||
const completion = projectSummaryCompletion(row);
|
||||
const stage = projectSummaryStage(row);
|
||||
const styleTitle = parseStyleProfile(row.project.styleProfileJson)?.title || resolveCanvasStylePreset(row.project.stylePresetId)?.title || (row.project.stylePresetId ? "自定义画风" : "未设置画风");
|
||||
const projectStyle = resolveProjectCanvasStyle(row.project.stylePresetId, row.project.styleProfileJson);
|
||||
const styleTitle = projectStyle?.title || parseStyleProfile(row.project.styleProfileJson)?.title || resolveCanvasStylePreset(row.project.stylePresetId)?.title || (row.project.stylePresetId ? "自定义画风" : "未设置画风");
|
||||
const coverUrl = projectStyle?.imageUrl;
|
||||
return (
|
||||
<Link to={`/projects/${row.project.id}/overview`} className="library-card project-library-card group">
|
||||
<span className="project-library-cover"><span className="project-library-cover-icon"><FolderKanban className="size-7" /></span><span className="project-library-cover-ratio">{row.project.aspectRatio}</span><span className="project-library-cover-stage">{stage.label}</span></span>
|
||||
<span className="project-library-cover">
|
||||
{coverUrl ? <img className="project-library-cover-art" src={coverUrl} alt="" /> : <span className="project-library-cover-icon"><FolderKanban className="size-7" /></span>}
|
||||
<span className="project-library-cover-scrim" />
|
||||
<span className="project-library-cover-ratio">{row.project.aspectRatio}</span>
|
||||
<span className="project-library-cover-stage">{stage.label}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="project-library-cover-delete"
|
||||
title="删除项目"
|
||||
aria-label={`删除项目 ${row.project.name}`}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
</span>
|
||||
<span className="project-library-body">
|
||||
<span className="project-library-heading"><strong title={row.project.name}>{row.project.name}</strong>{row.project.status === "archived" ? <em>已归档</em> : null}<ArrowRight className="project-library-arrow size-4" /></span>
|
||||
<span className="project-library-subtitle">{styleTitle} · {sourceTypeLabel(row.project.sourceType)}</span>
|
||||
|
||||
Reference in New Issue
Block a user