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:
爱笑的毛毛虫
2026-08-14 12:01:21 +08:00
committed by GitHub
parent 239c1bcd37
commit 7946625be9
37 changed files with 4599 additions and 912 deletions

View File

@@ -1,6 +1,6 @@
import { useEffect, useState, type ReactNode } from "react";
import { Button } from "antd";
import { useLocation, useNavigate } from "react-router";
import { useNavigate } from "react-router";
import { WorkspacePage } from "@/components/layout/workspace-page";
import { WorkspaceErrorState, WorkspaceLoadingState, WorkspaceState } from "@/components/layout/workspace-state";
@@ -15,16 +15,18 @@ const featureNames: Record<FeatureKey, string> = {
creditsEnabled: "积分中心",
};
let featureAvailabilityCheckedOnce = false;
export function RequireFeature({ feature, children }: { feature: FeatureKey; children: ReactNode }) {
const location = useLocation();
const navigate = useNavigate();
const features = useUserStore((state) => state.features);
const [checking, setChecking] = useState(true);
const [checking, setChecking] = useState(() => !useUserStore.getState().features[feature]);
const [error, setError] = useState("");
useEffect(() => {
if (featureAvailabilityCheckedOnce) return;
featureAvailabilityCheckedOnce = true;
let cancelled = false;
setChecking(true);
setError("");
refreshFeatureAvailability()
.catch((reason) => {
@@ -36,7 +38,7 @@ export function RequireFeature({ feature, children }: { feature: FeatureKey; chi
return () => {
cancelled = true;
};
}, [location.pathname]);
}, []);
if (checking) return <WorkspacePage><WorkspaceLoadingState label="正在确认功能状态" detail={featureNames[feature]} rows={3} /></WorkspacePage>;
if (error) return <WorkspacePage><WorkspaceErrorState title="无法确认功能状态" description={error} actionLabel="返回创作台" onRetry={() => navigate("/create", { replace: true })} /></WorkspacePage>;

View File

@@ -750,7 +750,7 @@ export function CanvasAssistantPanel({ nodes, selectedNodeIds, snapshot, project
{agentBusy ? <AgentWorkingMessage theme={theme} /> : null}
</>
) : (
<AgentChatEmptyState theme={theme} nodeCount={contextSummary.nodeCount} onSelect={setPrompt} />
<AgentChatEmptyState theme={theme} nodeCount={contextSummary.nodeCount} onSelect={(text) => { setPrompt(text); void sendMessage(text, messages); }} />
)}
</div>
)}

View File

@@ -172,8 +172,8 @@ export const CanvasLocalAgentPanel = memo(function CanvasLocalAgentPanel({ snaps
return () => clearTimeout(timer);
}, [connected, endpoint, snapshot, token]);
const sendPrompt = async () => {
const text = prompt.trim();
const sendPrompt = async (overrideText?: string) => {
const text = (overrideText ?? prompt).trim();
const files = attachments;
const requestPrompt = promptWithAttachments(text, files);
if (!connected || !requestPrompt || sending || waiting) return;
@@ -545,7 +545,7 @@ export const CanvasLocalAgentPanel = memo(function CanvasLocalAgentPanel({ snaps
) : (
<>
<div ref={listRef} className="thin-scrollbar min-h-0 flex-1 space-y-4 overflow-y-auto p-4">
{!messages.length && !pendingTool && !waiting ? <AgentChatEmptyState theme={theme} nodeCount={snapshot.nodes.length} onSelect={(prompt) => setAgentState({ prompt })} /> : null}
{!messages.length && !pendingTool && !waiting ? <AgentChatEmptyState theme={theme} nodeCount={snapshot.nodes.length} onSelect={(text) => { setAgentState({ prompt: text }); void sendPrompt(text); }} /> : null}
{messages.map((item) => (
<AgentChatMessage key={item.id} item={agentMessageToChatMessage(item)} theme={theme} user={user} />
))}

View File

@@ -3,7 +3,8 @@ import { ArrowUp, AtSign, Boxes, ChevronDown, FileText, ImageIcon, ImagePlus, Ma
import { Button, Image as AntImage, Modal, Tooltip } from "antd";
import { ModelPicker } from "@/components/model-picker";
import { configuredModelMatchesCapability, defaultConfig, modelOptionName, resolveModelChannel, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store";
import { defaultConfig, modelOptionName, resolveModelChannel, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store";
import { resolveCanvasGenerationModel } from "@/lib/canvas/canvas-project-generation";
import { CreditSymbol, requestCreditCost } from "@/constant/credits";
import { canvasThemes } from "@/lib/canvas-theme";
import { normalizeVideoDuration, normalizeVideoResolution } from "@/lib/video-generation-options";
@@ -475,8 +476,7 @@ function defaultMode(type: CanvasNodeData["type"]): CanvasNodeGenerationMode {
function buildNodeConfig(globalConfig: AiConfig, node: CanvasNodeData, mode: CanvasNodeGenerationMode): AiConfig {
const defaultModel = mode === "image" ? globalConfig.imageModel : mode === "video" ? globalConfig.videoModel : mode === "audio" ? globalConfig.audioModel : globalConfig.textModel;
const fallbackModel = mode === "image" ? defaultConfig.imageModel : mode === "video" ? defaultConfig.videoModel : mode === "audio" ? defaultConfig.audioModel : defaultConfig.textModel;
const storedModel = node.metadata?.model;
const model = storedModel && configuredModelMatchesCapability(globalConfig, storedModel, mode) ? storedModel : defaultModel && configuredModelMatchesCapability(globalConfig, defaultModel, mode) ? defaultModel : fallbackModel;
const model = resolveCanvasGenerationModel(globalConfig, node.metadata?.model, mode) || resolveCanvasGenerationModel(globalConfig, defaultModel, mode) || fallbackModel;
return {
...globalConfig,
model,

View File

@@ -51,7 +51,7 @@ export function CanvasProjectAssetModal({ open, detail, initialCategory = "all",
<div className="flex h-[min(620px,calc(100vh-80px))] min-h-[440px] flex-col overflow-hidden">
<header className="flex h-12 shrink-0 items-center justify-between border-b border-border py-0 pl-4 pr-12"><h2 className="text-sm font-semibold">引用项目角色与资产</h2><span className="text-[var(--fs-label)] text-foreground/42">已选 {selectedIds.size} 项</span></header>
<div className="grid min-h-0 flex-1 grid-rows-[auto_minmax(0,1fr)] sm:grid-cols-[150px_minmax(0,1fr)] sm:grid-rows-1">
<nav className="thin-scrollbar flex min-w-0 gap-1 overflow-x-auto border-b border-border p-2 sm:block sm:overflow-y-auto sm:border-b-0 sm:border-r" aria-label="项目资产分类">{categories.map((item) => <button key={item} type="button" onClick={() => setCategory(item)} className={`flex h-11 min-w-[104px] shrink-0 items-center justify-between rounded-md px-2 text-xs sm:w-full sm:min-w-0 ${category === item ? "bg-foreground/[.08] font-medium" : "text-foreground/55 hover:bg-foreground/[.04]"}`}><span>{categoryLabels[item] || "其他"}</span><span className="min-w-5 rounded bg-foreground/[.05] px-1 text-center text-[var(--fs-tiny)] tabular-nums">{item === "all" ? items.length : items.filter((asset) => asset.category === item).length}</span></button>)}</nav>
<nav className="thin-scrollbar flex min-w-0 gap-1 overflow-x-auto border-b border-border p-2 sm:block sm:overflow-y-auto sm:border-b-0 sm:border-r" aria-label="项目资产分类">{categories.map((item) => <button key={item} type="button" onClick={() => setCategory(item)} className={`flex h-11 min-w-[104px] shrink-0 items-center justify-between rounded-md px-2 text-xs sm:w-full sm:min-w-0 ${category === item ? "bg-surface-active font-medium" : "text-foreground/55 hover:bg-surface-hover"}`}><span>{categoryLabels[item] || "其他"}</span><span className="min-w-5 rounded bg-foreground/[.05] px-1 text-center text-[var(--fs-tiny)] tabular-nums">{item === "all" ? items.length : items.filter((asset) => asset.category === item).length}</span></button>)}</nav>
<div className="thin-scrollbar min-h-0 overflow-y-auto p-3">{visible.length ? <div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-4">{visible.map((item) => <ProjectAssetCard key={item.id} item={item} selected={selectedIds.has(item.id)} onToggle={() => toggle(item.id)} />)}</div> : <WorkspaceState icon="assets" compact className="h-full" title="此分类没有可引用资产" description={detail ? "先在项目角色与资产中完成角色确认或素材关联。" : "当前为自由画布,下方展示本地素材库;也可先上传素材后再引用。"} />}</div>
</div>
<footer className="flex h-12 shrink-0 items-center justify-between border-t border-border px-3"><span className="text-[var(--fs-tiny)] text-foreground/42">角色引用会在生成时解析当前角色版本</span><div className="flex gap-2"><Button size="small" onClick={onClose}>取消</Button><Button size="small" type="primary" disabled={!selectedIds.size} loading={inserting} onClick={() => void insert()}>引入 {selectedIds.size || ""} 项</Button></div></footer>

View File

@@ -103,10 +103,10 @@ export function CanvasProjectSidebar({ projectId, detail, onAddChapter, onLocate
if (collapsed) {
return (
<aside className="relative z-[var(--z-panel)] hidden w-11 shrink-0 flex-col items-center border-r border-border bg-background/94 py-2 backdrop-blur-xl lg:flex">
<button type="button" className="grid size-7 place-items-center rounded-md text-foreground/55 hover:bg-foreground/[.06]" title="展开项目侧栏" aria-label="展开项目侧栏" onClick={() => setCollapsed(false)}>
<button type="button" className="grid size-7 place-items-center rounded-md text-foreground/55 hover:bg-surface-hover" title="展开项目侧栏" aria-label="展开项目侧栏" onClick={() => setCollapsed(false)}>
<ChevronRight className="size-4" />
</button>
<Link to={`/projects/${projectId}/canvases`} className="mt-2 grid size-7 place-items-center rounded-md text-foreground/55 hover:bg-foreground/[.06]" title="返回项目画布列表">
<Link to={`/projects/${projectId}/canvases`} className="mt-2 grid size-7 place-items-center rounded-md text-foreground/55 hover:bg-surface-hover" title="返回项目画布列表">
<FolderKanban className="size-4" />
</Link>
</aside>
@@ -120,7 +120,7 @@ export function CanvasProjectSidebar({ projectId, detail, onAddChapter, onLocate
<FolderKanban className="size-3.5 shrink-0" />
<span className="truncate">{projectDetail?.project.name || "项目空间"}</span>
</Link>
<button type="button" className="grid size-7 shrink-0 place-items-center rounded-md text-foreground/45 hover:bg-foreground/[.06]" title="收起项目侧栏" aria-label="收起项目侧栏" onClick={() => setCollapsed(true)}>
<button type="button" className="grid size-7 shrink-0 place-items-center rounded-md text-foreground/45 hover:bg-surface-hover" title="收起项目侧栏" aria-label="收起项目侧栏" onClick={() => setCollapsed(true)}>
<ChevronLeft className="size-4" />
</button>
</header>
@@ -131,7 +131,7 @@ export function CanvasProjectSidebar({ projectId, detail, onAddChapter, onLocate
<Palette className="size-3.5" />
项目画风
</span>
<Link to={`/projects/${projectId}/settings`} className="grid size-6 place-items-center rounded text-foreground/35 hover:bg-foreground/[.06] hover:text-foreground" title="编辑项目画风" aria-label="编辑项目画风">
<Link to={`/projects/${projectId}/settings`} className="grid size-6 place-items-center rounded text-foreground/35 hover:bg-surface-hover hover:text-foreground" title="编辑项目画风" aria-label="编辑项目画风">
<Settings2 className="size-3.5" />
</Link>
</div>
@@ -139,7 +139,7 @@ export function CanvasProjectSidebar({ projectId, detail, onAddChapter, onLocate
type="button"
disabled={!style}
onClick={onLocateStyle}
className="group flex h-11 w-full items-center gap-2 rounded-md px-1.5 text-left hover:bg-foreground/[.045] disabled:cursor-default disabled:hover:bg-transparent"
className="group flex h-11 w-full items-center gap-2 rounded-md px-1.5 text-left hover:bg-surface-hover disabled:cursor-default disabled:hover:bg-transparent"
title={style ? "定位画布中的项目画风节点" : "项目尚未设置画风"}
>
{style ? (
@@ -164,7 +164,7 @@ export function CanvasProjectSidebar({ projectId, detail, onAddChapter, onLocate
剧情章节 <span className="tabular-nums text-foreground/32">{orderedUnits.length.toLocaleString("zh-CN")}</span>
</span>
{selectedId ? (
<button type="button" onClick={revealSelectedChapter} className="grid size-6 place-items-center rounded text-foreground/35 hover:bg-foreground/[.06] hover:text-foreground" title="回到当前章节" aria-label="回到当前章节">
<button type="button" onClick={revealSelectedChapter} className="grid size-6 place-items-center rounded text-foreground/35 hover:bg-surface-hover hover:text-foreground" title="回到当前章节" aria-label="回到当前章节">
<Crosshair className="size-3.5" />
</button>
) : null}
@@ -183,7 +183,7 @@ export function CanvasProjectSidebar({ projectId, detail, onAddChapter, onLocate
aria-label="搜索项目章节"
/>
{query ? (
<button type="button" onClick={() => setQuery("")} className="grid size-5 shrink-0 place-items-center rounded text-foreground/32 hover:bg-foreground/[.06] hover:text-foreground" aria-label="清空章节搜索">
<button type="button" onClick={() => setQuery("")} className="grid size-5 shrink-0 place-items-center rounded text-foreground/32 hover:bg-surface-hover hover:text-foreground" aria-label="清空章节搜索">
<X className="size-3" />
</button>
) : null}
@@ -208,7 +208,7 @@ export function CanvasProjectSidebar({ projectId, detail, onAddChapter, onLocate
event.dataTransfer.setData(CANVAS_PROJECT_CHAPTER_DND_TYPE, JSON.stringify(payload));
event.dataTransfer.effectAllowed = "copy";
}}
className={`group flex h-9 cursor-grab items-center gap-1 rounded-md px-1 text-xs active:cursor-grabbing ${active ? "bg-foreground/[.08]" : "hover:bg-foreground/[.045]"}`}
className={`group flex h-9 cursor-grab items-center gap-1 rounded-md px-1 text-xs active:cursor-grabbing ${active ? "bg-surface-active" : "hover:bg-surface-hover"}`}
>
<GripVertical className="size-3.5 shrink-0 text-foreground/22" />
<button
@@ -295,7 +295,7 @@ function ChapterPreview({
<div className="text-[var(--fs-tiny)] tabular-nums text-foreground/38">第 {chapterNumber.toLocaleString("zh-CN")} 章</div>
<h2 className="mt-0.5 truncate text-sm font-semibold">{unit.title}</h2>
</div>
<button type="button" onClick={onClose} className="grid size-6 shrink-0 place-items-center rounded text-foreground/38 hover:bg-foreground/[.06] hover:text-foreground" aria-label="关闭章节预览">
<button type="button" onClick={onClose} className="grid size-6 shrink-0 place-items-center rounded text-foreground/38 hover:bg-surface-hover hover:text-foreground" aria-label="关闭章节预览">
<X className="size-3.5" />
</button>
</header>
@@ -312,7 +312,7 @@ function ChapterPreview({
>
{adding ? <LoaderCircle className="size-3.5 animate-spin" /> : <Plus className="size-3.5" />}添加到画布
</button>
<Link to={`/projects/${projectId}/chapters/${unit.id}`} className="inline-flex h-8 items-center gap-1.5 rounded-md px-2.5 text-xs text-foreground/62 hover:bg-foreground/[.06] hover:text-foreground">
<Link to={`/projects/${projectId}/chapters/${unit.id}`} className="inline-flex h-8 items-center gap-1.5 rounded-md px-2.5 text-xs text-foreground/62 hover:bg-surface-hover hover:text-foreground">
打开编辑器
<ArrowUpRight className="size-3.5" />
</Link>

View File

@@ -115,6 +115,7 @@ export function CanvasScriptNodeContent({ node, batch, pipeline, scale, mentionR
? `${node.metadata.taskStage || "正在创建任务"}${typeof node.metadata.taskProgress === "number" ? ` · ${node.metadata.taskProgress}%` : ""}`
: node.metadata?.status === "error" ? generationErrorMessage(node.metadata.errorDetails) : "";
const [batchDetailsOpen, setBatchDetailsOpen] = useState(false);
const [moreMenuOpen, setMoreMenuOpen] = useState(false);
const pipelineDisabled = !rows.length || node.metadata?.status === "loading" || hasActiveBatchItems;
const missingImages = Math.max(0, pipeline.images.total - pipeline.images.created);
const missingVideos = Math.max(0, pipeline.videos.total - pipeline.videos.created);
@@ -164,9 +165,9 @@ export function CanvasScriptNodeContent({ node, batch, pipeline, scale, mentionR
<span className="min-w-0 flex-1 truncate text-sm font-semibold" title={node.title || "分镜脚本"}>{node.title || "分镜脚本"}</span>
{batchSummary ? <span className="min-w-0 max-w-[42%] truncate text-[var(--fs-label)] font-medium" title={batchSummary} style={{ color: batch?.status === "partial_failed" ? theme.accent.danger : theme.node.muted }}>{batchSummary}</span> : taskFeedback ? <span className="min-w-0 max-w-[38%] truncate text-[var(--fs-label)] font-medium" title={taskFeedback} style={{ color: node.metadata?.status === "error" ? theme.accent.danger : theme.node.muted }}>{taskFeedback}</span> : null}
<span className="text-[var(--fs-caption)] font-semibold tabular-nums" style={{ color: theme.node.muted }}>{rows.length} 镜 · {totalDuration}s</span>
<Tooltip title="全屏编辑"><button type="button" className="grid size-7 place-items-center rounded outline-none transition hover:bg-black/5 focus-visible:ring-2 dark:hover:bg-white/10" style={{ "--tw-ring-color": theme.node.muted } as CSSProperties} onMouseDown={(event) => event.stopPropagation()} onClick={(event) => { event.stopPropagation(); onOpen(); }} aria-label="全屏编辑"><Expand className="size-3.5" /></button></Tooltip>
<Dropdown menu={{ items: moreMenuItems }} trigger={["click"]} placement="bottomRight">
<button type="button" className="grid size-7 place-items-center rounded outline-none transition hover:bg-black/5 focus-visible:ring-2 dark:hover:bg-white/10" style={{ "--tw-ring-color": theme.node.muted } as CSSProperties} onMouseDown={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()} aria-label="更多操作"><MoreHorizontal className="size-3.5" /></button>
<Tooltip title="全屏编辑"><button type="button" className="grid size-7 place-items-center rounded outline-none transition hover:bg-black/5 focus-visible:ring-2 dark:hover:bg-white/10" style={{ "--tw-ring-color": theme.node.muted } as CSSProperties} onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()} onClick={(event) => { event.stopPropagation(); onOpen(); }} aria-label="全屏编辑"><Expand className="size-3.5" /></button></Tooltip>
<Dropdown open={moreMenuOpen} onOpenChange={setMoreMenuOpen} menu={{ items: moreMenuItems, onClick: () => setMoreMenuOpen(false) }} trigger={["click"]} placement="bottomRight">
<button type="button" className="grid size-7 place-items-center rounded outline-none transition hover:bg-black/5 focus-visible:ring-2 dark:hover:bg-white/10" style={{ "--tw-ring-color": theme.node.muted } as CSSProperties} onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()} onClick={(event) => { event.stopPropagation(); setMoreMenuOpen(true); }} aria-label="更多操作"><MoreHorizontal className="size-3.5" /></button>
</Dropdown>
</div>
{batch ? <Modal title="批次详情" open={batchDetailsOpen} onCancel={() => setBatchDetailsOpen(false)} footer={null} width={560} centered destroyOnHidden>

View File

@@ -1,9 +1,10 @@
import { Infinity as InfinityIcon, PanelLeftClose, PanelLeftOpen } from "lucide-react";
import { Fragment, useEffect, useRef, useState, type ReactNode } from "react";
import { Home, Infinity as InfinityIcon } from "lucide-react";
import { Fragment, useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
import { Link, useLocation, useNavigate } from "react-router";
import { ModelSetupGuide } from "@/components/layout/model-setup-guide";
import { WorkspaceSidebarFooter } from "@/components/layout/workspace-sidebar-footer";
import { WorkspaceTopBar } from "@/components/layout/workspace-top-bar";
import { navigationTools, type NavigationToolSlug } from "@/constant/navigation-tools";
import { cn } from "@/lib/utils";
import { refreshFeatureAvailability } from "@/lib/user-session";
@@ -16,12 +17,14 @@ export function AppWorkspaceShell({ children }: { children: ReactNode }) {
const user = useUserStore((state) => state.user);
const features = useUserStore((state) => state.features);
const [mobileSidebarExpanded, setMobileSidebarExpanded] = useState(false);
const [desktopSidebarCollapsed, setDesktopSidebarCollapsed] = useState(false);
const scrollRef = useRef<HTMLElement>(null);
const [scrollState, setScrollState] = useState({
hasTopFade: false,
hasBottomFade: false,
});
const hideChrome = pathname.startsWith("/admin") || /^\/canvas\/[^/]+/.test(pathname);
const showGlobalTopBar = !hideChrome;
const spatialWorkbench = isSpatialWorkbenchPath(pathname);
const creationWorkspace = pathname === "/create";
const slug = pathname.split("/").filter(Boolean)[0];
@@ -33,6 +36,39 @@ export function AppWorkspaceShell({ children }: { children: ReactNode }) {
if (tool.slug === "wallet") return features.creditsEnabled;
return true;
});
const topRailNavTools = visibleNavigationTools.filter((tool) => tool.section === "创作空间" || tool.slug === "skills");
const bottomRailNavTools = visibleNavigationTools.filter((tool) => tool.slug === "wallet");
const toggleSidebar = () => {
if (window.innerWidth < 1024) {
setMobileSidebarExpanded((current) => !current);
return;
}
setDesktopSidebarCollapsed((current) => !current);
};
const handleNavClick = () => {
if (window.innerWidth < 1024) setMobileSidebarExpanded(false);
};
const renderSidebarLink = (tool: (typeof navigationTools)[number]) => {
const Icon = tool.icon;
const active = tool.slug === activeToolSlug;
return (
<Link
key={tool.slug}
to={`/${tool.slug}`}
title={tool.label}
onClick={handleNavClick}
className={cn(
"app-workspace-nav-link app-workspace-rail-tile grid size-10 place-items-center rounded-md text-[var(--fs-tiny)] transition-colors",
active ? "is-active font-medium" : "",
)}
>
<Icon className="size-5 shrink-0" />
</Link>
);
};
const handleScroll = () => {
const element = scrollRef.current;
@@ -72,84 +108,89 @@ export function AppWorkspaceShell({ children }: { children: ReactNode }) {
return (
<>
<div className={cn("app-workspace-shell flex h-dvh min-h-0 w-full overflow-hidden", spatialWorkbench && "is-spatial", creationWorkspace && "is-creation-workspace")}>
<div className={cn("app-workspace-shell flex h-dvh min-h-0 w-full flex-col overflow-hidden", spatialWorkbench && "is-spatial", creationWorkspace && "is-creation-workspace")} data-sidebar-collapsed={desktopSidebarCollapsed || undefined}>
{!hideChrome && mobileSidebarExpanded ? <button type="button" className="app-workspace-sidebar-scrim lg:hidden" aria-label="收起侧栏" onClick={() => setMobileSidebarExpanded(false)} /> : null}
{showGlobalTopBar ? <WorkspaceTopBar sidebarOpen={window.innerWidth < 1024 ? mobileSidebarExpanded : !desktopSidebarCollapsed} onToggleSidebar={toggleSidebar} /> : null}
<div className="app-workspace-main-row flex min-h-0 min-w-0 flex-1 overflow-hidden">
{!hideChrome ? (
<aside className={cn("app-workspace-sidebar flex shrink-0 flex-col overflow-hidden transition-all duration-200", mobileSidebarExpanded ? "is-mobile-expanded w-[196px]" : "w-0 lg:w-[88px] lg:shrink-0")}>
<div
className={cn(
"flex h-14 shrink-0 items-center border-b border-border/55 text-foreground",
mobileSidebarExpanded ? "gap-2 px-3" : "justify-center",
"lg:justify-center lg:px-0",
)}
>
<Link to="/" className={cn("min-w-0 items-center gap-2", mobileSidebarExpanded || spatialWorkbench ? "flex" : "hidden", "lg:flex")} title="影策">
<span className="app-workspace-brand-mark grid size-7 shrink-0 place-items-center rounded-md bg-foreground text-background"><InfinityIcon className="size-4" /></span>
{spatialWorkbench ? <span className="min-w-0"><span className="block truncate text-[var(--fs-body)] font-semibold">影策</span><span className="block truncate text-[var(--fs-micro)] text-foreground/36">AI 叙事工作台</span></span> : <span className="truncate text-[var(--fs-body)] font-semibold">影策</span>}
</Link>
<aside className={cn("app-workspace-sidebar flex shrink-0 flex-col overflow-hidden transition-all duration-200", mobileSidebarExpanded ? "is-mobile-expanded w-[196px]" : "w-0 lg:w-[var(--workspace-sidebar-width)] lg:shrink-0")} style={{ "--workspace-sidebar-width": desktopSidebarCollapsed ? "0px" : "64px" } as CSSProperties}>
{/* 桌面:64px 电影胶片条式轨道 */}
<div className="app-workspace-rail hidden min-h-0 flex-col overflow-y-auto lg:flex">
<div className="app-workspace-rail-header flex h-16 shrink-0 items-center justify-center">
<Link to="/home" className="grid size-10 shrink-0 place-items-center rounded-md text-foreground/55 transition-colors hover:bg-surface-hover hover:text-foreground" title="工作台首页">
<Home className="size-4" />
</Link>
</div>
<div className="app-workspace-rail-middle flex min-h-0 w-full flex-1 flex-col items-center justify-start overflow-y-auto px-3 pt-3">
<nav className="flex w-full flex-col items-center gap-2">
{topRailNavTools.map((tool) => renderSidebarLink(tool))}
</nav>
<nav className="mt-4 flex w-full flex-col items-center gap-2 pb-3">
{bottomRailNavTools.map((tool) => renderSidebarLink(tool))}
</nav>
</div>
<div className="app-workspace-rail-footer shrink-0 px-2 pb-4 pt-3">
<WorkspaceSidebarFooter
showAnnouncement={false}
expandedClassName="hidden"
collapsedClassName="flex h-10 w-10 items-center justify-center rounded-md"
accountClassName="justify-center rounded-md"
/>
</div>
</div>
<nav
ref={scrollRef}
onScroll={handleScroll}
className={cn(
"app-workspace-sidebar-scroll-area flex min-h-0 flex-1 flex-col px-2 py-3",
scrollState.hasTopFade && "has-top-fade",
scrollState.hasBottomFade && "has-bottom-fade",
)}
>
{visibleNavigationTools.map((tool, index) => {
const Icon = tool.icon;
const active = tool.slug === activeToolSlug;
const showSection = index === 0 || tool.section !== visibleNavigationTools[index - 1]?.section;
return (
<Fragment key={tool.slug}>
{showSection ? <div className={cn("mb-2 px-2 text-[var(--fs-tiny)] font-medium text-foreground/34", index > 0 && "mt-4", mobileSidebarExpanded ? "block" : "hidden", "lg:hidden")}>{tool.section}</div> : null}
<Link
to={`/${tool.slug}`}
title={tool.label}
onClick={() => {
if (window.innerWidth < 1024) setMobileSidebarExpanded(false);
}}
className={cn(
"app-workspace-nav-link relative mb-1 flex shrink-0 items-center rounded-md text-[var(--fs-body)] transition-colors",
spatialWorkbench ? "h-11" : "h-9",
mobileSidebarExpanded ? "gap-3 px-2.5" : "justify-center px-0",
"lg:justify-center lg:px-0",
active ? "is-active font-medium" : "text-foreground/55 hover:bg-foreground/[0.045] hover:text-foreground/85",
)}
>
<Icon className="size-4 shrink-0" />
<span className={cn("truncate", mobileSidebarExpanded ? "inline" : "hidden", "lg:inline")}>{tool.label}</span>
</Link>
</Fragment>
);
})}
</nav>
<div className="shrink-0 border-t border-border/55 p-2">
<WorkspaceSidebarFooter
collapsedClassName={cn(
mobileSidebarExpanded ? "justify-start gap-2 px-2" : "justify-center gap-0 px-0",
"lg:justify-center lg:gap-0 lg:px-0",
{/* 移动端:抽屉完整导航 */}
<div className="app-workspace-mobile-nav flex min-h-0 flex-1 flex-col overflow-hidden lg:hidden">
<div className="flex h-14 shrink-0 items-center gap-2 border-b border-border/55 px-3">
<Link to="/" className="flex min-w-0 items-center gap-2" title="影策">
<span className="app-workspace-brand-mark grid size-7 shrink-0 place-items-center rounded-md bg-foreground text-background"><InfinityIcon className="size-4" /></span>
<span className="truncate text-[var(--fs-body)] font-semibold">影策</span>
</Link>
</div>
<nav
ref={scrollRef}
onScroll={handleScroll}
className={cn(
"app-workspace-sidebar-scroll-area flex min-h-0 flex-1 flex-col px-2 py-3",
scrollState.hasTopFade && "has-top-fade",
scrollState.hasBottomFade && "has-bottom-fade",
)}
expandedClassName={cn(mobileSidebarExpanded ? "flex" : "hidden", "lg:hidden")}
accountClassName={cn(
mobileSidebarExpanded ? "flex-row gap-2 px-2" : "flex-col gap-0.5 px-0 py-1",
"lg:flex-col lg:gap-0.5 lg:px-0 lg:py-1",
)}
/>
>
{visibleNavigationTools.map((tool, index) => {
const Icon = tool.icon;
const active = tool.slug === activeToolSlug;
const showSection = index === 0 || tool.section !== visibleNavigationTools[index - 1]?.section;
return (
<Fragment key={tool.slug}>
{showSection ? <div className="mb-2 px-2 text-[var(--fs-tiny)] font-medium text-foreground/34">{tool.section}</div> : null}
<Link
to={`/${tool.slug}`}
title={tool.label}
onClick={handleNavClick}
className={cn("app-workspace-nav-link relative mb-1 flex h-11 shrink-0 items-center gap-3 rounded-md px-2.5 text-[var(--fs-body)] transition-colors", active ? "is-active font-medium" : "text-foreground/55 hover:bg-surface-hover hover:text-foreground/85")}
>
<Icon className="size-4 shrink-0" />
<span className="truncate">{tool.label}</span>
</Link>
</Fragment>
);
})}
</nav>
<div className="shrink-0 border-t border-border/55 p-2">
<WorkspaceSidebarFooter
expandedClassName="flex"
collapsedClassName="hidden"
accountClassName="flex-row gap-2 px-2"
/>
</div>
</div>
</aside>
) : null}
<div className="app-workspace-stage relative min-h-0 min-w-0 flex-1 overflow-hidden">
{!hideChrome ? (
<button type="button" className="app-workspace-sidebar-toggle absolute left-2 top-2 z-30 grid size-8 place-items-center rounded-md border border-border/60 bg-background/75 text-foreground/80 backdrop-blur transition hover:bg-foreground/[0.06] lg:hidden" aria-label={mobileSidebarExpanded ? "收起侧栏" : "展开侧栏"} onClick={() => setMobileSidebarExpanded((current) => !current)}>
{mobileSidebarExpanded ? <PanelLeftClose className="size-4" /> : <PanelLeftOpen className="size-4" />}
</button>
) : null}
{children}
</div>
</div>
</div>
<ModelSetupGuide hidden={pathname === "/login" || pathname === "/register" || pathname.startsWith("/admin")} />
</>

View File

@@ -2,7 +2,6 @@ import { Button, Pagination } from "antd";
import { RotateCcw } from "lucide-react";
import type { ReactNode } from "react";
import { WorkspaceSignalIcon, type WorkspaceSignalIconVariant } from "@/components/ui/aceternity/workspace-signal-icon";
import { cn } from "@/lib/utils";
export function WorkspacePage({ children, className, grid = false, fluid = false }: { children: ReactNode; className?: string; grid?: boolean; fluid?: boolean }) {
@@ -13,14 +12,13 @@ export function WorkspacePage({ children, className, grid = false, fluid = false
);
}
export function PageHeader({ title, description, meta, actions, icon }: { title: string; description?: string; meta?: ReactNode; actions?: ReactNode; icon?: WorkspaceSignalIconVariant }) {
export function PageHeader({ title, description, meta, actions }: { title: string; description?: string; meta?: ReactNode; actions?: ReactNode }) {
return (
<header className="app-page-header flex min-h-14 flex-col gap-3 border-b border-border/80 pb-4 sm:flex-row sm:items-center sm:justify-between">
<header className="app-page-header flex min-h-14 flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex min-w-0 items-center gap-3">
{icon ? <WorkspaceSignalIcon variant={icon} /> : null}
<div className="min-w-0">
<div className="flex min-w-0 flex-wrap items-baseline gap-2.5">
<h1 className="truncate text-[var(--fs-title)] font-semibold leading-7">{title}</h1>
<div className="flex min-w-0 flex-wrap items-center gap-2.5">
<h1 className="app-page-header-title truncate font-semibold leading-7">{title}</h1>
{meta}
</div>
{description ? <p className="mt-1 text-xs leading-5 text-foreground/58">{description}</p> : null}
@@ -44,7 +42,7 @@ export function ListToolbar({ children, trailing, active, onReset, className }:
}
export function TableSurface({ children, className }: { children: ReactNode; className?: string }) {
return <div className={cn("app-table-surface mt-4 min-w-0 overflow-hidden rounded-lg border border-border bg-background", className)}>{children}</div>;
return <div className={cn("app-table-surface mt-4 min-w-0 overflow-hidden rounded-lg border border-border bg-surface", className)}>{children}</div>;
}
export function CollectionGrid({ children, className }: { children: ReactNode; className?: string }) {

View File

@@ -1,5 +1,5 @@
import { App, Popover, Switch } from "antd";
import { ChevronRight, LogIn, LogOut, Moon, ShieldCheck, Sun, UserRound } from "lucide-react";
import { ChevronRight, CircleUserRound, LogIn, LogOut, Moon, ShieldCheck, Sun } from "lucide-react";
import { useEffect, useState, type ReactNode } from "react";
import { Link, useNavigate } from "react-router";
@@ -17,9 +17,10 @@ type WorkspaceSidebarFooterProps = {
expandedClassName: string;
collapsedClassName: string;
accountClassName: string;
showAnnouncement?: boolean;
};
export function WorkspaceSidebarFooter({ expandedClassName, collapsedClassName, accountClassName }: WorkspaceSidebarFooterProps) {
export function WorkspaceSidebarFooter({ expandedClassName, collapsedClassName, accountClassName, showAnnouncement = true }: WorkspaceSidebarFooterProps) {
const theme = useThemeStore((state) => state.theme);
const setTheme = useThemeStore((state) => state.setTheme);
const user = useUserStore((state) => state.user);
@@ -49,10 +50,10 @@ export function WorkspaceSidebarFooter({ expandedClassName, collapsedClassName,
return (
<div>
{user ? (
{user && showAnnouncement ? (
<SystemAnnouncementCenter
userId={user.id}
className={cn("relative mb-1 flex h-9 w-full items-center rounded-md text-xs text-foreground/55 transition-colors hover:bg-foreground/[.05] hover:text-foreground", collapsedClassName)}
className={cn("relative mb-1 flex h-9 w-full items-center rounded-md text-xs text-foreground/55 transition-colors hover:bg-surface-hover hover:text-foreground", collapsedClassName)}
showLabel
labelClassName={expandedClassName}
staticMotion
@@ -81,7 +82,7 @@ export function WorkspaceSidebarFooter({ expandedClassName, collapsedClassName,
) : null}
<div className="border-y border-border/65 py-2">
<AppChangelogButton className="flex h-8 w-full items-center gap-2 rounded px-2 text-[var(--fs-label)] text-foreground/58 hover:bg-foreground/[.055] hover:text-foreground [&_svg]:size-3.5" showLabel showVersion versionClassName="ml-auto text-[var(--fs-micro)] tabular-nums text-foreground/32" />
<AppChangelogButton className="flex h-8 w-full items-center gap-2 rounded px-2 text-[var(--fs-label)] text-foreground/58 hover:bg-surface-hover hover:text-foreground [&_svg]:size-3.5" showLabel showVersion versionClassName="ml-auto text-[var(--fs-micro)] tabular-nums text-foreground/32" />
</div>
<div className="flex h-10 items-center px-2">
@@ -89,11 +90,11 @@ export function WorkspaceSidebarFooter({ expandedClassName, collapsedClassName,
<span className="ml-2 flex-1 text-xs text-foreground/65">深色模式</span>
<Switch size="small" checked={theme === "dark"} onChange={(checked) => setTheme(checked ? "dark" : "light")} aria-label="深色模式" />
</div>
<button type="button" className="flex h-9 w-full items-center gap-2 rounded px-2 text-xs text-foreground/55 hover:bg-foreground/[.055] hover:text-foreground" onClick={() => void handleLogout()}><LogOut className="size-3.5" />退出登录</button>
<button type="button" className="flex h-9 w-full items-center gap-2 rounded px-2 text-xs text-foreground/55 hover:bg-surface-hover hover:text-foreground" onClick={() => void handleLogout()}><LogOut className="size-3.5" />退出登录</button>
</div>
)}
>
<button type="button" className={cn("flex min-h-10 w-full min-w-0 items-center overflow-hidden rounded-md text-left transition-colors hover:bg-foreground/[.045]", accountClassName)} title={creditsEnabled ? `${user.displayName || user.username} · ${balance} 积分` : user.displayName || user.username}>
<button type="button" className={cn("flex min-h-10 w-full min-w-0 items-center overflow-hidden rounded-md text-left transition-colors hover:bg-surface-hover", accountClassName)} title={creditsEnabled ? `${user.displayName || user.username} · ${balance} 积分` : user.displayName || user.username}>
<UserAvatar user={user} className="size-7" />
<span className={cn("min-w-0 flex-1 flex-col", expandedClassName)}>
<span className="truncate text-xs font-medium">{user.displayName || user.username}</span>
@@ -103,7 +104,7 @@ export function WorkspaceSidebarFooter({ expandedClassName, collapsedClassName,
</button>
</Popover>
) : (
<Link to="/login" className={cn("flex h-10 items-center rounded-md text-xs text-foreground/65 hover:bg-foreground/[.05] hover:text-foreground", collapsedClassName)} title="登录">
<Link to="/login" className={cn("flex h-10 items-center rounded-md text-xs text-foreground/65 hover:bg-surface-hover hover:text-foreground", collapsedClassName)} title="登录">
<LogIn className="size-4 shrink-0" /><span className={expandedClassName}>登录</span>
</Link>
)}
@@ -112,7 +113,7 @@ export function WorkspaceSidebarFooter({ expandedClassName, collapsedClassName,
}
function MenuLink({ to, icon, label, onNavigate }: { to: string; icon: ReactNode; label: string; onNavigate: () => void }) {
return <Link to={to} onClick={onNavigate} className="flex h-9 items-center gap-2.5 rounded px-2 text-xs text-foreground/62 hover:bg-foreground/[.055] hover:text-foreground [&_svg]:size-3.5 [&_svg]:shrink-0">{icon}<span className="flex-1">{label}</span><ChevronRight className="!size-3 text-foreground/25" /></Link>;
return <Link to={to} onClick={onNavigate} className="flex h-9 items-center gap-2.5 rounded px-2 text-xs text-foreground/62 hover:bg-surface-hover hover:text-foreground [&_svg]:size-3.5 [&_svg]:shrink-0">{icon}<span className="flex-1">{label}</span><ChevronRight className="!size-3 text-foreground/25" /></Link>;
}
function UserAvatar({ user, className }: { user: LocalUser; className: string }) {
@@ -123,11 +124,11 @@ function UserAvatar({ user, className }: { user: LocalUser; className: string })
return (
<span className={`relative grid shrink-0 place-items-center ${className}`}>
<span className="grid size-full place-items-center overflow-hidden rounded-full bg-muted text-foreground/55">
<span className="grid size-full place-items-center overflow-hidden rounded-md bg-transparent text-foreground/55">
{avatarUrl && !failed ? (
<img src={avatarUrl} alt="" referrerPolicy="no-referrer" className="size-full object-cover" onError={() => setFailed(true)} />
) : (
<UserRound className="size-[52%]" aria-hidden />
<CircleUserRound className="app-workspace-account-icon" aria-hidden />
)}
</span>
<IdentityProviderBadge user={user} compact className="absolute -bottom-1 -right-1" />

View File

@@ -0,0 +1,36 @@
import { Infinity as InfinityIcon, PanelLeftClose, PanelLeftOpen, Settings2 } from "lucide-react";
import { Link } from "react-router";
import { SystemAnnouncementCenter } from "@/components/layout/system-announcement-center";
import { AnimatedThemeToggler } from "@/components/ui/animated-theme-toggler";
import { useThemeStore } from "@/stores/use-theme-store";
import { useUserStore } from "@/stores/use-user-store";
export function WorkspaceTopBar({ sidebarOpen, onToggleSidebar }: { sidebarOpen: boolean; onToggleSidebar: () => void }) {
const theme = useThemeStore((state) => state.theme);
const setTheme = useThemeStore((state) => state.setTheme);
const user = useUserStore((state) => state.user);
return (
<header className="app-workspace-topbar flex shrink-0 items-center justify-between gap-3 border-b px-4 sm:px-5">
<div className="flex min-w-0 items-center gap-2">
<button type="button" className="app-workspace-topbar-icon-button" aria-label={sidebarOpen ? "收起侧栏" : "展开侧栏"} onClick={onToggleSidebar}>
{sidebarOpen ? <PanelLeftClose className="size-4" /> : <PanelLeftOpen className="size-4" />}
</button>
<Link to="/" className="app-workspace-brand-link inline-flex min-w-0 items-center gap-2 text-foreground" title="影策工作台">
<span className="app-workspace-brand-mark grid size-7 shrink-0 place-items-center rounded-md bg-foreground text-background">
<InfinityIcon className="size-4" />
</span>
<span className="app-workspace-brand-wordmark truncate text-[var(--fs-body)] font-semibold">影策</span>
</Link>
</div>
<div className="flex shrink-0 items-center gap-1">
{user ? <SystemAnnouncementCenter userId={user.id} className="app-workspace-topbar-icon-button" /> : null}
<AnimatedThemeToggler className="app-workspace-topbar-icon-button" theme={theme} onThemeChange={setTheme} aria-label="切换主题" />
<Link to="/settings" className="app-workspace-topbar-icon-button" aria-label="设置" title="设置">
<Settings2 className="size-4" />
</Link>
</div>
</header>
);
}

View File

@@ -1,6 +1,6 @@
import { type GenerationTask } from "@/services/api/task-center";
import { backendProviderConfig, runBackendGenerationTask } from "@/services/api/generation-task";
import { configuredModelMatchesCapability, defaultConfig, resolveModelRequestConfig, type AiConfig } from "@/stores/use-config-store";
import { defaultConfig, modelMatchesCapability, modelOptionName, normalizeModelOptionValue, resolveModelRequestConfig, type AiConfig } from "@/stores/use-config-store";
import { resolveImageUrl, uploadImage } from "@/services/image-storage";
import { resolveMediaUrl } from "@/services/file-storage";
import { resourceIdFromStorageKey } from "@/services/api/resources";
@@ -287,8 +287,8 @@ export function getGenerationCount(count: string) {
export function buildGenerationConfig(config: AiConfig, node: CanvasNodeData | undefined, mode: CanvasNodeGenerationMode): AiConfig {
const defaultModel = mode === "image" ? config.imageModel : mode === "video" ? config.videoModel : mode === "audio" ? config.audioModel : config.textModel;
const fallbackModel = mode === "image" ? defaultConfig.imageModel : mode === "video" ? defaultConfig.videoModel : mode === "audio" ? defaultConfig.audioModel : defaultConfig.textModel;
const storedModel = node?.metadata?.model;
const model = storedModel && configuredModelMatchesCapability(config, storedModel, mode) ? storedModel : defaultModel && configuredModelMatchesCapability(config, defaultModel, mode) ? defaultModel : fallbackModel;
const storedModel = resolveCanvasGenerationModel(config, node?.metadata?.model, mode);
const model = storedModel || resolveCanvasGenerationModel(config, defaultModel, mode) || fallbackModel;
const imageProfile = mode === "image" ? modelCapabilityConfigFor(config, model).image! : undefined;
const normalizedImage = imageProfile ? normalizeImageValue(imageProfile, { quality: node?.metadata?.quality || config.quality || defaultConfig.quality, size: node?.metadata?.size || config.size || defaultConfig.size, transparentBackground: node?.metadata?.transparentBackground || config.transparentBackground, count: String(node?.metadata?.count || config.canvasImageCount || config.count || defaultConfig.count) }) : undefined;
return {
@@ -309,6 +309,13 @@ export function buildGenerationConfig(config: AiConfig, node: CanvasNodeData | u
};
}
export function resolveCanvasGenerationModel(config: AiConfig, model: string | undefined, mode: CanvasNodeGenerationMode): string {
if (!model) return "";
const normalized = normalizeModelOptionValue(model, config.channels);
if (!normalized) return "";
return modelMatchesCapability(modelOptionName(normalized), mode) ? normalized : "";
}
export function supportsVideoReferenceAudio(config: AiConfig) {
const interfaceType = resolveModelRequestConfig(config, config.model).interfaceType;
return interfaceType === "newapi-channel-1" || interfaceType === "newapi-channel-2" || isSeedanceVideoConfig(config);

View File

@@ -79,10 +79,10 @@ export function AdminShell() {
<AdminNavigation collapsed={collapsed} />
<div className="shrink-0 border-t border-border/70 p-2">
<Tooltip title={collapsed ? "更新日志" : undefined} placement="right">
<AppChangelogButton className={cn("flex h-8 w-full items-center rounded text-[var(--fs-label)] text-foreground/52 transition-colors hover:bg-foreground/[.055] hover:text-foreground", collapsed ? "justify-center px-0" : "gap-2 px-2")} showVersion={!collapsed} />
<AppChangelogButton className={cn("flex h-8 w-full items-center rounded text-[var(--fs-label)] text-foreground/52 transition-colors hover:bg-surface-hover hover:text-foreground", collapsed ? "justify-center px-0" : "gap-2 px-2")} showVersion={!collapsed} />
</Tooltip>
<Tooltip title={collapsed ? "返回创作台" : undefined} placement="right">
<NavLink to="/canvas" className={cn("flex h-8 items-center rounded text-[var(--fs-label)] text-foreground/52 transition-colors hover:bg-foreground/[.055] hover:text-foreground", collapsed ? "justify-center px-0" : "gap-2 px-2")}>
<NavLink to="/canvas" className={cn("flex h-8 items-center rounded text-[var(--fs-label)] text-foreground/52 transition-colors hover:bg-surface-hover hover:text-foreground", collapsed ? "justify-center px-0" : "gap-2 px-2")}>
<Home className="size-3.5" />
{!collapsed ? <span>返回创作台</span> : null}
</NavLink>
@@ -127,11 +127,11 @@ function MobileAdminNavigation() {
return (
<nav className="app-workspace-navigation hide-scrollbar flex shrink-0 gap-1 overflow-x-auto border-b border-border/70 px-3 py-2 lg:hidden" aria-label="管理后台分区">
{adminNavigation.flatMap((group) => group.items).map((item) => (
<NavLink key={item.path} to={item.path} end={item.path === "/admin"} className={({ isActive }) => cn("app-workspace-nav-link flex h-8 shrink-0 items-center gap-1.5 rounded-md px-2.5 text-xs transition-colors", isActive ? "is-active font-medium" : "text-foreground/60 hover:bg-foreground/[.05] hover:text-foreground")}>
<NavLink key={item.path} to={item.path} end={item.path === "/admin"} className={({ isActive }) => cn("app-workspace-nav-link flex h-8 shrink-0 items-center gap-1.5 rounded-md px-2.5 text-xs transition-colors", isActive ? "is-active font-medium" : "text-foreground/60 hover:bg-surface-hover hover:text-foreground")}>
{item.icon}<span>{item.label}</span>
</NavLink>
))}
<AppChangelogButton className="grid size-8 shrink-0 place-items-center rounded-md text-foreground/55 transition-colors hover:bg-foreground/[.05] hover:text-foreground [&_svg]:size-4" />
<AppChangelogButton className="grid size-8 shrink-0 place-items-center rounded-md text-foreground/55 transition-colors hover:bg-surface-hover hover:text-foreground [&_svg]:size-4" />
</nav>
);
}
@@ -151,7 +151,7 @@ function AdminNavigation({ collapsed }: { collapsed: boolean }) {
className={({ isActive }) => cn(
"app-workspace-nav-link flex h-9 items-center rounded-md text-[var(--fs-body)] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring",
collapsed ? "justify-center px-0" : "gap-2.5 px-2.5",
isActive ? "is-active font-medium" : "text-foreground/62 hover:bg-foreground/[.05] hover:text-foreground",
isActive ? "is-active font-medium" : "text-foreground/62 hover:bg-surface-hover hover:text-foreground",
)}
>
{item.icon}{!collapsed ? <span className="truncate">{item.label}</span> : null}

View File

@@ -1,6 +1,8 @@
import { CheckCheck, Copy, Download, FileUp, MoreHorizontal, PencilLine, Plus, Search, Trash2, Upload } from "lucide-react";
import { AudioLines, Box, CheckCheck, Clapperboard, Copy, Download, FileText, FileUp, Image as ImageIcon, Link2, MoreHorizontal, PencilLine, Play, Plus, Search, Trash2, Upload, type LucideIcon } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { App, Button, Drawer, Dropdown, Form, Input, Modal, Select, Space, Tag, Typography } from "antd";
import type { MenuProps } from "antd";
import { useNavigate } from "react-router";
import { CollectionGrid, ListToolbar, PageHeader, PaginationBar, WorkspacePage } from "@/components/layout/workspace-page";
import { WorkspaceState } from "@/components/layout/workspace-state";
@@ -52,8 +54,17 @@ const categoryOptions = [
{ label: "其他", value: "other" },
];
const assetKindIcons: Record<LibraryAsset["kind"], LucideIcon> = {
text: FileText,
image: ImageIcon,
video: Clapperboard,
audio: AudioLines,
model: Box,
};
export default function AssetsPage() {
const { message } = App.useApp();
const navigate = useNavigate();
const copyText = useCopyText();
const [form] = Form.useForm<AssetFormValues>();
const coverInputRef = useRef<HTMLInputElement>(null);
@@ -259,23 +270,28 @@ export default function AssetsPage() {
return (
<>
<WorkspacePage grid className="library-page assets-library-page">
<WorkspacePage grid className="library-page assets-library-page canvas-library-page">
<div className="studio-band">
<PageHeader
icon="assets"
title="素材库"
description="管理文本、图片、视频、音频和 3D 模型素材。"
meta={<span className="text-xs text-foreground/45">{filteredAssets.length} 个素材</span>}
meta={<span className="app-projects-header-meta assets-header-meta">{validAssets.length} 个素材</span>}
actions={(
<>
<Button icon={<Download className="size-4" />} onClick={() => void exportAllAssets()}>导出全部</Button>
<Dropdown trigger={["click"]} menu={{ items: [{ key: "package", icon: <FileUp className="size-4" />, label: "导入素材包", onClick: () => assetInputRef.current?.click() }, { key: "model", icon: <Upload className="size-4" />, label: "上传 3D 模型", onClick: () => modelInputRef.current?.click() }] }}><Button icon={<FileUp className="size-4" />}>导入</Button></Dropdown>
<Button className="library-primary-action" type="primary" icon={<Plus className="size-3.5" />} onClick={openCreate}>新增素材</Button>
<Button title="导出全部素材" aria-label="导出全部素材" icon={<Download className="size-4" />} onClick={() => void exportAllAssets()} />
<Dropdown trigger={["click"]} menu={{ items: [{ key: "package", icon: <FileUp className="size-4" />, label: "导入素材包", onClick: () => assetInputRef.current?.click() }, { key: "model", icon: <Upload className="size-4" />, label: "上传 3D 模型", onClick: () => modelInputRef.current?.click() }] }}>
<Button title="导入素材" aria-label="导入素材" icon={<FileUp className="size-4" />} />
</Dropdown>
</>
)}
/>
<ListToolbar className="library-toolbar" active={Boolean(keyword || kindFilter !== "all" || categoryFilter !== "all")} onReset={() => { setKeyword(""); setKindFilter("all"); setCategoryFilter("all"); setPage(1); }}>
<Input allowClear className="w-full sm:w-80" prefix={<Search className="size-4 text-foreground/40" />} value={keyword} placeholder="搜索标题、内容、标签或来源" onChange={(event) => { setPage(1); setKeyword(event.target.value); }} />
</ListToolbar>
</div>
<div className="canvas-library-frame assets-library-frame">
<div className="grid min-h-0 gap-4 lg:grid-cols-[176px_minmax(0,1fr)]">
<aside className="thin-scrollbar flex gap-2 overflow-x-auto border-b border-border/70 py-3 lg:sticky lg:top-0 lg:block lg:max-h-[calc(100vh-150px)] lg:overflow-y-auto lg:border-b-0 lg:border-r lg:pr-3">
<AssetFilterGroup title="素材类型" options={kindOptions} value={kindFilter} counts={kindCounts} onChange={(value) => { setKindFilter(value as AssetKind | "all"); setPage(1); }} />
@@ -283,30 +299,32 @@ export default function AssetsPage() {
</aside>
<section className="min-w-0">
{selectedAssets.length ? (
<div className="mt-3 flex min-h-10 flex-wrap items-center gap-2 border-y border-border/75 py-2 text-xs">
<strong className="mr-auto font-medium">已选择 {selectedAssets.length} 个素材</strong>
<Button size="small" icon={<CheckCheck className="size-3.5" />} disabled={allFilteredSelected} onClick={() => setSelectedIds((current) => Array.from(new Set([...current, ...filteredAssetIds])))}>全选</Button>
<Button size="small" onClick={() => setSelectedIds([])}>取消选择</Button>
<Button size="small" icon={<Download className="size-3.5" />} onClick={() => void exportSelectedAssets()}>导出</Button>
<Button size="small" danger icon={<Trash2 className="size-3.5" />} onClick={() => setBatchDeleteOpen(true)}>删除</Button>
</div>
<AssetsBatchBar count={selectedAssets.length} allSelected={allFilteredSelected} onSelectAll={() => setSelectedIds((current) => Array.from(new Set([...current, ...filteredAssetIds])))} onClear={() => setSelectedIds([])} onExport={() => void exportSelectedAssets()} onDelete={() => setBatchDeleteOpen(true)} />
) : null}
<CollectionGrid className="library-grid assets-library-grid">
{canCreateAsset ? <button type="button" className="library-create-card" onClick={openCreate}>
<span className="library-create-cover"><Plus className="size-8" /></span>
<span className="library-create-title">新增素材</span>
<span className="library-create-meta">文本、图片、音视频或模型</span>
</button> : null}
{visibleAssets.map((asset) => (
<AssetCard key={asset.id} asset={asset} selected={selectedIds.includes(asset.id)} onSelect={(selected) => setSelectedIds((current) => selected ? [...new Set([...current, asset.id])] : current.filter((id) => id !== asset.id))} onOpen={() => setPreviewAsset(asset)} onEdit={() => openEdit(asset)} onCopy={copyAssetText} onDownload={downloadImage} onDelete={() => setDeletingAsset(asset)} />
))}
</CollectionGrid>
{!visibleAssets.length && (validAssets.length > 0 || !canCreateAsset) ? <WorkspaceState icon="assets" compact title={validAssets.length ? "没有匹配的素材" : "素材库还是空的"} description={validAssets.length ? "调整关键词或左侧分类后再试。" : "从画布保存结果,或导入已有素材包开始整理。"} action={!validAssets.length ? <Button type="primary" icon={<Plus className="size-4" />} onClick={openCreate}>新增素材</Button> : undefined} /> : null}
<PaginationBar current={page} pageSize={pageSize} total={filteredAssets.length} pageSizeOptions={[20, 40, 80]} onChange={(nextPage, nextPageSize) => { setPage(nextPageSize !== pageSize ? 1 : nextPage); setPageSize(nextPageSize); }} />
{validAssets.length === 0 ? (
<AssetsEmptyState onNew={openCreate} onImport={() => assetInputRef.current?.click()} onGoCanvas={() => navigate("/canvas")} />
) : (
<>
{filteredAssets.length === 0 ? (
<WorkspaceState icon="assets" compact title="没有匹配的素材" description="调整关键词或左侧分类后再试。" />
) : (
<CollectionGrid className="library-grid assets-library-grid">
{canCreateAsset ? <button type="button" className="library-create-card" onClick={openCreate}>
<span className="library-create-cover"><Plus className="size-8" /></span>
<span className="library-create-title">新增素材</span>
<span className="library-create-meta">文本、图片、音视频或模型</span>
</button> : null}
{visibleAssets.map((asset) => (
<AssetCard key={asset.id} asset={asset} selected={selectedIds.includes(asset.id)} onSelect={(selected) => setSelectedIds((current) => selected ? [...new Set([...current, asset.id])] : current.filter((id) => id !== asset.id))} onOpen={() => setPreviewAsset(asset)} onEdit={() => openEdit(asset)} onCopy={copyAssetText} onDownload={downloadImage} onDelete={() => setDeletingAsset(asset)} />
))}
</CollectionGrid>
)}
<PaginationBar current={page} pageSize={pageSize} total={filteredAssets.length} pageSizeOptions={[20, 40, 80]} onChange={(nextPage, nextPageSize) => { setPage(nextPageSize !== pageSize ? 1 : nextPage); setPageSize(nextPageSize); }} />
</>
)}
</section>
</div>
</div>
</WorkspacePage>
<Modal className="library-modal" title={editingAsset ? "编辑素材" : "新增素材"} open={isAssetOpen} width={980} onCancel={() => setIsAssetOpen(false)} onOk={() => void saveAsset()} okText="保存" cancelText="取消" destroyOnHidden>
@@ -435,32 +453,16 @@ export default function AssetsPage() {
function AssetCard({ asset, selected, onSelect, onOpen, onEdit, onCopy, onDownload, onDelete }: { asset: LibraryAsset; selected: boolean; onSelect: (selected: boolean) => void; onOpen: () => void; onEdit: () => void; onCopy: (asset: LibraryAsset) => void; onDownload: (asset: LibraryAsset) => void; onDelete: () => void }) {
const summary = assetSummary(asset);
const menuItems: MenuProps["items"] = [
...(asset.kind === "text" || asset.kind === "image" ? [{ key: "edit", icon: <PencilLine className="size-3.5" />, label: "编辑", onClick: onEdit }] : []),
...(asset.kind === "text" ? [{ key: "copy", icon: <Copy className="size-3.5" />, label: "复制文本", onClick: () => void onCopy(asset) }] : []),
...(asset.kind === "image" || asset.kind === "video" || asset.kind === "audio" || asset.kind === "model" ? [{ key: "download", icon: <Download className="size-3.5" />, label: "下载", onClick: () => onDownload(asset) }] : []),
{ type: "divider" as const },
{ key: "delete", danger: true, icon: <Trash2 className="size-3.5" />, label: "删除", onClick: onDelete },
];
return (
<AssetLibraryCard selected={selected}>
<AssetLibraryCardMedia className="relative overflow-hidden bg-foreground/[.045]">
<button type="button" className="block w-full text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--workspace-accent)]" onClick={onOpen} aria-label={`查看素材:${asset.title}`}>
<AssetMediaPreview asset={asset} alt={asset.title} className="aspect-[4/3] w-full bg-black object-cover" fallback={<div className="flex aspect-[4/3] items-center justify-center bg-foreground/[.035] p-4 text-center text-xs leading-5 text-foreground/55">{asset.kind === "text" ? asset.data.content : "暂无封面"}</div>} />
</button>
<input type="checkbox" checked={selected} onClick={(event) => event.stopPropagation()} onChange={(event) => onSelect(event.target.checked)} className={`absolute left-2 top-2 size-4 accent-[var(--workspace-accent)] drop-shadow-md transition-opacity ${selected ? "opacity-100" : "opacity-100 sm:opacity-0 sm:group-hover:opacity-100 sm:group-focus-within:opacity-100"}`} aria-label={`选择 ${asset.title}`} />
<Dropdown
trigger={["click"]}
menu={{ items: [
...(asset.kind !== "video" && asset.kind !== "audio" && asset.kind !== "model" ? [{ key: "edit", icon: <PencilLine className="size-3.5" />, label: "编辑", onClick: onEdit }] : []),
...(asset.kind === "text" ? [{ key: "copy", icon: <Copy className="size-3.5" />, label: "复制文本", onClick: () => void onCopy(asset) }] : []),
...(asset.kind === "image" || asset.kind === "video" || asset.kind === "audio" || asset.kind === "model" ? [{ key: "download", icon: <Download className="size-3.5" />, label: "下载", onClick: () => onDownload(asset) }] : []),
{ type: "divider" as const },
{ key: "delete", danger: true, icon: <Trash2 className="size-3.5" />, label: "删除", onClick: onDelete },
] }}
>
<button type="button" className="absolute right-2 top-2 grid size-8 place-items-center rounded-[var(--r-sm)] border border-white/20 bg-black/55 text-white/85 opacity-100 shadow-sm backdrop-blur transition-[opacity,background-color] hover:bg-black/75 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/80 sm:opacity-0 sm:group-hover:opacity-100 sm:group-focus-within:opacity-100" aria-label="更多素材操作" title="更多操作">
<MoreHorizontal className="size-4" />
</button>
</Dropdown>
<div className="pointer-events-none absolute inset-x-2 bottom-2 flex items-center gap-1">
<span className="rounded-[var(--r-xs)] bg-black/60 px-1.5 py-0.5 text-[var(--fs-tiny)] font-medium text-white/90 backdrop-blur">{assetKindLabel(asset.kind)}</span>
<span className="rounded-[var(--r-xs)] bg-black/60 px-1.5 py-0.5 text-[var(--fs-tiny)] text-white/75 backdrop-blur">{assetCategoryLabel(asset.category)}</span>
</div>
</AssetLibraryCardMedia>
<AssetCover asset={asset} selected={selected} onSelect={onSelect} onOpen={onOpen} menuItems={menuItems} />
<button type="button" className="block w-full px-2.5 py-2 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--workspace-accent)]" onClick={onOpen}>
<div className="flex min-w-0 items-center justify-between gap-2">
<h2 className="truncate text-[var(--fs-body)] font-semibold text-foreground" title={asset.title}>{asset.title}</h2>
@@ -477,87 +479,227 @@ function AssetCard({ asset, selected, onSelect, onOpen, onEdit, onCopy, onDownlo
);
}
function AssetCover({ asset, selected, onSelect, onOpen, menuItems }: { asset: LibraryAsset; selected: boolean; onSelect: (selected: boolean) => void; onOpen: () => void; menuItems: MenuProps["items"] }) {
const KindIcon = assetKindIcons[asset.kind];
const clock = asset.kind === "video" || asset.kind === "audio" ? formatAssetClock(asset.data.durationMs) : null;
const showPlay = asset.kind === "video";
const isLight = asset.kind === "audio" || asset.kind === "text" || asset.kind === "model";
return (
<AssetLibraryCardMedia className={isLight ? "assets-cover is-light" : "assets-cover"}>
<button type="button" className="assets-cover-link" onClick={onOpen} aria-label={`查看素材:${asset.title}`}>
{asset.kind === "audio" ? (
<AudioWaveCover asset={asset} />
) : asset.kind === "text" ? (
<TextCover asset={asset} />
) : asset.kind === "model" ? (
<ModelCover asset={asset} />
) : (
<AssetMediaPreview asset={asset} alt={asset.title} className="assets-cover-media" fallback={<div className="assets-cover-fallback"><KindIcon className="size-7" /></div>} />
)}
<span className="assets-cover-vignette" aria-hidden="true" />
{showPlay ? <span className="assets-cover-play"><Play className="size-4" /></span> : null}
</button>
<span className="assets-cover-badges">
<span className="assets-cover-badge is-kind"><KindIcon />{assetKindLabel(asset.kind)}</span>
<span className="assets-cover-badge is-category">{assetCategoryLabel(asset.category)}</span>
</span>
{clock ? <span className="assets-cover-clock">{clock}</span> : null}
<input type="checkbox" checked={selected} onClick={(event) => event.stopPropagation()} onChange={(event) => onSelect(event.target.checked)} className="assets-select-check" aria-label={`选择 ${asset.title}`} />
<Dropdown
trigger={["click"]}
menu={{ items: menuItems }}
>
<button type="button" className="assets-cover-more" aria-label="更多素材操作" title="更多操作">
<MoreHorizontal className="size-4" />
</button>
</Dropdown>
</AssetLibraryCardMedia>
);
}
function AudioWaveCover({ asset }: { asset: LibraryAsset & { kind: "audio" } }) {
const bars = audioWaveBars(asset.id);
return (
<div className="assets-cover-wave" aria-hidden="true">
{bars.map((height, index) => <span key={index} style={{ height: `${height}%` }} />)}
<AudioLines className="assets-cover-wave-glyph" />
</div>
);
}
function TextCover({ asset }: { asset: LibraryAsset & { kind: "text" } }) {
return (
<div className="assets-cover-text">
<p>{asset.data.content || "空白文本素材"}</p>
</div>
);
}
function ModelCover({ asset }: { asset: LibraryAsset & { kind: "model" } }) {
return (
<div className="assets-cover-model">
<Box />
<span>{asset.data.fileName}</span>
</div>
);
}
function AssetsBatchBar({ count, allSelected, onSelectAll, onClear, onExport, onDelete }: { count: number; allSelected: boolean; onSelectAll: () => void; onClear: () => void; onExport: () => void; onDelete: () => void }) {
return (
<div className="assets-batch-bar" role="toolbar" aria-label="批量操作">
<span className="assets-batch-count">已选择 <strong>{count}</strong> 个素材</span>
<div className="assets-batch-actions">
<Button size="small" icon={<CheckCheck className="size-3.5" />} disabled={allSelected} onClick={onSelectAll}>全选</Button>
<Button size="small" onClick={onClear}>取消选择</Button>
<Button size="small" icon={<Download className="size-3.5" />} onClick={onExport}>导出</Button>
<Button size="small" danger icon={<Trash2 className="size-3.5" />} onClick={onDelete}>删除</Button>
</div>
</div>
);
}
const assetsEmptyBannerFrames = [
{ src: "/short-drama-styles/retro-hong-kong.jpg", caption: "ASSET.01 · 天台重逢" },
{ src: "/short-drama-styles/cyberpunk-neon.jpg", caption: "ASSET.02 · 雨夜霓虹" },
{ src: "/short-drama-styles/suspense-noir.jpg", caption: "ASSET.03 · 暗巷追逐" },
];
function AssetsEmptyState({ onNew, onImport, onGoCanvas }: { onNew: () => void; onImport: () => void; onGoCanvas: () => void }) {
return (
<div className="assets-empty">
<div className="assets-empty-banner" aria-hidden="true">
{assetsEmptyBannerFrames.map((frame, index) => (
<figure key={frame.caption} className={`assets-empty-banner-frame ${index === 1 ? "is-main" : index === 0 ? "is-back" : "is-front"}`}>
<img src={frame.src} alt="" loading="lazy" decoding="async" />
<span>{frame.caption}</span>
</figure>
))}
<span className="assets-empty-banner-caption"><span>影策素材库</span>把每次创作的结果,留档成可复用的资产</span>
</div>
<div className="assets-empty-cards">
<button type="button" className="assets-empty-card" onClick={onNew}>
<span className="assets-empty-card-icon"><Plus /></span>
<strong>新建素材</strong>
<span>录入提示词、说明文案,或上传图片资产。</span>
</button>
<button type="button" className="assets-empty-card" onClick={onImport}>
<span className="assets-empty-card-icon"><FileUp /></span>
<strong>导入素材包</strong>
<span>从素材压缩包一键恢复旧资产,继续创作。</span>
</button>
<button type="button" className="assets-empty-card" onClick={onGoCanvas}>
<span className="assets-empty-card-icon"><Clapperboard /></span>
<strong>去画布保存</strong>
<span>把画布上满意的镜头与画面留档进素材库。</span>
</button>
</div>
</div>
);
}
function AssetFilterGroup({ title, options, value, counts, onChange, className = "" }: { title: string; options: Array<{ label: string; value: string }>; value: string; counts: Map<string, number>; onChange: (value: string) => void; className?: string }) {
return (
<div className={className}>
<div className="mb-1.5 px-1 text-[var(--fs-tiny)] font-semibold uppercase tracking-[0.08em] text-foreground/38">{title}</div>
<div className="flex gap-1 lg:block">
{options.map((option) => (
<button key={option.value} type="button" className={`flex h-8 shrink-0 items-center justify-between gap-3 rounded px-2 text-xs transition-colors lg:mb-0.5 lg:w-full ${value === option.value ? "bg-foreground/[.08] font-medium text-foreground" : "text-foreground/56 hover:bg-foreground/[.04] hover:text-foreground"}`} onClick={() => onChange(option.value)}>
<span>{option.label}</span><span className="rounded bg-foreground/[.06] px-1.5 py-0.5 text-[var(--fs-tiny)] tabular-nums text-foreground/45">{counts.get(option.value) || 0}</span>
</button>
))}
<div className="flex gap-1.5 lg:block lg:space-y-0.5">
{options.map((option) => {
const active = value === option.value;
return (
<button key={option.value} type="button" aria-pressed={active} className={`assets-filter-item ${active ? "is-active" : ""}`} onClick={() => onChange(option.value)}>
<span className="assets-filter-item-label">{option.label}</span>
<span className="assets-filter-count">{counts.get(option.value) || 0}</span>
</button>
);
})}
</div>
</div>
);
}
function AssetDrawer({ asset, onClose, onCopy, onDownload }: { asset: LibraryAsset | null; onClose: () => void; onCopy: (asset: LibraryAsset) => void; onDownload: (asset: LibraryAsset) => void }) {
const facts = asset ? assetArchiveFacts(asset) : [];
const KindIcon = asset ? assetKindIcons[asset.kind] : Clapperboard;
return (
<Drawer className="library-drawer" title="素材详情" open={Boolean(asset)} size="large" onClose={onClose}>
<Drawer className="library-drawer" title="素材档案" open={Boolean(asset)} size="large" onClose={onClose}>
{asset ? (
<div className="space-y-5">
<AssetMediaPreview asset={asset} alt={asset.title} className="max-h-[420px] w-full rounded-lg bg-black object-contain" fallback={<div className="rounded-lg border border-stone-200 bg-stone-50 p-5 text-sm leading-6 text-stone-600 dark:border-stone-800 dark:bg-stone-900 dark:text-stone-300">{asset.kind === "text" ? asset.data.content : "暂无封面"}</div>} />
<div>
<Typography.Title level={4} className="!mb-2">
{asset.title}
</Typography.Title>
<Space size={[4, 4]} wrap>
<Tag>{assetKindLabel(asset.kind)}</Tag>
<Tag>{assetCategoryLabel(asset.category)}</Tag>
<StorageTag asset={asset} />
{(asset.tags || []).map((tag) => (
<Tag key={tag}>{tag}</Tag>
))}
</Space>
<div className="space-y-4">
<div className="asset-archive-header">
<span className="asset-archive-header-icon"><KindIcon /></span>
<div className="min-w-0">
<h2 className="asset-archive-title">{asset.title}</h2>
<p className="asset-archive-subtitle">{assetCategoryLabel(asset.category)} · {formatAssetDateTime(asset.createdAt)} 创建</p>
</div>
</div>
<div className="asset-drawer-content rounded-lg border border-stone-200 p-4 dark:border-stone-800">
<Typography.Text type="secondary" className="block text-xs">
内容
</Typography.Text>
<div className="asset-archive-preview">
{asset.kind === "text" ? (
<Typography.Paragraph className="mt-2 whitespace-pre-wrap">{asset.data.content}</Typography.Paragraph>
) : asset.kind === "video" ? (
<video src={asset.data.url} controls className="mt-2 aspect-video w-full rounded-lg bg-black" />
<div className="asset-archive-preview-note">{asset.data.content}</div>
) : asset.kind === "audio" ? (
<audio src={asset.data.url} controls className="mt-2 w-full" />
<div className="asset-archive-audio"><audio src={asset.data.url} controls /></div>
) : asset.kind === "model" ? (
<Typography.Text className="mt-2 block">{asset.data.fileName} · {formatBytes(asset.data.bytes)} · {asset.data.mimeType}</Typography.Text>
<div className="asset-archive-preview-model"><Box /><span>{asset.data.fileName} · {formatBytes(asset.data.bytes)}</span></div>
) : asset.kind === "video" ? (
<video src={asset.data.url} controls className="asset-archive-preview-media" />
) : (
<Typography.Text className="mt-2 block">
{asset.data.width}x{asset.data.height} · {formatBytes(asset.data.bytes)} · {asset.data.mimeType}
</Typography.Text>
<img src={asset.coverUrl || asset.data.dataUrl} alt={asset.title} loading="lazy" decoding="async" className="asset-archive-preview-media" />
)}
{asset.kind === "image" || asset.kind === "video" || asset.kind === "audio" || asset.kind === "model" ? (
<Typography.Text type="secondary" className="mt-2 block text-xs">
存储位置:{resourceStorageTitle(asset.data.storageKey)}
</Typography.Text>
) : null}
</div>
<div className="flex flex-wrap gap-1.5">
{(asset.tags || []).map((tag) => (
<Tag key={tag} className="m-0">{tag}</Tag>
))}
<StorageTag asset={asset} />
</div>
<div className="asset-archive-facts">
{facts.map((fact) => (
<div key={fact.label} className="asset-archive-fact">
<span className="asset-archive-fact-label">{fact.label}</span>
<span className="asset-archive-fact-value" title={fact.value}>{fact.value}</span>
</div>
))}
</div>
<div className="asset-archive-link"><Link2 /><span>所属项目</span><strong>{assetProjectLabel(asset)}</strong></div>
{asset.note ? (
<div>
<Typography.Text type="secondary">备注</Typography.Text>
<Typography.Paragraph className="mt-1">{asset.note}</Typography.Paragraph>
<div className="asset-archive-section">
<span className="asset-archive-section-title">备注</span>
<p className="asset-archive-section-body">{asset.note}</p>
</div>
) : null}
<Space>
<div className="asset-archive-actions">
{asset.kind === "text" ? (
<Button type="primary" icon={<Copy className="size-4" />} onClick={() => onCopy(asset)}>
复制文本
</Button>
<Button type="primary" icon={<Copy className="size-4" />} onClick={() => onCopy(asset)}>复制文本</Button>
) : null}
{asset.kind === "image" || asset.kind === "video" || asset.kind === "audio" || asset.kind === "model" ? (
<Button type="primary" icon={<Download className="size-4" />} onClick={() => onDownload(asset)}>
{asset.kind === "video" ? "下载视频" : asset.kind === "audio" ? "下载音频" : asset.kind === "model" ? "下载模型" : "下载图片"}
</Button>
<Button type="primary" icon={<Download className="size-4" />} onClick={() => onDownload(asset)}>{assetDownloadLabel(asset)}</Button>
) : null}
</Space>
</div>
</div>
) : null}
</Drawer>
);
}
function assetArchiveFacts(asset: LibraryAsset) {
const facts: Array<{ label: string; value: string }> = [
{ label: "类型", value: assetKindLabel(asset.kind) },
{ label: "分类", value: assetCategoryLabel(asset.category) },
];
if (asset.kind === "image" || asset.kind === "video") {
facts.push({ label: "尺寸", value: `${asset.data.width}x${asset.data.height}` });
}
if (asset.kind === "video" || asset.kind === "audio") {
facts.push({ label: "时长", value: formatAssetClock(asset.data.durationMs) || "未知" });
}
if (asset.kind !== "text") {
facts.push({ label: "大小", value: formatBytes(asset.data.bytes) });
facts.push({ label: "格式", value: asset.data.mimeType });
facts.push({ label: "存储", value: resourceStorageLabel(asset.data.storageKey) });
}
facts.push({ label: "来源", value: asset.source || "未标注" });
facts.push({ label: "创建", value: formatAssetDateTime(asset.createdAt) });
facts.push({ label: "更新", value: formatAssetDateTime(asset.updatedAt) });
return facts;
}
function assetSummary(asset: LibraryAsset) {
if (asset.kind === "text") return asset.data.content;
if (asset.kind === "audio") return `${formatAssetDuration(asset.data.durationMs)} · ${formatBytes(asset.data.bytes)} · ${asset.data.mimeType}`;
@@ -594,12 +736,46 @@ function assetKindLabel(kind: AssetKind) {
return kind === "image" ? "图片" : kind === "video" ? "视频" : kind === "audio" ? "音频" : kind === "model" ? "3D 模型" : "文本";
}
function assetDownloadLabel(asset: LibraryAsset) {
if (asset.kind === "video") return "下载视频";
if (asset.kind === "audio") return "下载音频";
if (asset.kind === "model") return "下载模型";
return "下载图片";
}
function formatAssetDuration(durationMs?: number) {
if (!durationMs) return "时长未知";
return `${Math.round(durationMs / 100) / 10} 秒`;
}
function formatAssetClock(durationMs?: number) {
if (!durationMs || durationMs < 1000) return null;
const total = Math.round(durationMs / 1000);
const minutes = Math.floor(total / 60);
const seconds = total % 60;
return `${minutes}:${String(seconds).padStart(2, "0")}`;
}
function formatAssetTime(value: string) {
const date = new Date(value);
return Number.isNaN(date.getTime()) ? "-" : date.toLocaleDateString("zh-CN", { month: "2-digit", day: "2-digit" });
}
function formatAssetDateTime(value: string) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "-";
return date.toLocaleString("zh-CN", { year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" });
}
function audioWaveBars(seed: string) {
let hash = 0;
for (const char of seed) hash = (hash * 31 + char.charCodeAt(0)) >>> 0;
const bars: number[] = [];
for (let index = 0; index < 26; index += 1) {
hash = (hash * 9301 + 49297) % 233280;
const random = hash / 233280;
const envelope = 0.35 + 0.65 * Math.abs(Math.sin(index * 0.55 + 1.2));
bars.push(Math.round((0.18 + 0.82 * random * envelope) * 100));
}
return bars;
}

View File

@@ -107,7 +107,7 @@ export function CanvasTopBar({
return (
<>
<div className="pointer-events-none absolute left-0 right-0 top-0 z-[var(--z-toolbar)] flex h-[var(--canvas-topbar-h)] items-center justify-between px-[var(--canvas-inset-x)]">
<div className="canvas-topbar pointer-events-none absolute left-0 right-0 top-0 z-[var(--z-toolbar)] flex h-[var(--canvas-topbar-h)] items-center justify-between px-4 sm:px-5">
<div className="pointer-events-auto flex min-w-0 items-center gap-3">
<Dropdown
trigger={["click"]}

View File

@@ -2,7 +2,7 @@ import { useEffect, useMemo, useRef, useState } from "react";
import { useNavigate, useSearchParams } from "react-router";
import { useQuery } from "@tanstack/react-query";
import { App, Button, Dropdown, Modal, Select } from "antd";
import { ArrowDownAZ, Clock3, Download, FileUp, ListFilter, MoreHorizontal, Search, SlidersHorizontal, Trash2, X } from "lucide-react";
import { ArrowDownAZ, Clock3, Download, FileUp, ListFilter, MoreHorizontal, Plus, Search, SlidersHorizontal, Trash2, X } from "lucide-react";
import { CollectionGrid, PageHeader, PaginationBar, WorkspacePage } from "@/components/layout/workspace-page";
import { WorkspaceLoadingState, WorkspaceState } from "@/components/layout/workspace-state";
@@ -163,12 +163,14 @@ export default function CanvasPage() {
return (
<WorkspacePage grid className="canvas-library-page">
<div className="studio-band">
<PageHeader
icon="canvas"
title="画布"
description="把镜头、素材和想法留在同一张画布里。"
meta={<span className="app-projects-header-meta">{projects.length} 个</span>}
actions={(
<>
<Button className="library-primary-action !h-9 !px-3.5" type="primary" disabled={!hydrated} icon={<Plus className="size-3.5" />} onClick={createAndEnter}>新建画布</Button>
{projects.length ? (
<Dropdown menu={{ items: [{ key: "delete-all", danger: true, icon: <Trash2 className="size-3.5" />, label: "删除全部画布", onClick: () => setDeleteIds(projects.map((project) => project.id)) }] }} trigger={["click"]}>
<Button className="!h-9 !w-9 !p-0" aria-label="更多画布操作" title="更多操作" icon={<MoreHorizontal className="size-4" />} />
@@ -196,7 +198,9 @@ export default function CanvasPage() {
</div>
<span className="canvas-library-count"><strong>{String(filteredProjects.length).padStart(2, "0")}</strong><span>/ {String(projects.length).padStart(2, "0")} 画布</span></span>
</section>
</div>
<div className="canvas-library-frame">
{selectedIds.length ? (
<div className="app-canvas-selection-toolbar mt-2 flex min-h-10 flex-wrap items-center gap-2 rounded-md border px-3 py-1.5 text-xs">
<strong className="mr-auto font-medium">已选 {selectedIds.length} 个画布</strong>
@@ -221,6 +225,7 @@ export default function CanvasPage() {
)}
<PaginationBar current={page} pageSize={pageSize} total={filteredProjects.length} pageSizeOptions={[12, 24, 48]} onChange={(nextPage, nextPageSize) => { setPage(nextPageSize !== pageSize ? 1 : nextPage); setPageSize(nextPageSize); }} />
</div>
<input ref={inputRef} type="file" accept="application/zip,.zip" className="hidden" onChange={(event) => void importCanvas(event.target.files?.[0])} />
<Modal title="加入项目" open={associationOpen} okText="保存关联" cancelText="取消" okButtonProps={{ disabled: !associationProjectId, loading: projectQuery.isFetching }} onCancel={() => setAssociationOpen(false)} onOk={() => void associateSelected()}>

View File

@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type ReactNode, type RefObject } from "react";
import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type CSSProperties, type ReactNode, type RefObject } from "react";
import localforage from "localforage";
import { App, Drawer, Modal, Popover, Spin, Tooltip } from "antd";
import { ArrowUp, Check, ChevronDown, Clock3, Download, FileText, Film, FolderOpen, History, Image as ImageIcon, LoaderCircle, Maximize2, MessageSquareText, Music2, Plus, RefreshCw, Search, SlidersHorizontal, Sparkles, Square, Upload, X } from "lucide-react";
import { ArrowDown, ArrowUp, Check, ChevronDown, Clapperboard, Clock3, Download, FileText, Film, FolderOpen, History, Image as ImageIcon, LoaderCircle, Maximize2, MessageSquareText, Music2, Plus, RefreshCw, Search, SlidersHorizontal, Sparkles, Square, Upload, X } from "lucide-react";
import ReactMarkdown from "react-markdown";
import { Link } from "react-router";
@@ -48,6 +48,7 @@ type CreationConversation = { id: string; title: string; updatedAt: string; mess
const STORAGE_KEY = "creation-conversations-v1";
const modeLabels: Record<CreationMode, string> = { text: "文本", image: "图片", video: "视频" };
const shotScriptLabels: Record<CreationMode, string> = { text: "创作思路", image: "画面指令", video: "镜头脚本" };
const ratioOptions = [
{ value: "1:1", label: "方形" },
{ value: "16:9", label: "横屏" },
@@ -68,6 +69,7 @@ const qualityOptions = [
const resolutionOptions = VIDEO_RESOLUTION_OPTIONS.map((value) => ({ value: String(value), label: videoResolutionLabel(value) }));
const countOptions = ["1", "2", "3", "4"];
const conversationTimeFormatter = new Intl.DateTimeFormat("zh-CN", { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", hour12: false });
const messageTimeFormatter = new Intl.DateTimeFormat("zh-CN", { hour: "2-digit", minute: "2-digit", hour12: false });
function newConversation(): CreationConversation {
return { id: createClientId(), title: "新创作", updatedAt: new Date().toISOString(), messages: [] };
@@ -77,8 +79,24 @@ function newMessage(role: CreationMessage["role"], content: string, extra: Parti
return { id: createClientId(), role, content, createdAt: new Date().toISOString(), ...extra };
}
type CreationShot = { user?: CreationMessage; result?: CreationMessage };
type CreationImageResult = NonNullable<BackendGenerationResult["images"]>[number];
function shotsFromMessages(messages: CreationMessage[]): CreationShot[] {
const shots: CreationShot[] = [];
for (const message of messages) {
if (message.role === "user") {
shots.push({ user: message });
} else if (shots.length) {
shots[shots.length - 1].result = message;
} else {
shots.push({ result: message });
}
}
return shots;
}
async function persistCreationImageResult(image: CreationImageResult): Promise<UploadedImage> {
if (!image.storageKey) return uploadImage(image.dataUrl);
const url = await resolveImageUrl(image.storageKey, image.dataUrl);
@@ -121,10 +139,13 @@ export default function CreatePage() {
const [videoQuality, setVideoQuality] = useState(config.vquality || "720");
const [count, setCount] = useState(String(Math.max(1, Math.min(4, Number(config.count) || 1))));
const [busy, setBusy] = useState(false);
const [selectedShotIndex, setSelectedShotIndex] = useState(-1);
const [composingNextShot, setComposingNextShot] = useState(false);
const [historyOpen, setHistoryOpen] = useState(false);
const [libraryOpen, setLibraryOpen] = useState(false);
const abortRef = useRef<AbortController | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const composerFocusRef = useRef<HTMLTextAreaElement>(null);
const threadScrollRef = useRef<HTMLElement>(null);
const followLatestMessageRef = useRef(true);
const taskSyncWarningRef = useRef(false);
@@ -142,6 +163,8 @@ export default function CreatePage() {
const isEmpty = !activeConversation?.messages.length;
const pendingMediaKey = useMemo(() => pendingCreationMediaKey(conversations), [conversations]);
const pendingTaskIds = useMemo(() => pendingCreationTaskIds(conversations), [conversations]);
const shots = useMemo(() => shotsFromMessages(activeConversation?.messages || []), [activeConversation]);
const visibleShotIndex = shots.length ? selectedShotIndex >= 0 && selectedShotIndex < shots.length ? selectedShotIndex : shots.length - 1 : -1;
useEffect(() => {
if (mode !== "image") return;
@@ -364,6 +387,8 @@ export default function CreatePage() {
setPrompt("");
setAttachments([]);
setDraftReferences([]);
setSelectedShotIndex(-1);
setComposingNextShot(false);
setBusy(true);
const controller = new AbortController();
abortRef.current = controller;
@@ -454,6 +479,8 @@ export default function CreatePage() {
setPrompt("");
setAttachments([]);
setDraftReferences([]);
setSelectedShotIndex(-1);
setComposingNextShot(false);
setHistoryOpen(false);
};
@@ -463,6 +490,8 @@ export default function CreatePage() {
setPrompt("");
setAttachments([]);
setDraftReferences([]);
setSelectedShotIndex(-1);
setComposingNextShot(false);
setHistoryOpen(false);
};
@@ -487,6 +516,8 @@ export default function CreatePage() {
if (!previous?.content || busy) return;
followLatestMessageRef.current = true;
restoreMessageDraft(previous);
setSelectedShotIndex(-1);
setComposingNextShot(false);
const removedIds = new Set([item.id, previous.id]);
updateActive((conversation) => {
const messages = conversation.messages.filter((message) => !removedIds.has(message.id));
@@ -514,6 +545,16 @@ export default function CreatePage() {
followLatestMessageRef.current = container.scrollHeight - container.scrollTop - container.clientHeight <= 160;
};
const nextShotNumber = shots.length + 1;
const beginComposeNextShot = () => {
setComposingNextShot(true);
setSelectedShotIndex(-1);
window.requestAnimationFrame(() => composerFocusRef.current?.focus());
};
const cancelComposeNextShot = () => setComposingNextShot(false);
const composerProps = {
mode,
prompt,
@@ -541,28 +582,63 @@ export default function CreatePage() {
setVideoQuality,
count,
setCount,
composerFocusRef,
placeholderOverride: composingNextShot ? `SC.${String(nextShotNumber).padStart(2, "0")} · 写下这一镜的镜头、画面或故事` : undefined,
onSubmit: submit,
onStop: () => abortRef.current?.abort(),
};
const visibleShot = shots[visibleShotIndex];
const visibleShotResultIndex = visibleShot?.result ? activeConversation.messages.indexOf(visibleShot.result) : -1;
return <>
<div className="creation-home relative flex h-full min-h-0 flex-col overflow-hidden">
<div className="creation-top-actions">
{!isEmpty ? <Tooltip title="新建创作"><button type="button" aria-label="新建创作" className="creation-top-action" onClick={startNewConversation}><Plus /></button></Tooltip> : null}
<Tooltip title="历史对话"><button type="button" aria-label="查看历史对话" aria-expanded={historyOpen} className="creation-top-action" onClick={() => setHistoryOpen(true)}><History /></button></Tooltip>
</div>
<main ref={threadScrollRef} onScroll={handleThreadScroll} className="creation-scrollbar flex h-full min-h-0 flex-col overflow-y-scroll overscroll-contain">
{isEmpty ? <section className="creation-empty-workspace">
<CreationEmptyArt />
<CreationIntro mode={mode} />
<div className="creation-empty-composer"><CreationComposer {...composerProps} variant="empty" /></div>
</section> : <>
<section className="creation-thread-stage"><div className="creation-results">{activeConversation.messages.map((item, index) => <CreationMessageView key={item.id} item={item} modelName={item.model ? modelDisplayName(config, item.model) : ""} onRetryFailure={() => retryFailedMessage(item, index)} onCreateVariant={() => createVariant(item, index)} />)}</div></section>
<section className="creation-thread-composer">
<CreationComposer {...composerProps} variant="thread" />
</section>
</>}
{isEmpty ? <>
<div className="creation-top-actions">
<Tooltip title="历史对话"><button type="button" aria-label="查看历史对话" aria-expanded={historyOpen} className="creation-top-action" onClick={() => setHistoryOpen(true)}><History /></button></Tooltip>
</div>
<main ref={threadScrollRef} onScroll={handleThreadScroll} className="creation-empty-workspace creation-scrollbar">
<CreationEmptyBanner />
<div className="creation-chat-intro">
<span className="creation-intro-signal" aria-hidden="true" />
<p>影策 · AI 影视创作工作台</p>
<h1>把脑海里的画面,<span className="creation-intro-emphasis"><span className="is-pink">交给影策</span><span className="is-blue">拍出来</span></span></h1>
</div>
<div className="creation-empty-composer">
<CreationComposer {...composerProps} variant="empty" />
</div>
<CreationEmptySuggest
onStartPrompt={(nextMode, prompt) => { selectMode(nextMode); setPrompt(prompt); window.requestAnimationFrame(() => composerFocusRef.current?.focus()); }}
onOpenLibrary={() => { selectMode("image"); setLibraryOpen(true); }}
/>
</main>
</> : <div className="storyboard-workbench">
<StoryboardToolbar
shots={shots}
activeIndex={visibleShotIndex}
composing={composingNextShot}
onSelect={(index) => { setSelectedShotIndex(index); setComposingNextShot(false); }}
onBeginCompose={beginComposeNextShot}
onCancelCompose={cancelComposeNextShot}
onNewConversation={startNewConversation}
onOpenHistory={() => setHistoryOpen(true)}
/>
<main ref={threadScrollRef} onScroll={handleThreadScroll} className="storyboard-workbench-stage creation-scrollbar">
<div className="storyboard-workbench-stage-inner">
{composingNextShot ? <StoryboardNextShotCard shotNumber={nextShotNumber} onCancel={cancelComposeNextShot} /> : visibleShot ? <StoryboardShotCard
shot={visibleShot}
shotNumber={visibleShotIndex + 1}
modelName={visibleShot.result?.model ? modelDisplayName(config, visibleShot.result.model) : ""}
busy={busy}
onRetryFailure={() => { if (visibleShotResultIndex >= 0 && visibleShot.result) retryFailedMessage(visibleShot.result, visibleShotResultIndex); }}
onCreateVariant={() => { if (visibleShotResultIndex >= 0 && visibleShot.result) createVariant(visibleShot.result, visibleShotResultIndex); }}
/> : null}
</div>
</main>
<section className="storyboard-workbench-composer">
<CreationComposer {...composerProps} variant="thread" />
</section>
</div>}
</div>
<CreationHistoryDrawer open={historyOpen} conversations={historyConversations} activeId={activeConversation.id} onClose={() => setHistoryOpen(false)} onSelect={selectConversation} />
<CreationAssetLibraryModal open={libraryOpen} assets={assets} mode={mode} selectedIds={new Set(attachments.filter((item) => item.id.startsWith("asset:")).map((item) => item.id.slice(6)))} onClose={() => setLibraryOpen(false)} onConfirm={handleLibrarySelect} onUpload={uploadLibraryAssets} />
@@ -683,22 +759,6 @@ function CreationHistoryDrawer({ open, conversations, activeId, onClose, onSelec
</Drawer>;
}
function CreationMessageView({ item, modelName, onRetryFailure, onCreateVariant }: { item: CreationMessage; modelName: string; onRetryFailure: () => void; onCreateVariant: () => void }) {
if (item.role === "user") return <CreationUserMessage item={item} />;
const mode = item.mode || "text";
const stateLabel = item.status === "pending" ? "生成中" : item.status === "cancelled" ? "已停止" : "";
return <article className="creation-assistant-message"><div className="creation-message-heading"><span className="creation-message-mark"><Sparkles /></span><span>{modeLabels[mode]}</span>{modelName ? <span className="creation-message-model">{modelName}</span> : null}{stateLabel ? <span className={`creation-message-state is-${item.status}`}>{stateLabel}</span> : null}</div>{mode === "text" ? <div className="creation-message-content">{item.content ? <ReactMarkdown>{item.content}</ReactMarkdown> : <span>正在生成…</span>}</div> : <MediaResult item={item} onRetryFailure={onRetryFailure} onCreateVariant={onCreateVariant} />}{item.error ? <div className="creation-message-error"><span>{generationErrorMessage(item.error)}</span><button type="button" onClick={onRetryFailure}><RefreshCw />重新生成</button></div> : null}</article>;
}
function CreationUserMessage({ item }: { item: CreationMessage }) {
const [previewUrl, setPreviewUrl] = useState("");
const [previewType, setPreviewType] = useState<"image" | "video">("image");
return <div className="creation-user-message"><div>{displayCreationPrompt(item.content, item.references || [])}</div>{item.references?.length ? <CreationMessageReferences references={item.references} /> : null}{item.attachments?.length ? <div className="creation-user-message-attachments">{item.attachments.map((attachment) => {
const url = attachment.previewUrl || ("dataUrl" in attachment ? attachment.dataUrl : attachment.url) || "";
return <button key={attachment.id} type="button" onClick={() => { const video = isVideoAttachment(attachment); setPreviewType(video ? "video" : "image"); setPreviewUrl(video ? attachment.url : url); }} aria-label={`预览 ${attachment.name}`} disabled={!url}>{isVideoAttachment(attachment) ? <video src={attachment.url} poster={url !== attachment.url ? url : undefined} muted playsInline preload="metadata" /> : <img src={url} alt={attachment.name} width={44} height={44} loading="lazy" />}<span aria-hidden="true"><Maximize2 /></span></button>;
})}</div> : null}<CreationMediaPreviewModal url={previewUrl} type={previewType} onClose={() => setPreviewUrl("")} /></div>;
}
function CreationMessageReferences({ references }: { references: CreationReference[] }) {
return <div className="creation-user-message-references" aria-label="本次引用">{references.map((reference) => {
const Icon = reference.kind === "skill" ? Sparkles : reference.kind === "image" ? ImageIcon : reference.kind === "video" ? Film : reference.kind === "audio" ? Music2 : FileText;
@@ -706,17 +766,6 @@ function CreationMessageReferences({ references }: { references: CreationReferen
})}</div>;
}
function MediaResult({ item, onRetryFailure, onCreateVariant }: { item: CreationMessage; onRetryFailure: () => void; onCreateVariant: () => void }) {
const [previewUrl, setPreviewUrl] = useState("");
const [previewType, setPreviewType] = useState<"image" | "video">("image");
const resultUrls = item.resultUrls;
const openPreview = (url: string, type: "image" | "video") => { setPreviewType(type); setPreviewUrl(url); };
if (item.status === "pending") return <div className="creation-media-pending"><Spin size="small" />正在生成{item.mode === "video" ? "视频" : "图片"}…</div>;
if ((item.status === "error" || item.status === "cancelled") && !resultUrls?.length) return null;
if (!resultUrls?.length) return <div className="creation-media-empty">没有返回可预览结果 <button type="button" onClick={onRetryFailure}>重试</button></div>;
return <div className="creation-media-result">{item.mode === "video" ? <button type="button" className="creation-video-result" onClick={() => openPreview(resultUrls[0], "video")} aria-label="预览生成视频"><video muted preload="metadata" className="size-full object-cover" src={resultUrls[0]} /><span><Maximize2 />预览视频</span></button> : <div className="creation-image-result-grid">{resultUrls.map((url) => <button key={url} type="button" className="creation-image-result" onClick={() => openPreview(url, "image")} aria-label="预览生成图片"><img src={url} alt="生成结果" /><span><Maximize2 /></span></button>)}</div>}<div className="creation-media-actions"><span>{item.mode === "video" ? "视频结果" : `${resultUrls.length} 张图片`}</span><button type="button" onClick={onCreateVariant}><RefreshCw />生成变体</button><Link to="/canvas">添加到画布</Link>{resultUrls.map((url, index) => <a key={`${url}-download`} href={url} download>{resultUrls.length > 1 ? `下载 ${index + 1}` : <><Download />下载</>}</a>)}</div><CreationMediaPreviewModal url={previewUrl} type={previewType} onClose={() => setPreviewUrl("")} /></div>;
}
function CreationMediaPreviewModal({ url, type, onClose }: { url: string; type: "image" | "video"; onClose: () => void }) {
return <Modal open={Boolean(url)} title={null} footer={null} centered destroyOnHidden width={type === "video" ? "min(1160px, calc(100vw - 32px))" : "min(980px, calc(100vw - 32px))"} onCancel={onClose} className="creation-media-preview-modal" styles={{ body: { padding: 0 } }}>{url ? type === "video" ? <video controls autoPlay className="creation-media-preview-video" src={url} /> : <img className="creation-media-preview-image" src={url} alt="媒体预览" /> : null}</Modal>;
}
@@ -749,6 +798,8 @@ type ComposerProps = {
setVideoQuality: (value: string) => void;
count: string;
setCount: (value: string) => void;
composerFocusRef: RefObject<HTMLTextAreaElement | null>;
placeholderOverride?: string;
onSubmit: () => void;
onStop: () => void;
};
@@ -771,7 +822,7 @@ function CreationComposer(props: ComposerProps) {
<input ref={props.fileInputRef} type="file" hidden accept={props.mode === "video" ? "image/*,video/*" : "image/*"} multiple onChange={props.onFileChange} />
<Tooltip title={!referencesSupported ? "当前模型不支持参考媒体" : "从素材库选择参考内容"}><button type="button" className="creation-chat-reference is-paper" onClick={props.onOpenLibrary} disabled={props.busy || !referencesSupported} aria-label="打开素材库选择参考内容"><Plus /><span>参考内容</span></button></Tooltip>
<div className="creation-chat-editor">
<CanvasResourceMentionTextarea value={props.prompt} references={props.references} mentionMenuWidth={400} sendOnEnter={false} onChange={props.setPrompt} onSubmit={props.onSubmit} containerClassName="creation-chat-mention-container" className="creation-chat-mention-editor creation-scrollbar" style={{ color: "var(--creation-text)" }} placeholder={props.variant === "empty" ? emptyPlaceholder : placeholder} aria-label="创作提示词,可使用 @ 引用当前参考内容或技能" spellCheck disabled={props.busy} />
<CanvasResourceMentionTextarea ref={props.composerFocusRef} value={props.prompt} references={props.references} mentionMenuWidth={400} sendOnEnter={false} onChange={props.setPrompt} onSubmit={props.onSubmit} containerClassName="creation-chat-mention-container" className="creation-chat-mention-editor creation-scrollbar" style={{ color: "var(--creation-text)" }} placeholder={props.placeholderOverride || (props.variant === "empty" ? emptyPlaceholder : placeholder)} aria-label="创作提示词,可使用 @ 引用当前参考内容或技能" spellCheck disabled={props.busy} />
{props.attachments.length ? <div className="creation-chat-attachment-strip">{props.attachments.map((item) => {
const isVideo = isVideoAttachment(item);
const url = isVideo ? item.url : item.previewUrl;
@@ -857,48 +908,211 @@ function DurationMenu({ profile, seconds, onChange }: { profile: VideoCapability
</Popover>;
}
const creationEmptyArtLibrary = [
"black-white-noir.jpg", "chinese-2d.jpg", "clay-stop-motion.jpg", "comic-pop.jpg", "cyberpunk-neon.jpg", "fantasy-3d.jpg",
"future-tech.jpg", "ink-narrative.jpg", "nature-healing.jpg", "period-live-action.jpg", "real-life.jpg", "retro-hong-kong.jpg",
"space-opera.jpg", "storybook-fantasy.jpg", "surreal-dream.jpg", "suspense-noir.jpg", "three-d-cartoon.jpg", "urban-live-action.jpg",
].map((file) => `/short-drama-styles/${file}`);
const creationEmptyBannerFrames = [
{ src: "/short-drama-styles/cyberpunk-neon.jpg", caption: "SC.01 · 雨夜霓虹" },
{ src: "/short-drama-styles/suspense-noir.jpg", caption: "SC.02 · 暗巷追逐" },
{ src: "/short-drama-styles/retro-hong-kong.jpg", caption: "SC.03 · 天台重逢" },
];
function CreationEmptyArt() {
const frames = useMemo(() => shuffleCreationArt(creationEmptyArtLibrary), []);
const [activeIndex, setActiveIndex] = useState(0);
useEffect(() => {
const timer = window.setInterval(() => setActiveIndex((current) => (current + 1) % frames.length), 4200);
return () => window.clearInterval(timer);
}, [frames.length]);
const imageAt = (offset: number) => frames[(activeIndex + offset) % frames.length];
return <div className="creation-empty-art" aria-label="随机轮播的创作风格参考图">
<div className="creation-empty-art-frame is-back"><img key={imageAt(0)} src={imageAt(0)} alt="" /></div>
<div className="creation-empty-art-frame is-main"><img key={imageAt(1)} src={imageAt(1)} alt="" /><span>你的下一帧,从这里开始</span></div>
<div className="creation-empty-art-frame is-front"><img key={imageAt(2)} src={imageAt(2)} alt="" /></div>
<div className="creation-empty-art-caption"><span>{String(activeIndex + 1).padStart(2, "0")}</span><span>镜头 · 氛围 · 故事</span></div>
function CreationEmptyBanner() {
return <div className="creation-empty-art" aria-hidden="true">
{creationEmptyBannerFrames.map((frame, index) => <figure key={frame.caption} className={`creation-empty-art-frame ${index === 1 ? "is-main" : index === 0 ? "is-back" : "is-front"}`}>
<img src={frame.src} alt="" />
<span>{frame.caption}</span>
</figure>)}
<span className="creation-empty-art-caption"><span>影策</span>把每一帧,交给镜头导演</span>
</div>;
}
function shuffleCreationArt(items: string[]) {
const shuffled = [...items];
for (let index = shuffled.length - 1; index > 0; index -= 1) {
const target = Math.floor(Math.random() * (index + 1));
[shuffled[index], shuffled[target]] = [shuffled[target], shuffled[index]];
}
return shuffled;
const creationEmptySuggestions: Array<{ mode: CreationMode; icon: typeof Clapperboard; title: string; hint: string; prompt: string; openLibrary?: boolean }> = [
{ mode: "video", icon: Clapperboard, title: "生成第一个镜头", hint: "描述画面、镜头运动与光线", prompt: "雨夜天台,镜头缓缓推近霓虹灯牌下的主角,她回眸看向镜头,强对比电影感布光" },
{ mode: "image", icon: ImageIcon, title: "从参考图开始", hint: "上传风格图,生成同风格画面", prompt: "", openLibrary: true },
{ mode: "text", icon: FileText, title: "续写故事", hint: "和 AI 讨论剧情、角色与对白", prompt: "帮我续写一个短剧故事,先聊聊剧情走向:" },
{ mode: "video", icon: Sparkles, title: "引用技能增强", hint: "@技能 调用分镜、配音等专业能力", prompt: "调用分镜技能,帮我规划这个镜头的拍摄方案:" },
];
function CreationEmptySuggest({ onStartPrompt, onOpenLibrary }: { onStartPrompt: (mode: CreationMode, prompt: string) => void; onOpenLibrary: () => void }) {
return <div className="creation-empty-suggest">
{creationEmptySuggestions.map((item) => {
const Icon = item.icon;
return <button key={item.title} type="button" className="suggest-card" onClick={() => { if (item.openLibrary) onOpenLibrary(); else onStartPrompt(item.mode, item.prompt); }}>
<span className={`suggest-icon is-${item.mode}`}><Icon size={15} strokeWidth={2} /></span>
<span className="suggest-copy"><strong>{item.title}</strong><span>{item.hint}</span></span>
</button>;
})}
</div>;
}
function CreationIntro({ mode }: { mode: CreationMode }) {
const copy = mode === "video" ? ["让", "想象", ",先在镜头里发生", "影策 · AI 叙事创作"] : mode === "image" ? ["让", "画面", ",从一个想法开始", "影策 · 视觉创作"] : ["把", "故事", ",写在第一句话里", "影策 · 叙事创作"];
return <header className="creation-chat-intro" aria-live="polite"><span className="creation-intro-signal" aria-hidden="true" /><h1>{copy[0]}<span className="creation-intro-emphasis"><span className="is-pink">{copy[1].slice(0, 1)}</span><span className="is-blue">{copy[1].slice(1)}</span></span>{copy[2]}</h1><p>{copy[3]}</p></header>;
type CreationThinking = { title: string; hint: string; steps: string[] };
function thinkingFor(mode: CreationMode): CreationThinking {
if (mode === "image") return { title: "正在为你画这一镜", hint: "影策正在理解你的构图意图,并把画面交给模型出图。", steps: ["理解构图", "定调画风", "生成画面"] };
if (mode === "text") return { title: "正在为你写这段", hint: "影策正在梳理你的创作脉络,组织语言与结构。", steps: ["梳理脉络", "组织语言", "输出段落"] };
return { title: "正在为你拍这一镜", hint: "影策正在拆解你的镜头脚本,设计运镜与光线,并交给模型渲染成片。", steps: ["拆解镜头", "设计运镜", "定调布光", "渲染成片"] };
}
function directorNoteFor(mode: CreationMode, settings: CreationSettings): string {
if (mode === "video") return `已按 ${settings.seconds}s · ${videoResolutionLabel(settings.videoQuality)} · ${settings.ratio} 渲染这一镜,等待你的下一句指令。`;
if (mode === "image") return `已按 ${settings.ratio} 出图 ${settings.count} 张,等待你的下一句指令。`;
return "";
}
function StoryboardToolbar({ shots, activeIndex, composing, onSelect, onBeginCompose, onCancelCompose, onNewConversation, onOpenHistory }: { shots: CreationShot[]; activeIndex: number; composing: boolean; onSelect: (index: number) => void; onBeginCompose: () => void; onCancelCompose: () => void; onNewConversation: () => void; onOpenHistory: () => void }) {
const [railOpen, setRailOpen] = useState(false);
const nextShotNumber = shots.length + 1;
const closeRail = () => setRailOpen(false);
const statusOf = (shot: CreationShot) => shot.result?.status || "queued";
const shotTitle = (shot: CreationShot) => shot.user ? displayCreationPrompt(shot.user.content, shot.user.references || []).trim() || "未命名镜头" : "镜头";
return <header className="storyboard-workbench-bar" aria-label="镜头工具条">
<div className="storyboard-workbench-rail">
<Tooltip title="镜头时间线"><button type="button" className={`storyboard-workbench-rail-button${railOpen ? " is-open" : ""}${composing ? " is-draft" : ""}`} aria-expanded={railOpen} aria-label="镜头时间线" onClick={() => setRailOpen((value) => !value)}><Film /><span className="storyboard-workbench-rail-badge">{composing ? nextShotNumber : shots.length}</span></button></Tooltip>
{railOpen ? <div className="storyboard-workbench-rail-pop" role="listbox" aria-label="镜头列表">
<div className="storyboard-workbench-rail-pop-head"><span className="storyboard-workbench-rail-pop-title"><Clapperboard />镜头时间线<small>{composing ? `下一镜 SC.${String(nextShotNumber).padStart(2, "0")}` : `${shots.length} 个镜头`}</small></span><button type="button" className="storyboard-workbench-rail-pop-close" aria-label="关闭镜头列表" onClick={closeRail}><X /></button></div>
<ul className="creation-scrollbar">
{shots.map((shot, index) => {
const status = statusOf(shot);
const title = shotTitle(shot);
const thumbUrl = shot.result?.resultUrls?.[0];
const thumbIsVideo = shot.result?.mode === "video";
return <li key={shot.user?.id || shot.result?.id || index}>
<button type="button" className={`storyboard-workbench-rail-row${index === activeIndex && !composing ? " is-active" : ""}`} onClick={() => { onSelect(index); closeRail(); }}>
<span className="storyboard-workbench-rail-thumb">{thumbUrl ? (thumbIsVideo ? <video muted preload="metadata" src={thumbUrl} /> : <img src={thumbUrl} alt="" />) : <span className="storyboard-workbench-rail-thumb-ph"><Clapperboard /><em>SC.{String(index + 1).padStart(2, "0")}</em></span>}</span>
<span className="storyboard-workbench-rail-info">
<span className="storyboard-workbench-rail-head"><span className="storyboard-workbench-rail-row-shot">SC.{String(index + 1).padStart(2, "0")}</span><span className={`storyboard-workbench-rail-row-state is-${status}`}>{status === "pending" ? "生成中" : status === "error" ? "失败" : status === "done" ? "完成" : "待生成"}</span>{shot.result?.createdAt ? <time dateTime={shot.result.createdAt}>{formatMessageTime(shot.result.createdAt)}</time> : null}</span>
<span className="storyboard-workbench-rail-row-title">{title}</span>
</span>
</button>
</li>;
})}
{composing ? <li><button type="button" className="storyboard-workbench-rail-row is-draft" onClick={() => { onCancelCompose(); closeRail(); }}><span className="storyboard-workbench-rail-thumb"><span className="storyboard-workbench-rail-thumb-ph"><Clapperboard /><em>SC.{String(nextShotNumber).padStart(2, "0")}</em></span></span><span className="storyboard-workbench-rail-info"><span className="storyboard-workbench-rail-head"><span className="storyboard-workbench-rail-row-shot">SC.{String(nextShotNumber).padStart(2, "0")}</span><span className="storyboard-workbench-rail-row-state">待撰写</span></span><span className="storyboard-workbench-rail-row-title">等待你的脚本</span></span></button></li> : null}
</ul>
<button type="button" className="storyboard-workbench-rail-pop-add" onClick={() => { closeRail(); onBeginCompose(); }}><Plus />新增镜头</button>
</div> : null}
</div>
<div className="storyboard-workbench-bar-actions">
<Tooltip title={composing ? "收起下一镜" : "新增镜头"}><button type="button" aria-label={composing ? "收起下一镜" : "新增镜头"} className="storyboard-workbench-bar-action" onClick={composing ? onCancelCompose : onBeginCompose}>{composing ? <X /> : <Clapperboard />}</button></Tooltip>
<Tooltip title="新建创作"><button type="button" aria-label="新建创作" className="storyboard-workbench-bar-action" onClick={onNewConversation}><Plus /></button></Tooltip>
<Tooltip title="历史对话"><button type="button" aria-label="查看历史对话" className="storyboard-workbench-bar-action" onClick={onOpenHistory}><History /></button></Tooltip>
</div>
</header>;
}
function StoryboardShotCard({ shot, shotNumber, modelName, busy, onRetryFailure, onCreateVariant }: { shot: CreationShot; shotNumber: number; modelName: string; busy: boolean; onRetryFailure: () => void; onCreateVariant: () => void }) {
const user = shot.user;
const result = shot.result;
const status = result?.status || "queued";
const mode = result?.mode || user?.mode || "video";
const briefVisible = Boolean(user?.content.trim() || user?.references?.length || user?.attachments?.length);
return <article className={`storyboard-workbench-card is-${status}`}>
<header className="storyboard-workbench-card-head">
<div className="storyboard-workbench-card-heading">
<span className="storyboard-workbench-card-shot"><span className="storyboard-workbench-card-shot-index">SC.{String(shotNumber).padStart(2, "0")}</span>镜头 {shotNumber}</span>
<span className="storyboard-workbench-card-mode">{mode === "video" ? <Film /> : mode === "image" ? <ImageIcon /> : <MessageSquareText />}{modeLabels[mode]}</span>
{modelName ? <span className="storyboard-workbench-card-model">{modelName}</span> : null}
{status === "pending" ? <span className="storyboard-workbench-card-state is-pending"><LoaderCircle className="animate-spin" />生成中</span> : status === "error" ? <span className="storyboard-workbench-card-state is-error">生成失败</span> : status === "done" ? <span className="storyboard-workbench-card-state is-done"><Check />已完成</span> : <span className="storyboard-workbench-card-state">待生成</span>}
</div>
<div className="storyboard-workbench-card-actions">
{status === "error" ? <button type="button" onClick={onRetryFailure} disabled={busy}><RefreshCw />重新生成</button> : null}
{status === "done" && result?.resultUrls?.length ? <button type="button" onClick={onCreateVariant} disabled={busy}><RefreshCw />生成变体</button> : null}
{status === "done" && result?.resultUrls?.length ? <Link to="/canvas">添加到画布</Link> : null}
{result?.resultUrls?.map((url, index) => <a key={`${url}-download`} href={url} download>{result.resultUrls!.length > 1 ? `下载 ${index + 1}` : <><Download />下载</>}</a>)}
</div>
</header>
<div className="storyboard-workbench-card-body">
<div className="storyboard-workbench-thread" aria-label={`镜头 ${shotNumber} 的对话过程`}>
{briefVisible && user ? <div className="storyboard-workbench-turn is-user">
<div className="storyboard-workbench-turn-copy">
<div className="storyboard-workbench-turn-meta"><span className="storyboard-workbench-turn-role">{shotScriptLabels[mode]}</span>{user.createdAt ? <time className="storyboard-workbench-turn-time" dateTime={user.createdAt}>{formatMessageTime(user.createdAt)}</time> : null}</div>
<div className="storyboard-workbench-turn-bubble">
<p className="storyboard-workbench-turn-text">{displayCreationPrompt(user.content, user.references || [])}</p>
{user.references?.length ? <CreationMessageReferences references={user.references} /> : null}
{user.attachments?.length ? <StoryboardBriefAttachments attachments={user.attachments} /> : null}
</div>
</div>
</div> : null}
{briefVisible && user ? <div className="storyboard-workbench-handoff" aria-hidden="true"><span className="storyboard-workbench-handoff-rail" /><span className="storyboard-workbench-handoff-badge"><ArrowDown />交给影策 AI</span><span className="storyboard-workbench-handoff-rail" /></div> : null}
<div className="storyboard-workbench-turn is-ai">
<span className="storyboard-workbench-ai-avatar"><Clapperboard /></span>
<div className="storyboard-workbench-turn-copy">
<div className="storyboard-workbench-turn-meta"><span className="storyboard-workbench-turn-role is-ai"><Sparkles />影策 AI</span>{modelName ? <span className="storyboard-workbench-turn-model">{modelName}</span> : null}{result?.createdAt ? <time className="storyboard-workbench-turn-time" dateTime={result.createdAt}>{formatMessageTime(result.createdAt)}</time> : null}</div>
<div className="storyboard-workbench-turn-bubble">
<StoryboardShotResult result={result} onRetryFailure={onRetryFailure} onCreateVariant={onCreateVariant} />
</div>
</div>
</div>
</div>
</div>
</article>;
}
function StoryboardNextShotCard({ shotNumber, onCancel }: { shotNumber: number; onCancel: () => void }) {
return <article className="storyboard-workbench-card is-next">
<header className="storyboard-workbench-card-head">
<div className="storyboard-workbench-card-heading">
<span className="storyboard-workbench-card-shot"><span className="storyboard-workbench-card-shot-index">SC.{String(shotNumber).padStart(2, "0")}</span>下一镜 {shotNumber}</span>
<span className="storyboard-workbench-card-state is-draft"><Clapperboard />待撰写</span>
</div>
<div className="storyboard-workbench-card-actions">
<button type="button" onClick={onCancel}><X />取消撰写</button>
</div>
</header>
<div className="storyboard-workbench-card-body">
<div className="storyboard-workbench-next-panel">
<span className="storyboard-workbench-next-panel-icon"><Clapperboard /></span>
<div className="storyboard-workbench-next-panel-copy">
<strong>SC.{String(shotNumber).padStart(2, "0")} 等待你的脚本</strong>
<span>在下方写下这一镜的镜头、画面或故事。影策会拆解脚本、设计运镜并渲染成片,这一镜会作为 SC.{String(shotNumber).padStart(2, "0")} 自动加入镜头轨道。</span>
</div>
</div>
</div>
</article>;
}
function StoryboardBriefAttachments({ attachments }: { attachments: CreationAttachment[] }) { const [previewUrl, setPreviewUrl] = useState("");
const [previewType, setPreviewType] = useState<"image" | "video">("image");
return <><div className="creation-user-message-attachments storyboard-workbench-brief-attachments">{attachments.map((attachment) => {
const isVideo = isVideoAttachment(attachment);
const url = attachment.previewUrl || ("dataUrl" in attachment ? attachment.dataUrl : attachment.url) || "";
return <button key={attachment.id} type="button" onClick={() => { setPreviewType(isVideo ? "video" : "image"); setPreviewUrl(isVideo ? attachment.url : url); }} aria-label={`预览 ${attachment.name}`} disabled={!url}>{isVideo ? <video src={attachment.url} poster={url !== attachment.url ? url : undefined} muted playsInline preload="metadata" /> : <img src={url} alt={attachment.name} width={44} height={44} loading="lazy" />}<span aria-hidden="true"><Maximize2 /></span></button>;
})}</div><CreationMediaPreviewModal url={previewUrl} type={previewType} onClose={() => setPreviewUrl("")} /></>;
}
function StoryboardShotResult({ result, onRetryFailure, onCreateVariant }: { result?: CreationMessage; onRetryFailure: () => void; onCreateVariant: () => void }) {
const [previewUrl, setPreviewUrl] = useState("");
const [previewType, setPreviewType] = useState<"image" | "video">("image");
const openPreview = (url: string, type: "image" | "video") => { setPreviewType(type); setPreviewUrl(url); };
if (!result) return <div className="storyboard-workbench-empty"><Film />这一镜还没开始——在下方写出你的脚本,我来接手。</div>;
const mode = result.mode || "video";
const status = result.status || "queued";
const resultUrls = result.resultUrls || [];
if (status === "pending" || status === "queued") {
const thinking = thinkingFor(mode);
return <div className="storyboard-workbench-pending"><div className="storyboard-workbench-thinking">
<span className="storyboard-workbench-thinking-copy"><strong>{thinking.title}</strong><span>{thinking.hint}</span></span>
<span className="storyboard-workbench-pipeline" aria-hidden="true">{thinking.steps.map((step, index) => <em key={step} style={{ "--step": index } as CSSProperties}><i>{String(index + 1).padStart(2, "0")}</i>{step}</em>)}</span>
</div></div>;
}
if (status === "error") return <div className="storyboard-workbench-error"><span>{generationErrorMessage(result.error || "")}</span><button type="button" onClick={onRetryFailure}><RefreshCw />重新生成</button></div>;
if (mode === "text") return <div className="creation-message-content storyboard-workbench-text">{result.content ? <ReactMarkdown>{result.content}</ReactMarkdown> : <span>正在生成…</span>}</div>;
if (!resultUrls.length) return <div className="storyboard-workbench-empty"><Film />没有返回可预览结果 <button type="button" onClick={onRetryFailure}>重试</button></div>;
const note = result.settings ? directorNoteFor(mode, result.settings) : "";
return <>
{mode === "video" ? <button type="button" className="creation-video-result" onClick={() => openPreview(resultUrls[0], "video")} aria-label="预览生成视频"><video muted preload="metadata" className="size-full object-cover" src={resultUrls[0]} /><span><Maximize2 />预览视频</span></button> : <div className="creation-image-result-grid">{resultUrls.map((url) => <button key={url} type="button" className="creation-image-result" onClick={() => openPreview(url, "image")} aria-label="预览生成图片"><img src={url} alt="生成结果" /><span><Maximize2 /></span></button>)}</div>}
{note ? <p className="storyboard-workbench-director-note"><span>导演手记</span>{note}</p> : null}
<div className="storyboard-workbench-media-meta"><span>{mode === "video" ? "视频结果" : `${resultUrls.length} 张图片`}</span><button type="button" onClick={onCreateVariant}><RefreshCw />生成变体</button><Link to="/canvas">添加到画布</Link>{resultUrls.map((url, index) => <a key={`${url}-download`} href={url} download>{resultUrls.length > 1 ? `下载 ${index + 1}` : <><Download />下载</>}</a>)}</div>
<CreationMediaPreviewModal url={previewUrl} type={previewType} onClose={() => setPreviewUrl("")} />
</>;
}
function videoResolutionLabel(value: string | number) {
return Number(String(value).replace(/p$/i, "")) === 2160 ? "4K" : `${String(value).replace(/p$/i, "")}P`;
}
function formatMessageTime(value: string) {
const timestamp = new Date(value).getTime();
return Number.isFinite(timestamp) ? conversationTimeFormatter.format(timestamp) : "";
}
function conversationPreviewMessage(conversation: CreationConversation) {
let fallback: CreationMessage | undefined;
for (let index = conversation.messages.length - 1; index >= 0; index -= 1) {

View File

@@ -1,13 +1,13 @@
import { useMemo, type CSSProperties, type ReactNode } from "react";
import { useMemo, type ReactNode } from "react";
import { useQuery } from "@tanstack/react-query";
import { App, Button } from "antd";
import { ArrowRight, BookOpenText, Bot, CheckCircle2, CircleAlert, Clapperboard, Clock3, FolderKanban, Images, LayoutGrid, ListChecks, Plus, Sparkles } from "lucide-react";
import { ArrowRight, Bot, Clapperboard, FolderKanban, Images, LayoutGrid, ListChecks, Plus, Sparkles } from "lucide-react";
import { Link, useNavigate } from "react-router";
import { CanvasProjectCard } from "@/components/canvas/canvas-project-card";
import { WorkspaceErrorState, WorkspaceLoadingState } from "@/components/layout/workspace-state";
import { WorkspaceSignalIcon } from "@/components/ui/aceternity/workspace-signal-icon";
import { projectAttentionCount, projectContinueTarget, projectDetailStage, projectNextActions, projectSummaryCompletion } from "@/lib/project-workbench";
import { projectDetailStage, projectSummaryCompletion } from "@/lib/project-workbench";
import { getProject, listProjects, type ProjectSummary } from "@/services/api/projects";
import { createCanvasProjectWithRemoteSync } from "@/services/user-data-sync";
import { useCanvasStore } from "@/stores/canvas/use-canvas-store";
@@ -59,17 +59,15 @@ export default function IndexPage() {
const loadingUserWorkspace = !userHydrated || (Boolean(user && shortDramaEnabled) && domainProjectsQuery.isLoading);
return (
<main className="app-user-content app-workspace-canvas app-workspace-scroll h-full overflow-y-auto text-foreground">
<div className="app-home-workbench mx-auto w-full max-w-[1440px] px-4 pb-12 pt-5 sm:px-6 lg:px-8">
<div className="app-home-workbench w-full px-4 pb-12 pt-5 sm:px-6 lg:px-8">
{loadingUserWorkspace ? (
<WorkspaceLoadingState className="mt-3 max-w-[980px]" label="正在恢复工作台" detail="读取项目、章节和最近画布" rows={5} />
<WorkspaceLoadingState className="mt-3" label="正在恢复工作台" detail="读取项目、章节和最近画布" rows={5} />
) : user && shortDramaEnabled && domainProjectsQuery.isError ? (
<WorkspaceErrorState title="项目工作台加载失败" description={domainProjectsQuery.error instanceof Error ? domainProjectsQuery.error.message : "暂时无法读取项目列表。"} onRetry={() => void domainProjectsQuery.refetch()} />
) : shortDramaEnabled && activeProject ? (
<ReturningWorkspace
summary={activeProject}
detail={activeProjectQuery.data}
detailLoading={activeProjectQuery.isLoading}
detailError={activeProjectQuery.isError}
recentProjects={domainProjects.slice(0, 5)}
recentIndependentCanvases={recentIndependentCanvases}
onCreateIndependentCanvas={createIndependentCanvas}
@@ -88,91 +86,45 @@ export default function IndexPage() {
);
}
function ReturningWorkspace({ summary, detail, detailLoading, detailError, recentProjects, recentIndependentCanvases, onCreateIndependentCanvas }: {
function ReturningWorkspace({ summary, detail, recentProjects, recentIndependentCanvases, onCreateIndependentCanvas }: {
summary: ProjectSummary;
detail?: Awaited<ReturnType<typeof getProject>>;
detailLoading: boolean;
detailError: boolean;
recentProjects: ProjectSummary[];
recentIndependentCanvases: ReturnType<typeof useCanvasStore.getState>["projects"];
onCreateIndependentCanvas: () => void;
}) {
const taskCenterEnabled = useUserStore((state) => state.features.taskCenterEnabled);
const stage = detail ? projectDetailStage(detail) : { label: "进行中", detail: "读取项目进度" };
const continueTarget = detail ? projectContinueTarget(detail) : { href: `/projects/${summary.project.id}/overview`, title: summary.project.name, context: "打开项目概览", updatedAt: summary.project.updatedAt };
const nextActions = detail ? projectNextActions(detail, 4).filter((action) => taskCenterEnabled || !action.href.startsWith("/tasks")).slice(0, 3) : [];
const completion = projectSummaryCompletion(summary);
const attentionCount = detail ? projectAttentionCount(detail) : 0;
return (
<>
<header className="app-home-header flex flex-col gap-4 border-b border-border/80 pb-5 sm:flex-row sm:items-center sm:justify-between">
<div className="flex min-w-0 items-center gap-3">
<WorkspaceSignalIcon variant="home" />
<div className="min-w-0">
<h1 className="text-[var(--fs-title)] font-semibold leading-7">继续创作</h1>
<p className="mt-1 text-xs leading-5 text-foreground/55">回到最近工作,或先处理阻塞制作的事项。</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button icon={<LayoutGrid className="size-3.5" />} onClick={onCreateIndependentCanvas}>打开画布</Button>
<Link className="inline-flex h-9 items-center gap-2 rounded-md bg-foreground px-3.5 text-sm font-medium text-background transition-opacity hover:opacity-85 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-foreground/25" to="/projects?create=1"><Plus className="size-3.5" />创建项目</Link>
</div>
</header>
<section className="app-home-metrics grid grid-cols-2 gap-3 py-5 xl:grid-cols-4" aria-label="项目状态概览">
<WorkspaceMetric icon={<BookOpenText />} label="剧情章节" value={summary.unitCount} detail={`${summary.completedUnitCount} 章已完成`} />
<WorkspaceMetric icon={<LayoutGrid />} label="项目画布" value={summary.canvasCount} detail="已关联制作空间" />
<WorkspaceMetric icon={<Images />} label="项目资产" value={summary.assetCount} detail="角色、场景与媒体" />
<WorkspaceMetric icon={<CircleAlert />} label="需要处理" value={attentionCount} detail={attentionCount ? "建议优先处理" : "当前流程顺畅"} attention={attentionCount > 0} />
</section>
<section className="app-home-focus-panel grid overflow-hidden rounded-lg border border-border/80 lg:grid-cols-[minmax(260px,.8fr)_minmax(320px,1fr)_minmax(280px,.72fr)]">
<div className="min-w-0 p-5 sm:p-6 lg:p-7">
<div className="flex flex-wrap items-center gap-2 text-xs text-foreground/50">
<span className="font-medium text-[var(--workspace-accent)]">{stage.label}</span>
<span aria-hidden>·</span>
<span>{stage.detail}</span>
</div>
<h2 className="mt-3 truncate text-2xl font-semibold sm:text-3xl">{summary.project.name}</h2>
<div className="mt-4 flex flex-wrap items-center gap-x-5 gap-y-2 text-xs text-foreground/52">
<span>{summary.completedUnitCount}/{summary.unitCount} 章已完成</span>
<span>{summary.canvasCount} 张项目画布</span>
<span>{summary.assetCount} 项资产</span>
<span>更新于 {formatRelativeTime(summary.project.updatedAt)}</span>
</div>
<div className="mt-4 h-1.5 w-full max-w-[620px] overflow-hidden rounded-full bg-foreground/[.08]" aria-label={`章节完成度 ${completion}%`}>
<div className="h-full rounded-full bg-[var(--workspace-accent)] transition-[width]" style={{ width: `${completion}%` }} />
</div>
<Link to={continueTarget.href} className="mt-6 inline-flex min-h-10 max-w-full items-center gap-3 rounded-md bg-foreground px-4 py-2 text-sm font-medium text-background transition-opacity hover:opacity-85 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-foreground/25">
<span className="min-w-0 truncate">{continueTarget.context} · {continueTarget.title}</span>
<ArrowRight className="size-4 shrink-0" />
</Link>
</div>
<SpatialChapterStack detail={detail} loading={detailLoading} projectId={summary.project.id} />
<div className="border-t border-border/75 p-5 sm:p-6 lg:border-l lg:border-t-0 lg:p-7">
<div className="flex items-center justify-between gap-3">
<h2 className="text-sm font-semibold">下一步</h2>
<Link to={`/projects/${summary.project.id}/overview`} className="text-xs text-foreground/48 hover:text-foreground">查看项目</Link>
</div>
{detailLoading ? <div className="mt-4 text-xs text-foreground/45">正在整理项目待办...</div> : null}
{detailError ? <div className="mt-4 text-xs leading-5 text-foreground/48">项目详情暂时无法读取。可先打开项目概览或自由画布继续工作。</div> : null}
{!detailLoading && nextActions.length ? (
<div className="mt-3 divide-y divide-border/70">
{nextActions.map((action) => <WorkbenchActionLink key={action.id} action={action} />)}
<div className="studio-band">
<header className="app-page-header flex min-h-14 flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex min-w-0 items-center gap-3">
<div className="min-w-0">
<h1 className="text-[var(--fs-title)] font-semibold leading-7">继续创作</h1>
<p className="mt-1 text-xs leading-5 text-foreground/55">回到最近工作,或先处理阻塞制作的事项。</p>
</div>
) : null}
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button icon={<LayoutGrid className="size-3.5" />} onClick={onCreateIndependentCanvas}>打开画布</Button>
<Link className="inline-flex h-9 items-center gap-2 rounded-md bg-foreground px-3.5 text-sm font-medium text-background transition-opacity hover:opacity-85 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-foreground/25" to="/projects?create=1"><Plus className="size-3.5" />创建项目</Link>
</div>
</header>
</div>
<section className="app-home-quick-create grid gap-3 py-6 sm:grid-cols-3" aria-label="快捷创建">
<QuickCreateCard icon={<FolderKanban className="size-5" />} title="创建短剧项目" description="从空白、小说或文本开始建立章节流程。" action="创建" href="/projects?create=1" />
<QuickCreateCard icon={<LayoutGrid className="size-5" />} title="打开自由画布" description="适合快速试图、提示词实验和自由创作。" action="打开" onClick={onCreateIndependentCanvas} />
<QuickCreateCard icon={<Images className="size-5" />} title="进入素材库" description="整理角色、场景、画风和媒体资产。" action="进入" href="/assets" />
</section>
<section className="grid gap-8 py-7 xl:grid-cols-[minmax(0,1.2fr)_minmax(320px,.8fr)]">
<section className="grid gap-8 py-6 xl:grid-cols-[minmax(0,1.2fr)_minmax(320px,.8fr)]">
<div className="min-w-0">
<div className="mb-3 flex items-center justify-between gap-4">
<div><h2 className="text-base font-semibold">最近项目</h2><p className="mt-1 text-xs text-foreground/45">按最近更新时间排列</p></div>
<Link to="/projects" className="inline-flex items-center gap-1.5 text-xs text-foreground/50 hover:text-foreground">查看全部<ArrowRight className="size-3.5" /></Link>
</div>
<div className="overflow-hidden rounded-lg border border-border/80 bg-background/65">
<div className="app-home-timeline overflow-hidden rounded-lg border border-border/80 bg-background/65">
{recentProjects.map((project, index) => <RecentProjectRow key={project.project.id} summary={project} divided={index > 0} />)}
</div>
</div>
@@ -193,6 +145,25 @@ function ReturningWorkspace({ summary, detail, detailLoading, detailError, recen
)}
</div>
</section>
<section className="app-home-template-rail border-t border-border/80 py-7" aria-label="工作流入口">
<div className="mb-4 flex items-end justify-between gap-4">
<div><h2 className="text-base font-semibold">从工作流开始</h2><p className="mt-1 text-xs leading-5 text-foreground/48">整理故事 → 确认设定 → 制作镜头 → 检查结果。</p></div>
<span className="hidden text-[var(--fs-label)] text-foreground/38 sm:block">{stage.label} · {completion}%</span>
</div>
<div className="app-workflow-grid grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
{workflow.map((item, index) => (
<div key={item.title} className="app-workflow-card relative min-w-0 p-4">
<div className="flex items-center justify-between">
<span className="grid size-6 place-items-center rounded-md bg-foreground/[.06] text-[var(--fs-label)] font-semibold tabular-nums text-[var(--workspace-accent)]">0{index + 1}</span>
{index < workflow.length - 1 ? <ArrowRight className="size-3.5 text-foreground/25" aria-hidden="true" /> : null}
</div>
<h3 className="mt-2 text-sm font-semibold">{item.title}</h3>
<p className="mt-1 text-xs leading-5 text-foreground/48">{item.description}</p>
</div>
))}
</div>
</section>
</>
);
}
@@ -219,10 +190,13 @@ function FirstProjectWorkspace({ authenticated, canvasHydrated, recentIndependen
<section className="border-b border-border/80 py-7">
<div className="mb-5"><h2 className="text-lg font-semibold">从故事到结果</h2><p className="mt-1 text-xs leading-5 text-foreground/48">每一步都保留输入、版本和生成记录,可以随时返回调整。</p></div>
<div className="app-workflow-rail grid border-t border-border/75 sm:grid-cols-2 xl:grid-cols-4">
<div className="app-workflow-grid grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
{workflow.map((item, index) => (
<div key={item.title} className={`app-workflow-step min-w-0 border-b border-border/75 py-4 sm:px-4 xl:border-b-0 xl:border-r ${index % 2 === 0 ? "sm:pl-0" : "sm:border-l"} ${index === workflow.length - 1 ? "xl:border-r-0" : ""}`}>
<span className="text-[var(--fs-label)] font-semibold tabular-nums text-[var(--workspace-accent)]">0{index + 1}</span>
<div key={item.title} className="app-workflow-card relative min-w-0 p-4">
<div className="flex items-center justify-between">
<span className="grid size-6 place-items-center rounded-md bg-foreground/[.06] text-[var(--fs-label)] font-semibold tabular-nums text-[var(--workspace-accent)]">0{index + 1}</span>
{index < workflow.length - 1 ? <ArrowRight className="size-3.5 text-foreground/25" aria-hidden="true" /> : null}
</div>
<h3 className="mt-2 text-sm font-semibold">{item.title}</h3>
<p className="mt-1 text-xs leading-5 text-foreground/48">{item.description}</p>
</div>
@@ -251,74 +225,33 @@ function FirstProjectWorkspace({ authenticated, canvasHydrated, recentIndependen
{recentIndependentCanvases.length ? (
<section className="border-t border-border/80 pt-6">
<div className="mb-4 flex items-center justify-between"><h2 className="text-base font-semibold">继续自由画布</h2><Link to="/canvas" className="text-xs text-foreground/50 hover:text-foreground">查看全部</Link></div>
<div className="grid max-w-[940px] gap-4 sm:grid-cols-2 lg:grid-cols-3">{recentIndependentCanvases.map((project) => <CanvasProjectCard key={project.id} project={project} variant="recent" />)}</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">{recentIndependentCanvases.map((project) => <CanvasProjectCard key={project.id} project={project} variant="recent" />)}</div>
</section>
) : null}
</>
);
}
function WorkspaceMetric({ icon, label, value, detail, attention = false }: { icon: ReactNode; label: string; value: number; detail: string; attention?: boolean }) {
return (
<article className="app-home-metric min-w-0 rounded-lg border p-4">
<div className="flex items-start justify-between gap-3">
<span className={attention ? "text-foreground/75" : "text-[var(--workspace-accent)]"}>{icon}</span>
<span className={`size-1.5 rounded-full ${attention ? "bg-foreground/75" : "bg-[var(--workspace-accent)]"}`} />
</div>
<div className="mt-5 text-[var(--fs-label)] text-foreground/45">{label}</div>
<div className="mt-1 text-2xl font-semibold tabular-nums">{value}</div>
<div className="mt-1 truncate text-[var(--fs-tiny)] text-foreground/38">{detail}</div>
</article>
);
}
function SpatialChapterStack({ detail, loading, projectId }: { detail?: Awaited<ReturnType<typeof getProject>>; loading: boolean; projectId: string }) {
const units = detail?.units.slice().sort((left, right) => left.position - right.position).slice(0, 6) || [];
const activeUnitId = units.find((unit) => unit.status !== "completed")?.id || units.at(-1)?.id;
return (
<div className="spatial-chapter-panel min-w-0 border-t border-border/75 p-5 sm:p-6 lg:border-l lg:border-t-0 lg:p-7">
<div className="flex items-center justify-between gap-3">
<div><div className="text-[var(--fs-tiny)] font-medium text-foreground/38">制作层级</div><h2 className="mt-1 text-sm font-semibold">章节轨道</h2></div>
<span className="text-[var(--fs-tiny)] tabular-nums text-foreground/35">{units.length ? `${units.length} 章` : "待建立"}</span>
</div>
<div className="spatial-chapter-deck mt-4" aria-label="项目章节制作层级">
{loading ? Array.from({ length: 4 }, (_, index) => <span key={index} className="spatial-chapter-card is-loading" style={{ "--deck-x": `${index * 12}px`, "--deck-y": `${index * 28}px`, "--deck-z": `${-index * 18}px`, zIndex: 8 - index } as CSSProperties} />) : null}
{!loading && units.length ? units.map((unit, index) => (
<Link
key={unit.id}
to={`/projects/${projectId}/chapters/${unit.id}`}
className={`spatial-chapter-card ${unit.id === activeUnitId ? "is-active" : ""}`}
style={{ "--deck-x": `${index * 12}px`, "--deck-y": `${index * 28}px`, "--deck-z": `${-index * 18}px`, zIndex: 8 - index } as CSSProperties}
>
<span className="text-[var(--fs-micro)] font-semibold tabular-nums opacity-55">{String(unit.position + 1).padStart(2, "0")}</span>
<span className="mt-2 block truncate text-xs font-semibold">{unit.title}</span>
<span className="mt-1 block text-[var(--fs-micro)] opacity-50">{unit.status === "completed" ? "已完成" : unit.id === activeUnitId ? "当前制作" : "等待推进"}</span>
</Link>
)) : null}
{!loading && !units.length ? <div className="grid min-h-52 place-items-center text-center text-xs leading-5 text-foreground/42">创建剧情章节后<br />这里会形成制作层级</div> : null}
</div>
</div>
);
}
function WorkbenchActionLink({ action }: { action: ReturnType<typeof projectNextActions>[number] }) {
const Icon = action.tone === "danger" ? CircleAlert : action.tone === "attention" ? Clock3 : CheckCircle2;
return (
<Link to={action.href} className="group grid grid-cols-[20px_minmax(0,1fr)_auto] gap-2 py-3 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-foreground/20">
<Icon className={`mt-0.5 size-4 ${action.tone === "danger" ? "text-foreground/80" : action.tone === "attention" ? "text-foreground/60" : "text-foreground/35"}`} />
<span className="min-w-0"><span className="block text-xs font-medium">{action.title}</span><span className="mt-1 line-clamp-2 block text-[var(--fs-label)] leading-4 text-foreground/45">{action.description}</span></span>
<span className="self-center text-[var(--fs-label)] font-medium text-foreground/45 transition-colors group-hover:text-foreground">{action.actionLabel}</span>
</Link>
function QuickCreateCard({ icon, title, description, action, href, onClick }: { icon: ReactNode; title: string; description: string; action: string; href?: string; onClick?: () => void }) {
const content = (
<>
<span className="grid size-10 shrink-0 place-items-center rounded-md border border-border/70 bg-foreground/[.04] text-[var(--workspace-accent)]">{icon}</span>
<span className="mt-4 block text-sm font-semibold">{title}</span>
<span className="mt-1 block text-xs leading-5 text-foreground/48">{description}</span>
<span className="mt-4 inline-flex items-center gap-1 text-xs font-medium text-foreground/55 transition-colors group-hover:text-foreground">{action}<ArrowRight className="size-3.5" /></span>
</>
);
const className = "app-home-quick-create-card group flex min-h-[148px] flex-col items-start rounded-lg border border-border/80 bg-card p-4 text-left transition hover:-translate-y-0.5 hover:border-foreground/20 hover:shadow-[var(--card-elevation-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring";
return href ? <Link to={href} className={className}>{content}</Link> : <button type="button" className={className} onClick={onClick}>{content}</button>;
}
function RecentProjectRow({ summary, divided }: { summary: ProjectSummary; divided: boolean }) {
const completion = projectSummaryCompletion(summary);
return (
<Link to={`/projects/${summary.project.id}/overview`} className={`group grid min-h-[68px] grid-cols-[minmax(0,1fr)_80px_20px] items-center gap-3 px-3 py-2.5 hover:bg-foreground/[.025] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-foreground/20 sm:grid-cols-[minmax(0,1fr)_100px_120px_20px] ${divided ? "border-t border-border/65" : ""}`}>
<Link to={`/projects/${summary.project.id}/overview`} className={`group grid min-h-[68px] grid-cols-[minmax(0,1fr)_80px_20px] items-center gap-3 px-3 py-2.5 hover:bg-surface-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-foreground/20 sm:grid-cols-[minmax(0,1fr)_100px_120px_20px] ${divided ? "border-t border-border/65" : ""}`}>
<span className="min-w-0"><span className="block truncate text-sm font-medium">{summary.project.name}</span><span className="mt-1 block truncate text-[var(--fs-label)] text-foreground/42">{summary.unitCount} 章 · {summary.canvasCount} 张项目画布 · {summary.assetCount} 项资产</span></span>
<span className="hidden text-[var(--fs-label)] text-foreground/45 sm:block">更新于<br />{formatRelativeTime(summary.project.updatedAt)}</span>
<span className="min-w-0"><span className="flex items-center justify-between text-[var(--fs-tiny)] text-foreground/42"><span>章节</span><span>{completion}%</span></span><span className="mt-1.5 block h-1 overflow-hidden rounded-full bg-foreground/[.08]"><span className="block h-full rounded-full bg-foreground/65" style={{ width: `${completion}%` }} /></span></span>
<span className="min-w-0"><span className="flex items-center justify-between text-[var(--fs-tiny)] text-foreground/42"><span>章节</span><span>{completion}%</span></span><span className="mt-1.5 block h-1 overflow-hidden rounded-full bg-surface-active"><span className="block h-full rounded-full bg-foreground/65" style={{ width: `${completion}%` }} /></span></span>
<ArrowRight className="size-4 text-foreground/25 transition-transform group-hover:translate-x-0.5 group-hover:text-foreground/60" />
</Link>
);

View File

@@ -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}

View File

@@ -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 }) {

View File

@@ -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">

View File

@@ -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() {

View File

@@ -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>

View File

@@ -1,11 +1,10 @@
import { App, Button, Form, Input, InputNumber, Popconfirm, Segmented, Select, Tag, Tooltip } from "antd";
import { ArrowLeft, Boxes, ChevronDown, ChevronUp, CircleCheck, Cloud, Info, MessageSquareText, Plus, RadioTower, RefreshCw, SlidersHorizontal, Trash2 } from "lucide-react";
import { ArrowLeft, Boxes, ChevronDown, ChevronUp, CircleCheck, Cloud, MessageSquareText, Plus, RadioTower, RefreshCw, SlidersHorizontal, Trash2 } from "lucide-react";
import { useEffect, useState, type ReactNode } from "react";
import { useNavigate, useSearchParams } from "react-router";
import { UserOSSSettingsForm } from "@/components/layout/user-oss-settings-form";
import { WorkspaceState } from "@/components/layout/workspace-state";
import { WorkspaceSignalIcon } from "@/components/ui/aceternity/workspace-signal-icon";
import { ChannelHeadersEditor, validateChannelHeaders } from "@/components/channel-headers-editor";
import { refreshSystemChannels } from "@/lib/user-session";
import { fetchChannelModels, type ChannelModelCatalogItem } from "@/services/api/image";
@@ -249,30 +248,26 @@ export default function SettingsPage() {
};
return (
<main className="app-workspace-page flex h-full min-h-0 flex-col text-foreground">
<header className="flex min-h-16 shrink-0 items-center justify-between gap-4 border-b border-border/70 px-4 py-3 sm:px-5">
<div className="flex min-w-0 items-center gap-3">
<main className="settings-page app-workspace-page flex h-full min-h-0 flex-col text-foreground">
<header className="settings-topbar shrink-0">
<div className="flex min-w-0 items-center gap-2.5">
{shouldPromptContinue ? (
<button type="button" className="app-workspace-icon-button shrink-0" onClick={() => navigate(-1)} aria-label="返回创作页面" title="返回创作页面"><ArrowLeft className="size-4" /></button>
) : null}
<WorkspaceSignalIcon variant="settings" />
<div className="min-w-0">
<h1 className="truncate text-base font-semibold">配置与用户偏好</h1>
<p className="mt-0.5 truncate text-xs text-foreground/50">模型连接、生成默认值与个人媒体存储</p>
</div>
<h1 className="truncate text-sm font-semibold">设置</h1>
</div>
{shouldPromptContinue ? <Button type="primary" onClick={finishConfig}>保存并返回创作</Button> : null}
{shouldPromptContinue ? <Button type="primary" size="small" onClick={finishConfig}>保存并返回</Button> : null}
</header>
<div className="flex min-h-0 flex-1 flex-col md:flex-row">
<aside className="w-full shrink-0 border-b border-border/70 bg-transparent p-2 md:w-[224px] md:border-b-0 md:border-r md:p-3">
<nav className="thin-scrollbar flex gap-1 overflow-x-auto md:block md:space-y-1" aria-label="配置分类">
<div className="settings-library-frame flex min-h-0 flex-1 flex-col md:flex-row">
<aside className="settings-nav-panel w-full shrink-0 md:w-[200px]">
<nav className="thin-scrollbar flex gap-1 overflow-x-auto p-2 md:block md:space-y-1 md:p-2.5" aria-label="配置分类">
{configSections.map((item) => {
const selected = item.key === activeTab;
return (
<button
key={item.key}
type="button"
className={`flex h-9 shrink-0 items-center gap-2 rounded-md px-3 text-left text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring md:h-auto md:w-full md:items-start md:gap-3 md:py-2.5 ${selected ? "bg-[var(--workspace-accent-soft)] text-foreground" : "text-foreground/58 hover:bg-muted/55 hover:text-foreground"}`}
className={`settings-nav-item flex h-9 shrink-0 items-center gap-2 rounded-md px-3 text-left text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring md:h-auto md:w-full md:items-start md:gap-3 md:py-2.5 ${selected ? "is-active" : "text-foreground/58 hover:bg-muted/55 hover:text-foreground"}`}
onClick={() => selectSection(item.key)}
aria-current={selected ? "page" : undefined}
>
@@ -284,9 +279,9 @@ export default function SettingsPage() {
</nav>
</aside>
<section className="flex min-h-0 min-w-0 flex-1 flex-col bg-transparent">
<section className="settings-content flex min-h-0 min-w-0 flex-1 flex-col">
<div className="app-workspace-scroll min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 py-4 md:px-6 md:py-5">
<div className={activeTab === "prompts" ? "h-full w-full" : "mx-auto w-full max-w-none"}>
<div className={`settings-surface-card ${activeTab === "prompts" ? "h-full w-full" : "mx-auto w-full max-w-none"}`}>
{([
{
key: "channels",
@@ -294,15 +289,10 @@ export default function SettingsPage() {
children: (
<SettingsPane>
<Form layout="vertical" requiredMark={false}>
<div className="mb-4 flex flex-wrap items-center justify-between gap-3 border-b border-border pb-4">
<div className="min-w-0 flex-1">
<div className="flex w-fit max-w-full flex-wrap items-center gap-1.5 text-xs text-foreground/65">
<Info className="size-3.5 shrink-0" />
<span>渠道只保存连接类型;拉取模型后,请在每个模型下配置能力与请求协议。</span>
<Button type="link" size="small" className="h-auto p-0 text-xs font-semibold" onClick={() => selectSection("models")}>
打开模型选择
</Button>
</div>
<div className="settings-pane-header">
<div className="min-w-0">
<h2>自定义渠道</h2>
<p>渠道只保存连接类型;拉取模型后,请在“模型与能力”中配置协议。<Button type="link" size="small" className="h-auto p-0 text-xs font-semibold" onClick={() => selectSection("models")}>打开模型选择</Button></p>
</div>
<div className="flex w-full gap-2 sm:w-auto sm:shrink-0">
<Button
@@ -320,7 +310,7 @@ export default function SettingsPage() {
</div>
</div>
{userChannels.length ? (
<div className="space-y-2">
<div className="settings-channel-list space-y-2">
{userChannels.map((channel) => (
<section key={channel.id} aria-labelledby={`channel-${channel.id}-title`} className="rounded-md border border-border bg-background p-2.5 sm:p-3">
<div className="mb-2.5 flex flex-wrap items-start justify-between gap-2.5">
@@ -371,6 +361,7 @@ export default function SettingsPage() {
</div>
<div id={`channel-${channel.id}-details`} hidden={collapsedChannelIds.has(channel.id)}>
<div className="grid gap-x-3 gap-y-2 lg:grid-cols-12">
<div className="settings-field-group-label lg:col-span-12">连接信息</div>
<Form.Item label="渠道名称" htmlFor={`channel-${channel.id}-name`} className="mb-0 lg:col-span-3">
<Input
id={`channel-${channel.id}-name`}
@@ -418,6 +409,7 @@ export default function SettingsPage() {
onBlur={(event) => updateChannel(channel.id, { secretKey: event.target.value.trim() })}
/>
</Form.Item>
<div className="settings-field-group-label lg:col-span-12">模型与能力</div>
<Form.Item label="模型列表" htmlFor={`channel-${channel.id}-models`} className="mb-0 lg:col-span-7">
<Select
id={`channel-${channel.id}-models`}
@@ -458,7 +450,15 @@ export default function SettingsPage() {
label: "模型",
children: (
<SettingsPane>
<ModelDefaultGrid config={config} onChange={(key, model) => updateConfig(key, model)} />
<div className="settings-pane-header">
<div className="min-w-0">
<h2>模型选择</h2>
<p>按领域选择默认模型;模型能力与请求协议在渠道“模型与能力”中配置。</p>
</div>
</div>
<div className="settings-section-card">
<ModelDefaultGrid config={config} onChange={(key, model) => updateConfig(key, model)} />
</div>
</SettingsPane>
),
},
@@ -467,6 +467,13 @@ export default function SettingsPage() {
label: "生成偏好",
children: (
<SettingsPane>
<div className="settings-pane-header">
<div className="min-w-0">
<h2>生成偏好</h2>
<p>画布、视频与音频默认值,节点内仍可单独覆盖。</p>
</div>
</div>
<div className="settings-section-card">
<Form layout="vertical" requiredMark={false}>
<section className="border-b border-border pb-6">
<div className="mb-4"><h3 className="text-sm font-semibold">画布生成</h3><p className="mt-1 text-xs text-foreground/55">设置新建生成任务时使用的初始值,节点内仍可单独覆盖。</p></div>
@@ -514,6 +521,7 @@ export default function SettingsPage() {
</div>
</section>
</Form>
</div>
</SettingsPane>
),
},
@@ -532,7 +540,9 @@ export default function SettingsPage() {
),
children: (
<SettingsPane>
<UserOSSSettingsForm />
<div className="settings-section-card">
<UserOSSSettingsForm />
</div>
</SettingsPane>
),
},

View File

@@ -163,7 +163,9 @@ export default function SkillsPage() {
return (
<>
<WorkspacePage className="library-page skills-library-page" grid>
<PageHeader icon="skills" title="技能库" description="把常用的提示词、角色设定和创作方法收进自己的工具架。" meta={<span className="text-xs text-foreground/45">{total} 个技能</span>} actions={null} />
<div className="studio-band">
<PageHeader title="技能库" description="把常用的提示词、角色设定和创作方法收进自己的工具架。" meta={<span className="text-xs text-foreground/45">{total} 个技能</span>} actions={null} />
</div>
<div className="mt-1 flex flex-col border-b border-border/75 xl:flex-row xl:items-end xl:justify-between">
<nav className="thin-scrollbar -mb-px flex min-w-0 overflow-x-auto" aria-label="技能库范围" role="tablist">

View File

@@ -26,7 +26,7 @@ export function SkillDetailDrawer({ skill, loading, mutating, categories, onClos
<section aria-labelledby="skill-instruction-title">
<div className="mb-2 flex items-center justify-between gap-3">
<h2 id="skill-instruction-title" className="text-sm font-semibold">技能指令</h2>
{skill.markdown_url ? <Tooltip title="打开 Markdown 原文"><a className="inline-flex size-8 items-center justify-center rounded-md text-foreground/55 hover:bg-foreground/[.06] hover:text-foreground" href={skill.markdown_url} target="_blank" rel="noreferrer" aria-label="打开 Markdown 原文"><ExternalLink className="size-4" /></a></Tooltip> : null}
{skill.markdown_url ? <Tooltip title="打开 Markdown 原文"><a className="inline-flex size-8 items-center justify-center rounded-md text-foreground/55 hover:bg-surface-hover hover:text-foreground" href={skill.markdown_url} target="_blank" rel="noreferrer" aria-label="打开 Markdown 原文"><ExternalLink className="size-4" /></a></Tooltip> : null}
</div>
{loading ? <Skeleton active paragraph={{ rows: 14 }} /> : <pre className="thin-scrollbar max-h-[52vh] overflow-auto whitespace-pre-wrap break-words rounded-md border border-border/70 bg-foreground/[.025] p-4 font-mono text-xs leading-6 text-foreground/78">{skill.instruction || "暂无技能指令"}</pre>}
</section>

View File

@@ -1,5 +1,5 @@
import { App, Button, Drawer, Form, Input, Modal, Segmented, Select, Tooltip, Typography } from "antd";
import { Coins, Eye, FileText, FolderKanban, Image as ImageIcon, Play, Plus, RefreshCw, RotateCcw, Search, Video, X } from "lucide-react";
import { App, Button, Drawer, Form, Input, Modal, Segmented, Select, Switch, Tooltip, Typography } from "antd";
import { LayoutGrid, List, Plus, RefreshCw, Search } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useSearchParams } from "react-router";
@@ -9,16 +9,42 @@ import { CONTENT_MODERATION_ERROR_CODE, generationErrorMessage, isContentModerat
import { formatTaskKind, operationOptions, statusLabel } from "@/lib/generation-task-display";
import { modelCapabilityConfigFor } from "@/lib/model-capabilities";
import { cancelGenerationTask, createAgentSession, createGenerationTask, listGenerationTasks, listTaskLogs, queryFailedVideoProviderTask, queryGenerationTask, retryGenerationTask, type CreateTaskInput, type GenerationTask, type TaskLog, type TaskStatus } from "@/services/api/task-center";
import { cancelGenerationTask, createAgentSession, createGenerationTask, listGenerationTasks, listTaskLogs, queryFailedVideoProviderTask, queryGenerationTask, retryGenerationTask, type CreateTaskInput, type GenerationTask, type TaskLog } from "@/services/api/task-center";
import { syncGenerationTaskToCanvasStore } from "@/lib/canvas/canvas-generation-task-sync";
import { useCanvasStore } from "@/stores/canvas/use-canvas-store";
import { modelDisplayName, resolveModelRequestConfig, useConfigStore, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store";
import { resolveModelRequestConfig, useConfigStore, useEffectiveConfig } from "@/stores/use-config-store";
import { useUserStore } from "@/stores/use-user-store";
import { formatCredits } from "@/constant/credits";
import { listProjects, type ProjectSummary } from "@/services/api/projects";
import { TaskGridCard } from "./task-grid-card";
import { TaskGroupHeader, type TaskGroup } from "./task-group-header";
import { TaskListRow } from "./task-list-row";
import { formatModelName, getTaskCanvasContext, isTaskFailed, providerCancelStatusLabel, taskMediaKind } from "./task-shared";
import { TaskStatPills, type TaskStatusFilter } from "./task-stat-pills";
type TaskStatusFilter = "all" | "failed" | "active" | "succeeded";
type TaskKindFilter = "all" | "text" | "image" | "video";
type TaskViewMode = "list" | "grid";
function preferenceKeys() {
const userId = useUserStore.getState().user?.id ?? "anon";
return { view: `task-center-view.${userId}`, group: `task-center-group.${userId}` };
}
function readTaskPreference(key: string, fallback: string): string {
try {
return window.localStorage.getItem(key) ?? fallback;
} catch (error) {
console.warn("读取任务中心偏好失败", error);
return fallback;
}
}
function writeTaskPreference(key: string, value: string): void {
try {
window.localStorage.setItem(key, value);
} catch (error) {
console.warn("保存任务中心偏好失败", error);
}
}
function taskStatusFilter(value: string | null): TaskStatusFilter {
return value === "failed" || value === "active" || value === "succeeded" ? value : "all";
@@ -33,7 +59,7 @@ export default function TasksPage() {
const shortDramaEnabled = useUserStore((state) => state.features.shortDramaEnabled);
const creditsEnabled = useUserStore((state) => state.features.creditsEnabled);
const [form] = Form.useForm<CreateTaskInput & { operation: string }>();
const [tasks, setTasks] = useState<GenerationTask[]>([]);
const { view: viewPreferenceKey, group: groupPreferenceKey } = preferenceKeys();
const [domainProjects, setDomainProjects] = useState<ProjectSummary[]>([]);
const [loading, setLoading] = useState(false);
const [actingId, setActingId] = useState("");
@@ -51,14 +77,17 @@ export default function TasksPage() {
const [modelFilter, setModelFilter] = useState("all");
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
const [viewMode, setViewMode] = useState<TaskViewMode>(() => (readTaskPreference(viewPreferenceKey, "list") === "grid" ? "grid" : "list"));
const [groupEnabled, setGroupEnabled] = useState<boolean>(() => readTaskPreference(groupPreferenceKey, "0") === "1");
const [retryingGroup, setRetryingGroup] = useState("");
const [detailTask, setDetailTask] = useState<GenerationTask | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
const [taskLogs, setTaskLogs] = useState<TaskLog[]>([]);
const [logsLoading, setLogsLoading] = useState(false);
const [mediaPreview, setMediaPreview] = useState<{ url: string; kind: "image" | "video"; title: string } | null>(null);
const [tasks, setTasks] = useState<GenerationTask[]>([]);
const syncedCanvasTaskIdsRef = useRef(new Set<string>());
const tasksRef = useRef<GenerationTask[]>([]);
const canvasById = useMemo(() => new Map(projects.map((project) => [project.id, project])), [projects]);
const domainProjectNameById = useMemo(() => new Map(domainProjects.map((item) => [item.project.id, item.project.name])), [domainProjects]);
const projectOptions = useMemo(() => projects.map((project) => {
@@ -81,6 +110,78 @@ export default function TasksPage() {
return !query || `${task.prompt} ${task.model || ""} ${formatTaskKind(task)} ${context.canvasName} ${context.projectName}`.toLowerCase().includes(query);
}), [canvasById, domainProjectNameById, effectiveConfig, keyword, kindFilter, modelFilter, projectFilter, statusFilter, tasks]);
const visibleTasks = useMemo(() => filteredTasks.slice((page - 1) * pageSize, page * pageSize), [filteredTasks, page, pageSize]);
const taskStats = useMemo(() => {
let today = 0;
let active = 0;
let succeeded = 0;
let failed = 0;
const now = new Date();
for (const task of tasks) {
if (task.createdAt) {
const created = new Date(task.createdAt);
if (!Number.isNaN(created.getTime()) && created.getFullYear() === now.getFullYear() && created.getMonth() === now.getMonth() && created.getDate() === now.getDate()) today += 1;
}
if (task.status === "queued" || task.status === "running") active += 1;
else if (task.status === "succeeded") succeeded += 1;
else if (task.status === "failed" || task.status === "cancelled") failed += 1;
}
return { today, active, succeeded, failed };
}, [tasks]);
const groupingActive = viewMode === "list" && groupEnabled;
const visibleTaskGroups = useMemo(
() => (groupingActive ? groupTasksByCanvas(filteredTasks, canvasById, domainProjectNameById) : []),
[canvasById, domainProjectNameById, filteredTasks, groupingActive],
);
const changeViewMode = (mode: TaskViewMode) => {
setViewMode(mode);
writeTaskPreference(viewPreferenceKey, mode);
};
const changeGroupEnabled = (enabled: boolean) => {
setGroupEnabled(enabled);
writeTaskPreference(groupPreferenceKey, enabled ? "1" : "0");
};
const retryGroupTasks = async (key: string, items: GenerationTask[]) => {
const retryable = items.filter((task) => isTaskFailed(task) && task.errorCode !== CONTENT_MODERATION_ERROR_CODE && !isContentModerationError(task.error));
if (!retryable.length) return;
setRetryingGroup(key);
try {
for (const task of retryable) {
await runAction(task.id, "retry");
}
} finally {
setRetryingGroup("");
}
};
const renderTaskRow = (task: GenerationTask) => (
<TaskListRow
key={task.id}
task={task}
canvasById={canvasById}
projectNameById={domainProjectNameById}
effectiveConfig={effectiveConfig}
creditsEnabled={creditsEnabled}
actingId={actingId}
onOpen={() => void openTaskDetail(task)}
onRetry={() => void runAction(task.id, "retry")}
onCancel={() => void runAction(task.id, "cancel")}
onPreview={() => task.previewUrl && setMediaPreview({ url: task.previewUrl, kind: task.previewKind === "video" ? "video" : "image", title: task.prompt || formatTaskKind(task) })}
/>
);
const renderTaskGridCard = (task: GenerationTask) => (
<TaskGridCard
key={task.id}
task={task}
actingId={actingId}
onOpen={() => void openTaskDetail(task)}
onRetry={() => void runAction(task.id, "retry")}
onCancel={() => void runAction(task.id, "cancel")}
/>
);
useEffect(() => {
if (!shortDramaEnabled) {
@@ -284,40 +385,93 @@ export default function TasksPage() {
return (
<>
<WorkspacePage grid className="library-page task-library-page">
<PageHeader
icon="tasks"
title="任务中心"
meta={<span className="text-xs text-foreground/45">{filteredTasks.length} 个任务{loading ? " · 正在同步" : ""}</span>}
actions={(
<>
<Button icon={<RefreshCw className={`size-3.5 ${loading ? "animate-spin" : ""}`} />} onClick={() => void loadTasks(true)}>刷新</Button>
<Button className="library-primary-action" type="primary" icon={<Plus className="size-3.5" />} onClick={() => setCreateOpen(true)}>新建任务</Button>
</>
)}
/>
<ListToolbar className="library-toolbar task-library-toolbar" active={Boolean(keyword || projectFilter !== "all" || kindFilter !== "all" || modelFilter !== "all" || statusFilter !== "all")} onReset={() => { setKeyword(""); setProjectFilter("all"); setKindFilter("all"); setModelFilter("all"); setStatusFilter("all"); setPage(1); }}>
<Input id="task-search" name="taskSearch" allowClear className="app-list-search" prefix={<Search className="size-4 text-foreground/40" />} value={keyword} placeholder="搜索任务、模型或画布" onChange={(event) => { setKeyword(event.target.value); setPage(1); }} />
<Select className="w-full sm:w-48" value={projectFilter} onChange={(value) => { setProjectFilter(value); setPage(1); }} options={[{ label: "全部画布", value: "all" }, ...projectOptions]} />
<Select className="w-full sm:w-32" value={kindFilter} onChange={(value) => { setKindFilter(value as TaskKindFilter); setPage(1); }} options={[{ label: "全部类型", value: "all" }, { label: "文本", value: "text" }, { label: "图片", value: "image" }, { label: "视频", value: "video" }]} />
<Select className="w-full sm:w-44" value={modelFilter} onChange={(value) => { setModelFilter(value); setPage(1); }} options={[{ label: "全部模型", value: "all" }, ...modelOptions.map((model) => ({ label: model, value: model }))]} />
<Segmented
size="small"
value={statusFilter}
onChange={(value) => { setStatusFilter(value as typeof statusFilter); setPage(1); }}
options={[
{ label: "全部", value: "all" },
{ label: "待处理", value: "failed" },
{ label: "运行中", value: "active" },
{ label: "已完成", value: "succeeded" },
]}
<div className="studio-band">
<PageHeader
title="任务中心"
description="跟踪生成任务的排队、进度与失败原因。"
meta={<span className="app-projects-header-meta">{filteredTasks.length} 个任务{loading ? " · 同步中" : ""}</span>}
actions={(
<>
<Button icon={<RefreshCw className={`size-3.5 ${loading ? "animate-spin" : ""}`} />} onClick={() => void loadTasks(true)}>刷新</Button>
<Button className="library-primary-action" type="primary" icon={<Plus className="size-3.5" />} onClick={() => setCreateOpen(true)}>新建任务</Button>
</>
)}
/>
</ListToolbar>
<TaskStatPills stats={taskStats} statusFilter={statusFilter} onFilterChange={setStatusFilter} />
<ListToolbar
className="library-toolbar task-library-toolbar"
active={Boolean(keyword || projectFilter !== "all" || kindFilter !== "all" || modelFilter !== "all" || statusFilter !== "all")}
onReset={() => { setKeyword(""); setProjectFilter("all"); setKindFilter("all"); setModelFilter("all"); setStatusFilter("all"); setPage(1); }}
trailing={(
<div className="flex flex-wrap items-center gap-2.5">
{viewMode === "list" ? (
<label className="inline-flex cursor-pointer items-center gap-2 text-xs text-foreground/55">
<Switch size="small" checked={groupEnabled} onChange={changeGroupEnabled} />
<span>按画布分组</span>
</label>
) : null}
<div className="task-view-switch" role="group" aria-label="任务视图">
<Tooltip title="列表视图">
<Button type={viewMode === "list" ? "primary" : "text"} size="small" aria-label="列表视图" aria-pressed={viewMode === "list"} icon={<List className="size-3.5" />} onClick={() => changeViewMode("list")} />
</Tooltip>
<Tooltip title="网格视图">
<Button type={viewMode === "grid" ? "primary" : "text"} size="small" aria-label="网格视图" aria-pressed={viewMode === "grid"} icon={<LayoutGrid className="size-3.5" />} onClick={() => changeViewMode("grid")} />
</Tooltip>
</div>
</div>
)}
>
<Input id="task-search" name="taskSearch" allowClear className="app-list-search" prefix={<Search className="size-4 text-foreground/40" />} value={keyword} placeholder="搜索任务、模型或画布" onChange={(event) => { setKeyword(event.target.value); setPage(1); }} />
<Select className="w-full sm:w-48" value={projectFilter} onChange={(value) => { setProjectFilter(value); setPage(1); }} options={[{ label: "全部画布", value: "all" }, ...projectOptions]} />
<Select className="w-full sm:w-32" value={kindFilter} onChange={(value) => { setKindFilter(value as TaskKindFilter); setPage(1); }} options={[{ label: "全部类型", value: "all" }, { label: "文本", value: "text" }, { label: "图片", value: "image" }, { label: "视频", value: "video" }]} />
<Select className="w-full sm:w-44" value={modelFilter} onChange={(value) => { setModelFilter(value); setPage(1); }} options={[{ label: "全部模型", value: "all" }, ...modelOptions.map((model) => ({ label: model, value: model }))]} />
<Segmented
size="small"
value={statusFilter}
onChange={(value) => { setStatusFilter(value as typeof statusFilter); setPage(1); }}
options={[
{ label: "全部", value: "all" },
{ label: "失败/取消", value: "failed" },
{ label: "运行中", value: "active" },
{ label: "已完成", value: "succeeded" },
]}
/>
</ListToolbar>
</div>
{loading && !tasks.length ? <div className="library-loading-grid" aria-label="正在加载任务">{Array.from({ length: 8 }, (_, index) => <div key={index} className="library-skeleton" />)}</div> : null}
{!loading || tasks.length ? (
visibleTasks.length ? <div className="task-record-list">{visibleTasks.map((task) => <TaskListRow key={task.id} task={task} canvasById={canvasById} projectNameById={domainProjectNameById} effectiveConfig={effectiveConfig} creditsEnabled={creditsEnabled} actingId={actingId} onOpen={() => void openTaskDetail(task)} onRetry={() => void runAction(task.id, "retry")} onCancel={() => void runAction(task.id, "cancel")} onPreview={() => task.previewUrl && setMediaPreview({ url: task.previewUrl, kind: task.previewKind === "video" ? "video" : "image", title: task.prompt || formatTaskKind(task) })} />)}</div> : <WorkspaceState compact title={taskEmptyState(statusFilter).title} description={taskEmptyState(statusFilter).description} />
) : null}
<PaginationBar current={page} pageSize={pageSize} total={filteredTasks.length} pageSizeOptions={[20, 50, 100]} onChange={(nextPage, nextPageSize) => { setPage(nextPageSize !== pageSize ? 1 : nextPage); setPageSize(nextPageSize); }} />
<div className="canvas-library-frame task-library-frame">
{loading && !tasks.length ? <div className="library-loading-grid" aria-label="正在加载任务">{Array.from({ length: 8 }, (_, index) => <div key={index} className="library-skeleton" />)}</div> : null}
{!loading || tasks.length ? (
visibleTasks.length ? (
viewMode === "grid" ? (
<div className="task-grid-view">
{visibleTasks.map(renderTaskGridCard)}
</div>
) : groupingActive ? (
<div className="task-group-list">
{visibleTaskGroups.map((group) => (
<section key={group.key} className="task-group">
<TaskGroupHeader group={group} retrying={retryingGroup === group.key} onRetryFailed={() => void retryGroupTasks(group.key, group.tasks)} />
<div className="task-record-list">
{group.tasks.map(renderTaskRow)}
</div>
</section>
))}
</div>
) : (
<div className="task-record-list">{visibleTasks.map(renderTaskRow)}</div>
)
) : (
<WorkspaceState
compact
title={taskEmptyState(statusFilter).title}
description={taskEmptyState(statusFilter).description}
action={<Button className="library-primary-action" type="primary" icon={<Plus className="size-3.5" />} onClick={() => setCreateOpen(true)}>新建任务</Button>}
/>
)
) : null}
{!groupingActive ? <PaginationBar current={page} pageSize={pageSize} total={filteredTasks.length} pageSizeOptions={[20, 50, 100]} onChange={(nextPage, nextPageSize) => { setPage(nextPageSize !== pageSize ? 1 : nextPage); setPageSize(nextPageSize); }} /> : null}
</div>
</WorkspacePage>
<Modal className="library-modal" title="新建异步生成任务" open={createOpen} onCancel={() => setCreateOpen(false)} onOk={submitTask} confirmLoading={creating} okText="创建任务">
<Form form={form} layout="vertical" initialValues={{ operation: "agent_session" }}>
@@ -385,7 +539,7 @@ function canQueryProviderTask(task: GenerationTask) {
}
function reconcileTaskSummaries(current: GenerationTask[], next: GenerationTask[]) {
if (current.length !== next.length) return next;
if (current.length === 0) return next;
const currentById = new Map(current.map((task) => [task.id, task]));
let changed = false;
const reconciled = next.map((task) => {
@@ -412,44 +566,6 @@ function TaskResultMedia({ value, taskType }: { value?: string; taskType: string
);
}
function TaskListRow({ task, canvasById, projectNameById, effectiveConfig, creditsEnabled, actingId, onOpen, onRetry, onCancel, onPreview }: {
task: GenerationTask;
canvasById: Map<string, { title: string; projectId?: string }>;
projectNameById: Map<string, string>;
effectiveConfig: AiConfig;
creditsEnabled: boolean;
actingId: string;
onOpen: () => void;
onRetry: () => void;
onCancel: () => void;
onPreview: () => void;
}) {
const context = getTaskCanvasContext(task, canvasById, projectNameById);
const isActive = task.status === "queued" || task.status === "running";
const isFailed = task.status === "failed" || task.status === "cancelled";
return (
<article className={`task-record-row group${isFailed ? " is-attention" : ""}`}>
<TaskPreviewThumbnail task={task} onOpen={onPreview} />
<div className="task-record-main">
<div className="task-record-heading">
<span className={`task-record-status ${isFailed ? "is-failed" : isActive ? "is-active" : "is-success"}`}><i className={statusDotClassName(task.status)} />{statusLabel[task.status]}</span>
<button type="button" className="task-record-title" title={task.prompt} onClick={onOpen}>{task.prompt || "未命名任务"}</button>
</div>
<div className="task-record-meta"><span>{formatTaskKind(task)}</span><span aria-hidden="true">·</span><span>{formatModelName(effectiveConfig, task)}</span><span className="task-record-meta-canvas"><FolderKanban className="size-3" />{context.canvasName}{context.projectName ? ` · ${context.projectName}` : ""}</span></div>
{isActive ? <div className="task-record-progress"><span>{task.stage || "正在生成"}</span><span>{task.progress || 0}%</span><i><b style={{ width: `${task.progress || 0}%` }} /></i></div> : null}
{isFailed ? <p className="task-record-error" title={task.error ? generationErrorMessage(task.error) : undefined}>{taskAttentionReason(task)}</p> : null}
</div>
<div className="task-record-date"><TaskDate value={task.createdAt} /></div>
{creditsEnabled ? <TaskBilling billing={task.billing} /> : <span className="task-record-billing-empty" aria-hidden="true" />}
<div className="task-record-actions">
<Tooltip title="查看详情"><Button type="text" size="small" icon={<Eye className="size-3.5" />} aria-label="查看详情" onClick={onOpen} /></Tooltip>
{isFailed ? <Tooltip title="重试任务"><Button type="text" size="small" icon={<RotateCcw className="size-3.5" />} aria-label="重试任务" loading={actingId === task.id} disabled={task.errorCode === CONTENT_MODERATION_ERROR_CODE || isContentModerationError(task.error)} onClick={onRetry} /></Tooltip> : null}
{isActive ? <Tooltip title="取消任务"><Button type="text" size="small" danger icon={<X className="size-3.5" />} aria-label="取消任务" loading={actingId === task.id} onClick={onCancel} /></Tooltip> : null}
</div>
</article>
);
}
function resultMediaUrls(value?: string) {
if (!value) return [];
let parsed: unknown;
@@ -478,98 +594,28 @@ function isVideoResult(value: string, taskType: string) {
return value.startsWith("data:video/") || /\.(mp4|webm|mov)(?:$|\?)/i.test(value) || taskType.includes("video");
}
function TaskPreviewThumbnail({ task, onOpen }: { task: GenerationTask; onOpen: () => void }) {
const isVideo = task.previewKind === "video";
const fallbackVideo = task.type.includes("video");
if (!task.previewUrl) {
const Icon = fallbackVideo ? Video : task.type.includes("image") ? ImageIcon : FileText;
return <span className="grid h-12 w-[68px] shrink-0 place-items-center rounded-md border border-border/70 bg-muted/35 text-foreground/28"><Icon className="size-4" /></span>;
function groupTasksByCanvas(tasks: GenerationTask[], canvasById: Map<string, { title: string; projectId?: string }>, projectNameById: Map<string, string>): TaskGroup[] {
const groups: TaskGroup[] = [];
const byKey = new Map<string, TaskGroup>();
for (const task of tasks) {
const context = getTaskCanvasContext(task, canvasById, projectNameById);
const key = `${context.projectName}\u0000${context.canvasName}`;
let group = byKey.get(key);
if (!group) {
group = { key, title: context.canvasName, projectName: context.projectName, tasks: [] };
byKey.set(key, group);
groups.push(group);
}
group.tasks.push(task);
}
return (
<button type="button" onClick={onOpen} className="group relative h-12 w-[68px] shrink-0 overflow-hidden rounded-md border border-border/80 bg-black focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" aria-label={isVideo ? "放大预览生成视频" : "放大预览生成图片"}>
{isVideo
? <video src={task.previewUrl} width={68} height={48} muted playsInline preload="metadata" className="h-full w-full object-cover" />
: <img src={task.previewUrl} alt="" width={68} height={48} loading="lazy" className="h-full w-full object-cover" />}
<span className="absolute inset-0 grid place-items-center bg-black/0 text-white opacity-0 transition-[background-color,opacity] duration-150 group-hover:bg-black/30 group-hover:opacity-100 group-focus-visible:bg-black/30 group-focus-visible:opacity-100">
{isVideo ? <Play className="size-4 fill-current" /> : <Eye className="size-4" />}
</span>
</button>
);
}
function getTaskCanvasContext(task: GenerationTask, canvasById: Map<string, { title: string; projectId?: string }>, projectNameById: Map<string, string>) {
if (!task.projectId) return { canvasName: "未绑定画布", projectName: "" };
const canvas = canvasById.get(task.projectId);
if (canvas) return { canvasName: canvas.title || "未命名画布", projectName: canvas.projectId ? projectNameById.get(canvas.projectId) || "" : "" };
const projectName = projectNameById.get(task.projectId);
return projectName ? { canvasName: "项目级任务", projectName } : { canvasName: "画布已移除", projectName: "" };
}
function taskAttentionReason(task: GenerationTask) {
if (task.status === "cancelled") return providerCancelStatusLabel(task);
if (task.errorCode === CONTENT_MODERATION_ERROR_CODE || isContentModerationError(task.error)) return "内容审核未通过,请修改输入后新建任务";
if (task.error) return generationErrorMessage(task.error);
return task.stage || "生成失败,打开详情查看原因";
}
function providerCancelStatusLabel(task: GenerationTask) {
if (task.providerCancelStatus === "requested") return "已请求上游取消,正在等待确认";
if (task.providerCancelStatus === "confirmed") return "上游已确认取消,积分已退回";
if (task.providerCancelStatus === "uncertain") {
if (task.billing?.status === "settled") return "上游未能取消,费用已结算";
if (task.billing?.status === "refunded") return "上游取消结果未确认,积分已退回";
return task.providerCancelError || "上游无法确认取消,费用待核对";
}
return task.billing?.status === "refunded" ? "任务在调用上游前取消,积分已退回" : "任务已取消,可按原输入重新提交";
return groups;
}
function taskEmptyState(status: TaskStatusFilter) {
if (status === "all") return { title: "还没有任务", description: "新提交的生成会在这里显示状态和实时进度。" };
if (status === "active") return { title: "没有运行中的任务", description: "新提交的生成会在这里显示排队状态和实时进度。" };
if (status === "succeeded") return { title: "还没有已完成任务", description: "生成成功后,结果预览和执行记录会保留在这里。" };
return { title: "目前没有需要处理的任务", description: "失败或取消的生成会出现在这里,并提供原因和可用操作。" };
}
function statusDotClassName(status: TaskStatus) {
if (status === "succeeded") return "task-record-dot bg-emerald-500";
if (status === "running") return "task-record-dot is-pulsing bg-amber-500";
if (status === "queued") return "task-record-dot bg-blue-500";
if (status === "failed") return "task-record-dot bg-red-500";
return "task-record-dot bg-foreground/30";
}
function taskMediaKind(task: GenerationTask): Exclude<TaskKindFilter, "all"> {
const value = `${task.type} ${task.operation || ""}`.toLowerCase();
if (value.includes("video") || value.includes("视频")) return "video";
if (value.includes("image") || value.includes("图片") || value.includes("画面")) return "image";
return "text";
}
function TaskDate({ value }: { value?: string }) {
if (!value) return <span className="text-xs text-foreground/38">-</span>;
const date = new Date(value);
if (Number.isNaN(date.getTime())) return <span className="text-xs text-foreground/38">-</span>;
const compact = `${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()} ${date.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" })}`;
return <time className="task-record-date-value" dateTime={date.toISOString()} title={date.toLocaleString()}>{compact}</time>;
}
function TaskBilling({ billing }: { billing?: GenerationTask["billing"] }) {
if (!billing) return <span className="task-record-billing-empty text-xs text-foreground/30">-</span>;
const amount = formatCredits(billing.amountMicrocredits);
const note = billing.status === "settled" ? "已结算" : billing.status === "refunded" ? "已退回" : billing.status === "uncertain" ? "待核对" : "预计";
return <div className={`task-record-billing ${billing.status === "uncertain" ? "is-uncertain" : ""}`} title={`积分${note}`}><Coins className="size-4" /><span><strong>{amount}</strong><small>{note}</small></span></div>;
}
function formatModelName(config: AiConfig, task: GenerationTask) {
const raw = (task.model || task.provider || "").trim();
const model = raw.includes("::") ? raw.split("::").pop()?.trim() || raw : raw;
if (!model) return "工作流";
if (model === "version-router") return "版本对比工作流";
if (model === "workflow-router") return "工作流路由";
if (model === "internal-agent") return "内置工作流";
if (model === "openai-compatible") return "OpenAI 兼容接口";
return modelDisplayName(config, raw);
return { title: "没有失败或取消的任务", description: "失败或取消的生成会出现在这里,并提供原因和可用操作。" };
}
function formatDate(value?: string) {

View File

@@ -0,0 +1,67 @@
import { Button, Tooltip } from "antd";
import { Eye, FileText, Image as ImageIcon, RotateCcw, Video, X } from "lucide-react";
import { CONTENT_MODERATION_ERROR_CODE, isContentModerationError } from "@/lib/generation-error";
import { statusLabel } from "@/lib/generation-task-display";
import type { GenerationTask } from "@/services/api/task-center";
import { isTaskFailed, statusDotClassName, TaskDate } from "./task-shared";
export function TaskGridCard({ task, actingId, onOpen, onRetry, onCancel }: { task: GenerationTask; actingId: string; onOpen: () => void; onRetry: () => void; onCancel: () => void }) {
const isActive = task.status === "queued" || task.status === "running";
const isFailed = isTaskFailed(task);
const isVideo = task.previewKind === "video";
const fallbackVideo = task.type.includes("video");
const Icon = fallbackVideo ? Video : task.type.includes("image") ? ImageIcon : FileText;
return (
<article className={`task-grid-card${isFailed ? " is-attention" : ""}`}>
<div className="task-grid-thumb">
{task.previewUrl ? (
isVideo ? (
<video src={task.previewUrl} muted playsInline preload="metadata" className="h-full w-full object-cover" />
) : (
<img src={task.previewUrl} alt="" loading="lazy" className="h-full w-full object-cover" />
)
) : (
<Icon />
)}
<div className="task-grid-overlay">
<Tooltip title="查看详情">
<Button type="text" size="small" icon={<Eye className="size-3.5" />} aria-label="查看详情" onClick={onOpen} />
</Tooltip>
{isFailed ? (
<Tooltip title="重试任务">
<Button
type="text"
size="small"
icon={<RotateCcw className="size-3.5" />}
aria-label="重试任务"
loading={actingId === task.id}
disabled={task.errorCode === CONTENT_MODERATION_ERROR_CODE || isContentModerationError(task.error)}
onClick={onRetry}
/>
</Tooltip>
) : null}
{isActive ? (
<Tooltip title="取消任务">
<Button type="text" size="small" danger icon={<X className="size-3.5" />} aria-label="取消任务" loading={actingId === task.id} onClick={onCancel} />
</Tooltip>
) : null}
</div>
</div>
<div className="task-grid-body">
<button type="button" className="task-grid-title" title={task.prompt} onClick={onOpen}>
{task.prompt || "未命名任务"}
</button>
<div className="task-grid-meta">
<span className={`task-grid-status ${isFailed ? "is-failed" : isActive ? "is-active" : task.status === "succeeded" ? "is-success" : ""}`}>
<i className={statusDotClassName(task.status)} />
{statusLabel[task.status]}
</span>
<span className="task-grid-date">
<TaskDate value={task.createdAt} />
</span>
</div>
</div>
</article>
);
}

View File

@@ -0,0 +1,43 @@
import { FolderKanban, RotateCcw } from "lucide-react";
import { CONTENT_MODERATION_ERROR_CODE, isContentModerationError } from "@/lib/generation-error";
import type { GenerationTask } from "@/services/api/task-center";
import { isTaskFailed } from "./task-shared";
export type TaskGroup = { key: string; title: string; projectName: string; tasks: GenerationTask[] };
export function TaskGroupHeader({ group, retrying = false, onRetryFailed }: { group: TaskGroup; retrying?: boolean; onRetryFailed: () => void }) {
const succeeded = group.tasks.filter((task) => task.status === "succeeded").length;
const active = group.tasks.filter((task) => task.status === "queued" || task.status === "running").length;
const failed = group.tasks.filter((task) => isTaskFailed(task) && task.errorCode !== CONTENT_MODERATION_ERROR_CODE && !isContentModerationError(task.error)).length;
const title = group.projectName ? `${group.title} · ${group.projectName}` : group.title;
return (
<div className="task-group-head">
<span className="task-group-ic">
<FolderKanban />
</span>
<div className="min-w-0">
<div className="task-group-name">
<span>{title}</span>
<span className="task-group-count">
共 {group.tasks.length} 项 · 完成 {succeeded}
</span>
</div>
<span className="task-group-sub">
{active ? <span>{active} 运行中</span> : null}
{active && failed ? <span aria-hidden="true"> · </span> : null}
{failed ? <b className="is-bad">{failed} 失败</b> : null}
{(active || failed) && succeeded ? <span aria-hidden="true"> · </span> : null}
{succeeded ? <span>{succeeded} 已完成</span> : null}
{!active && !failed && !succeeded ? "暂无进行中的任务" : null}
</span>
</div>
{failed ? (
<button type="button" className="task-group-reset" disabled={retrying} onClick={onRetryFailed}>
<RotateCcw className={retrying ? "animate-spin" : undefined} />
{retrying ? "重试中..." : "重试失败任务"}
</button>
) : null}
</div>
);
}

View File

@@ -0,0 +1,128 @@
import { Button, Tooltip } from "antd";
import { Eye, FileText, FolderKanban, Image as ImageIcon, Play, RotateCcw, Video, X } from "lucide-react";
import { CONTENT_MODERATION_ERROR_CODE, generationErrorMessage, isContentModerationError } from "@/lib/generation-error";
import { formatTaskKind, statusLabel } from "@/lib/generation-task-display";
import type { GenerationTask } from "@/services/api/task-center";
import type { AiConfig } from "@/stores/use-config-store";
import { formatModelName, getTaskCanvasContext, isTaskFailed, statusDotClassName, taskAttentionReason, TaskBilling, TaskDate } from "./task-shared";
export function TaskListRow({
task,
canvasById,
projectNameById,
effectiveConfig,
creditsEnabled,
actingId,
onOpen,
onRetry,
onCancel,
onPreview,
}: {
task: GenerationTask;
canvasById: Map<string, { title: string; projectId?: string }>;
projectNameById: Map<string, string>;
effectiveConfig: AiConfig;
creditsEnabled: boolean;
actingId: string;
onOpen: () => void;
onRetry: () => void;
onCancel: () => void;
onPreview: () => void;
}) {
const context = getTaskCanvasContext(task, canvasById, projectNameById);
const isActive = task.status === "queued" || task.status === "running";
const isFailed = isTaskFailed(task);
return (
<article className={`task-record-row group${isFailed ? " is-attention" : ""}`}>
<TaskPreviewThumbnail task={task} onOpen={onPreview} />
<div className="task-record-main">
<div className="task-record-heading">
<span className={`task-record-status ${isFailed ? "is-failed" : isActive ? "is-active" : "is-success"}`}>
<i className={statusDotClassName(task.status)} />
{statusLabel[task.status]}
</span>
<button type="button" className="task-record-title" title={task.prompt} onClick={onOpen}>
{task.prompt || "未命名任务"}
</button>
</div>
<div className="task-record-meta">
<span>{formatTaskKind(task)}</span>
<span aria-hidden="true">·</span>
<span>{formatModelName(effectiveConfig, task)}</span>
<span className="task-record-meta-canvas">
<FolderKanban className="size-3" />
{context.canvasName}
{context.projectName ? ` · ${context.projectName}` : ""}
</span>
</div>
{isActive ? (
<div className="task-record-progress">
<span>{task.stage || "正在生成"}</span>
<span>{task.progress || 0}%</span>
<i>
<b style={{ width: `${task.progress || 0}%` }} />
</i>
</div>
) : null}
{isFailed ? (
<p className="task-record-error" title={task.error ? generationErrorMessage(task.error) : undefined}>
{taskAttentionReason(task)}
</p>
) : null}
</div>
<div className="task-record-date">
<TaskDate value={task.createdAt} />
</div>
{creditsEnabled ? <TaskBilling billing={task.billing} /> : <span className="task-record-billing-empty" aria-hidden="true" />}
<div className="task-record-actions">
<Tooltip title="查看详情">
<Button type="text" size="small" icon={<Eye className="size-3.5" />} aria-label="查看详情" onClick={onOpen} />
</Tooltip>
{isFailed ? (
<Tooltip title="重试任务">
<Button
type="text"
size="small"
icon={<RotateCcw className="size-3.5" />}
aria-label="重试任务"
loading={actingId === task.id}
disabled={task.errorCode === CONTENT_MODERATION_ERROR_CODE || isContentModerationError(task.error)}
onClick={onRetry}
/>
</Tooltip>
) : null}
{isActive ? (
<Tooltip title="取消任务">
<Button type="text" size="small" danger icon={<X className="size-3.5" />} aria-label="取消任务" loading={actingId === task.id} onClick={onCancel} />
</Tooltip>
) : null}
</div>
</article>
);
}
function TaskPreviewThumbnail({ task, onOpen }: { task: GenerationTask; onOpen: () => void }) {
const isVideo = task.previewKind === "video";
const fallbackVideo = task.type.includes("video");
if (!task.previewUrl) {
const Icon = fallbackVideo ? Video : task.type.includes("image") ? ImageIcon : FileText;
return (
<span className="task-record-thumb">
<Icon className="size-4" />
</span>
);
}
return (
<button type="button" onClick={onOpen} className="task-record-thumb group focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" aria-label={isVideo ? "放大预览生成视频" : "放大预览生成图片"}>
{isVideo ? (
<video src={task.previewUrl} width={68} height={48} muted playsInline preload="metadata" className="h-full w-full object-cover" />
) : (
<img src={task.previewUrl} alt="" width={68} height={48} loading="lazy" className="h-full w-full object-cover" />
)}
<span className="absolute inset-0 grid place-items-center bg-black/0 text-white opacity-0 transition-[background-color,opacity] duration-150 group-hover:bg-black/30 group-hover:opacity-100 group-focus-visible:bg-black/30 group-focus-visible:opacity-100">
{isVideo ? <Play className="size-4 fill-current" /> : <Eye className="size-4" />}
</span>
</button>
);
}

View File

@@ -0,0 +1,90 @@
import { Coins } from "lucide-react";
import { formatCredits } from "@/constant/credits";
import { CONTENT_MODERATION_ERROR_CODE, generationErrorMessage, isContentModerationError } from "@/lib/generation-error";
import type { GenerationTask, TaskStatus } from "@/services/api/task-center";
import { modelDisplayName, type AiConfig } from "@/stores/use-config-store";
export function getTaskCanvasContext(task: GenerationTask, canvasById: Map<string, { title: string; projectId?: string }>, projectNameById: Map<string, string>) {
if (!task.projectId) return { canvasName: "未绑定画布", projectName: "" };
const canvas = canvasById.get(task.projectId);
if (canvas) return { canvasName: canvas.title || "未命名画布", projectName: canvas.projectId ? projectNameById.get(canvas.projectId) || "" : "" };
const projectName = projectNameById.get(task.projectId);
return projectName ? { canvasName: "项目级任务", projectName } : { canvasName: "画布已移除", projectName: "" };
}
export function isTaskFailed(task: GenerationTask) {
return task.status === "failed" || task.status === "cancelled";
}
export function taskAttentionReason(task: GenerationTask) {
if (task.status === "cancelled") return providerCancelStatusLabel(task);
if (task.errorCode === CONTENT_MODERATION_ERROR_CODE || isContentModerationError(task.error)) return "内容审核未通过,请修改输入后新建任务";
if (task.error) return generationErrorMessage(task.error);
return task.stage || "生成失败,打开详情查看原因";
}
export function providerCancelStatusLabel(task: GenerationTask) {
if (task.providerCancelStatus === "requested") return "已请求上游取消,正在等待确认";
if (task.providerCancelStatus === "confirmed") return "上游已确认取消,积分已退回";
if (task.providerCancelStatus === "uncertain") {
if (task.billing?.status === "settled") return "上游未能取消,费用已结算";
if (task.billing?.status === "refunded") return "上游取消结果未确认,积分已退回";
return task.providerCancelError || "上游无法确认取消,费用待核对";
}
return task.billing?.status === "refunded" ? "任务在调用上游前取消,积分已退回" : "任务已取消,可按原输入重新提交";
}
export function statusDotClassName(status: TaskStatus) {
if (status === "succeeded") return "task-record-dot is-success";
if (status === "running") return "task-record-dot is-active is-pulsing";
if (status === "queued") return "task-record-dot is-queued";
if (status === "failed") return "task-record-dot is-failed";
return "task-record-dot is-idle";
}
export function taskMediaKind(task: GenerationTask): "text" | "image" | "video" {
const value = `${task.type} ${task.operation || ""}`.toLowerCase();
if (value.includes("video") || value.includes("视频")) return "video";
if (value.includes("image") || value.includes("图片") || value.includes("画面")) return "image";
return "text";
}
export function TaskDate({ value }: { value?: string }) {
if (!value) return <span className="text-xs text-foreground/38">-</span>;
const date = new Date(value);
if (Number.isNaN(date.getTime())) return <span className="text-xs text-foreground/38">-</span>;
const compact = `${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()} ${date.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" })}`;
return (
<time className="task-record-date-value" dateTime={date.toISOString()} title={date.toLocaleString()}>
{compact}
</time>
);
}
export function TaskBilling({ billing }: { billing?: GenerationTask["billing"] }) {
if (!billing) return <span className="task-record-billing-empty text-xs text-foreground/30">-</span>;
const amount = formatCredits(billing.amountMicrocredits);
const note = billing.status === "settled" ? "已结算" : billing.status === "refunded" ? "已退回" : billing.status === "uncertain" ? "待核对" : "预计";
return (
<div className={`task-record-billing ${billing.status === "uncertain" ? "is-uncertain" : ""}`} title={`积分${note}`}>
<Coins className="size-4" />
<span>
<strong>{amount}</strong>
<small>{note}</small>
</span>
</div>
);
}
export function formatModelName(config: AiConfig, task: GenerationTask) {
const raw = (task.model || task.provider || "").trim();
const model = raw.includes("::") ? raw.split("::").pop()?.trim() || raw : raw;
if (!model) return "工作流";
if (model === "version-router") return "版本对比工作流";
if (model === "workflow-router") return "工作流路由";
if (model === "internal-agent") return "内置工作流";
if (model === "openai-compatible") return "OpenAI 兼容接口";
return modelDisplayName(config, raw);
}

View File

@@ -0,0 +1,36 @@
export type TaskStatusFilter = "all" | "failed" | "active" | "succeeded";
export type TaskStats = { today: number; active: number; succeeded: number; failed: number };
export function TaskStatPills({ stats, statusFilter, onFilterChange }: { stats: TaskStats; statusFilter: TaskStatusFilter; onFilterChange: (value: TaskStatusFilter) => void }) {
return (
<div className="task-stat-pills">
<span className="task-stat-pill">
<span className="task-stat-dot" />
今日生成<b>{stats.today}</b>
</span>
<button type="button" aria-pressed={statusFilter === "active"} className={`task-stat-pill is-clickable${statusFilter === "active" ? " is-active-filter" : ""}`} onClick={() => onFilterChange(statusFilter === "active" ? "all" : "active")}>
<span className="task-stat-dot is-run" />
运行中<b>{stats.active}</b>
</button>
<button
type="button"
aria-pressed={statusFilter === "succeeded"}
className={`task-stat-pill is-clickable${statusFilter === "succeeded" ? " is-active-filter" : ""}`}
onClick={() => onFilterChange(statusFilter === "succeeded" ? "all" : "succeeded")}
>
<span className="task-stat-dot is-ok" />
已完成<b>{stats.succeeded}</b>
</button>
<button
type="button"
aria-pressed={statusFilter === "failed"}
className={`task-stat-pill is-clickable is-fail${statusFilter === "failed" ? " is-active-filter" : ""}`}
onClick={() => onFilterChange(statusFilter === "failed" ? "all" : "failed")}
>
<span className="task-stat-dot is-bad" />
失败<b>{stats.failed}</b>
</button>
</div>
);
}

View File

@@ -7,7 +7,6 @@ import { ArrowDownLeft, ArrowUpRight, CalendarCheck, Coins, RefreshCw, RotateCcw
import { formatCredits } from "@/constant/credits";
import { PaginationBar, TableSurface } from "@/components/layout/workspace-page";
import { WorkspaceState } from "@/components/layout/workspace-state";
import { WorkspaceSignalIcon } from "@/components/ui/aceternity/workspace-signal-icon";
import { CometCard } from "@/components/ui/aceternity/comet-card";
import { aceternityMotion } from "@/lib/aceternity-motion";
import { checkinCredits, getWallet, redeemCredits, type CreditLedgerEntry, type WalletSummary } from "@/services/api/wallet";
@@ -119,38 +118,46 @@ export default function WalletPage() {
return (
<main className="app-user-content app-workspace-scroll library-page wallet-library-page relative h-full overflow-y-auto text-foreground">
<div className="relative mx-auto max-w-[1440px] px-4 py-6 sm:px-6 lg:px-8">
<motion.header initial={reducedMotion ? false : { opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: aceternityMotion.duration.panel, ease: aceternityMotion.easing.enter }} className="app-page-header flex flex-wrap items-start justify-between gap-4 pb-6">
<div className="flex items-center gap-3">
<WorkspaceSignalIcon variant="wallet" />
<div>
<h1 className="text-[var(--fs-title)] font-semibold leading-7">积分中心</h1>
<p className="mt-1 text-xs leading-5 text-foreground/58">模型调用、冻结与退款都在同一条可追溯流水中。</p>
<div className="relative w-full px-4 py-6 sm:px-6 lg:px-8">
<div className="studio-band">
<motion.header initial={reducedMotion ? false : { opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: aceternityMotion.duration.panel, ease: aceternityMotion.easing.enter }} className="app-page-header flex flex-wrap items-start justify-between gap-4">
<div className="flex min-w-0 items-center gap-3">
<div className="min-w-0">
<h1 className="text-[var(--fs-heading-lg)] font-semibold leading-7">积分中心</h1>
<p className="mt-1 text-xs leading-5 text-foreground/58">模型调用、冻结与退款都在同一条可追溯流水中。</p>
</div>
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button icon={<CalendarCheck className="size-4" />} type={wallet?.policy.checkedInToday ? "default" : "primary"} loading={checkingIn} disabled={wallet?.policy.checkedInToday} onClick={() => void checkin()}>
{wallet?.policy.checkedInToday ? "今日已签到" : `签到 +${formatCredits(wallet?.policy.checkinBonusMicrocredits || 0)}`}
</Button>
<Button icon={<RefreshCw className="size-4" />} loading={loading} onClick={() => void reload()}>
刷新余额
</Button>
</div>
</motion.header>
<div className="flex flex-wrap items-center gap-2">
<span className="app-projects-header-meta wallet-credit-meta">
<Coins className="size-3" />
可用 {formatCredits(account?.availableMicrocredits || 0, 6)}
</span>
<Button className="library-primary-action" icon={<CalendarCheck className="size-4" />} type={wallet?.policy.checkedInToday ? "default" : "primary"} loading={checkingIn} disabled={wallet?.policy.checkedInToday} onClick={() => void checkin()}>
{wallet?.policy.checkedInToday ? "今日已签到" : `签到 +${formatCredits(wallet?.policy.checkinBonusMicrocredits || 0)}`}
</Button>
<Button icon={<RefreshCw className="size-4" />} loading={loading} onClick={() => void reload()}>
刷新余额
</Button>
</div>
</motion.header>
</div>
<section className="library-feature-grid mt-6 grid gap-4 lg:grid-cols-[minmax(0,1.35fr)_minmax(340px,0.65fr)]">
<CometCard rotateDepth={2.2} translateDepth={2} glare={!reducedMotion} className="credit-balance-card overflow-hidden rounded-lg border">
<div className="flex min-h-[210px] flex-col justify-between p-5 sm:p-6">
<div className="wallet-balance-inner flex min-h-[210px] flex-col justify-between p-5 sm:p-6">
<div className="flex items-start justify-between gap-4">
<div>
<div className="flex items-center gap-2 text-xs font-medium opacity-60">
<Coins className="size-4 text-amber-500" />
<Coins className="size-4" />
可用创作积分
</div>
<div className="mt-4 text-5xl font-semibold tabular-nums">{formatCredits(account?.availableMicrocredits || 0, 6)}</div>
<div className="wallet-balance-number mt-4 flex items-baseline gap-2">
<span className="text-5xl font-semibold tabular-nums">{formatCredits(account?.availableMicrocredits || 0, 6)}</span>
<span className="text-sm opacity-55">积分</span>
</div>
<div className="mt-2 text-xs opacity-45">最近更新 {formatTime(account?.updatedAt)}</div>
</div>
<span className="inline-flex items-center gap-1.5 rounded-full border border-current/10 px-2.5 py-1 text-[var(--fs-label)] font-medium opacity-70">
<span className="inline-flex items-center gap-1.5 rounded-full border border-current/15 px-2.5 py-1 text-[var(--fs-label)] font-medium opacity-70">
<ShieldCheck className="size-3.5" />
账户正常
</span>
@@ -164,7 +171,7 @@ export default function WalletPage() {
<motion.div initial={reducedMotion ? false : { opacity: 0, x: 12 }} animate={{ opacity: 1, x: 0 }} transition={{ duration: aceternityMotion.duration.panel, ease: aceternityMotion.easing.enter }} className="wallet-redeem-panel app-workspace-surface flex flex-col rounded-lg border p-5 backdrop-blur-xl sm:p-6">
<div className="flex items-start gap-3">
<span className="grid size-9 shrink-0 place-items-center rounded-xl border border-amber-400/25 bg-amber-400/10 text-amber-600 dark:text-amber-300">
<span className="wallet-redeem-icon grid size-9 shrink-0 place-items-center rounded-xl border">
<TicketCheck className="size-4" />
</span>
<div>
@@ -279,16 +286,16 @@ function LedgerTypeTag({ type }: { type: CreditLedgerEntry["type"] }) {
function ledgerTypeMeta(type: CreditLedgerEntry["type"]) {
const values = {
redeem: { label: "兑换充值", tagColor: "success", icon: <ArrowDownLeft className="size-4" />, iconClass: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-300" },
admin_grant: { label: "管理员充值", tagColor: "blue", icon: <ArrowDownLeft className="size-4" />, iconClass: "bg-sky-500/10 text-sky-600 dark:text-sky-300" },
redeem: { label: "兑换充值", tagColor: "default", icon: <ArrowDownLeft className="size-4" />, iconClass: "bg-foreground/8 text-foreground/70" },
admin_grant: { label: "管理员充值", tagColor: "default", icon: <ArrowDownLeft className="size-4" />, iconClass: "bg-foreground/8 text-foreground/70" },
consume: { label: "模型消费", tagColor: "error", icon: <Sparkles className="size-4" />, iconClass: "bg-rose-500/10 text-rose-600 dark:text-rose-300" },
reserve: { label: "积分冻结", tagColor: "warning", icon: <ArrowUpRight className="size-4" />, iconClass: "bg-amber-500/10 text-amber-600 dark:text-amber-300" },
refund: { label: "消费退款", tagColor: "warning", icon: <RotateCcw className="size-4" />, iconClass: "bg-amber-500/10 text-amber-600 dark:text-amber-300" },
admin_adjustment: { label: "管理员调账", tagColor: "default", icon: <SlidersHorizontal className="size-4" />, iconClass: "bg-muted text-foreground/60" },
signup_bonus: { label: "注册奖励", tagColor: "gold", icon: <Sparkles className="size-4" />, iconClass: "bg-amber-500/10 text-amber-600 dark:text-amber-300" },
checkin_bonus: { label: "签到奖励", tagColor: "cyan", icon: <CalendarCheck className="size-4" />, iconClass: "bg-cyan-500/10 text-cyan-600 dark:text-cyan-300" },
admin_adjustment: { label: "管理员调账", tagColor: "default", icon: <SlidersHorizontal className="size-4" />, iconClass: "bg-foreground/8 text-foreground/70" },
signup_bonus: { label: "注册奖励", tagColor: "default", icon: <Sparkles className="size-4" />, iconClass: "bg-foreground/8 text-foreground/70" },
checkin_bonus: { label: "签到奖励", tagColor: "default", icon: <CalendarCheck className="size-4" />, iconClass: "bg-foreground/8 text-foreground/70" },
} as const;
return values[type] || { label: "其他积分变动", tagColor: "default", icon: <ArrowUpRight className="size-4" />, iconClass: "bg-muted text-foreground/60" };
return values[type] || { label: "其他积分变动", tagColor: "default", icon: <ArrowUpRight className="size-4" />, iconClass: "bg-foreground/8 text-foreground/70" };
}
function ledgerTitle(entry: CreditLedgerEntry) {

View File

@@ -387,6 +387,11 @@ function toChatCompletionToolChoice(toolChoice: ToolChoice) {
return typeof toolChoice === "object" ? { type: "function", function: { name: toolChoice.name } } : toolChoice;
}
function isToolChoiceCompatibilityError(error: unknown) {
const message = error instanceof Error ? error.message : "";
return /tool[_\s-]?choice|thinking\s+mode/i.test(message);
}
function parseChatCompletionPayload(payload: ChatCompletionPayload): ToolResponseResult {
if (typeof payload.code === "number" && payload.code !== 0) throw new Error(payload.msg || "请求失败");
if (payload.error?.message) throw new Error(payload.error.message);
@@ -997,7 +1002,7 @@ export async function requestImageQuestion(config: AiConfig, messages: AiTextMes
if (answer === "没有返回内容") onDelta(answer);
return answer;
}
if (requestConfig.interfaceType === "chat-completion") {
if (requestConfig.interfaceType === "chat-completion" || !requestConfig.interfaceType) {
const answer =
(
await requestStreamingChatCompletion(
@@ -1038,19 +1043,31 @@ export async function requestToolResponse(config: AiConfig, messages: ResponseIn
if (requestConfig.apiFormat === "gemini") {
return await requestGeminiStreamingResponse(requestConfig, toGeminiBody(requestConfig, messages, toGeminiToolOptions(tools, toolChoice)), onDelta, options);
}
if (requestConfig.interfaceType === "chat-completion") {
return await requestStreamingChatCompletion(
requestConfig,
{
model: requestConfig.model,
messages: toChatCompletionMessages(withSystemMessage(requestConfig, messages)),
tools,
tool_choice: toChatCompletionToolChoice(toolChoice),
parallel_tool_calls: false,
},
onDelta,
options,
);
if (requestConfig.interfaceType === "chat-completion" || !requestConfig.interfaceType) {
const chatPayload: Record<string, unknown> = {
model: requestConfig.model,
messages: toChatCompletionMessages(withSystemMessage(requestConfig, messages)),
tools,
tool_choice: toChatCompletionToolChoice(toolChoice),
parallel_tool_calls: false,
};
try {
return await requestStreamingChatCompletion(requestConfig, chatPayload, onDelta, options);
} catch (error) {
if (!isToolChoiceCompatibilityError(error)) throw error;
// 部分 OpenAI 兼容上游仅支持 auto,思考模式则可能要求完全省略该字段。
if (toolChoice !== "auto") {
try {
return await requestStreamingChatCompletion(requestConfig, { ...chatPayload, tool_choice: toChatCompletionToolChoice("auto") }, onDelta, options);
} catch (autoError) {
if (!isToolChoiceCompatibilityError(autoError)) throw autoError;
}
}
const { tool_choice: _ignored, ...withoutToolChoice } = chatPayload;
void _ignored;
return await requestStreamingChatCompletion(requestConfig, withoutToolChoice, onDelta, options);
}
}
return await requestStreamingResponse(
requestConfig,

File diff suppressed because it is too large Load Diff