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