diff --git a/web/src/components/canvas/canvas-project-card.tsx b/web/src/components/canvas/canvas-project-card.tsx index ab2155777..bc547cf22 100644 --- a/web/src/components/canvas/canvas-project-card.tsx +++ b/web/src/components/canvas/canvas-project-card.tsx @@ -1,4 +1,4 @@ -import { Check, Clapperboard, Download, FileText, Frame, Image as ImageIcon, MoreHorizontal, Music2, Pencil, Settings2, Sparkles, Trash2, Video, X } from "lucide-react"; +import { Check, Clapperboard, Download, FileText, Frame, Image as ImageIcon, MoreHorizontal, Music2, Pencil, Plus, Settings2, Sparkles, Trash2, Video, X } from "lucide-react"; import { useNavigate, useSearchParams } from "react-router"; import { Dropdown, Input } from "antd"; @@ -10,6 +10,14 @@ import { resourceFileUrl, resourceIdFromStorageKey } from "@/services/api/resour import { resolveBackendApiUrl } from "@/stores/use-config-store"; import { cn } from "@/lib/utils"; +export function CanvasCreateCard({ disabled, onClick }: { disabled?: boolean; onClick: () => void }) { + return ; +} + export function CanvasProjectCard({ project, projectName, variant = "library" }: { project: CanvasProject; projectName?: string; variant?: "library" | "recent" }) { const navigate = useNavigate(); const [searchParams] = useSearchParams(); @@ -32,11 +40,11 @@ export function CanvasProjectCard({ project, projectName, variant = "library" }: const compact = variant === "recent"; return ( -
!editing && open()}> +
!editing && open()}>
- {!compact ? event.stopPropagation()}> toggleSelected(project.id, event.target.checked)} className="app-canvas-project-checkbox size-3.5" aria-label={`选择 ${project.title}`} /> : null} - {project.nodes.length} 节点 + {!compact ? event.stopPropagation()}> toggleSelected(project.id, event.target.checked)} className="app-canvas-project-checkbox" aria-label={`选择 ${project.title}`} /> : null} +
-
-
- {editing ? ( - event.stopPropagation()} onChange={(event) => setEditingTitle(event.target.value)} onKeyDown={(event) => event.key === "Enter" && saveTitle()} autoFocus /> - ) : ( - - )} +
+
{editing ? ( -
event.stopPropagation()}> - - + event.stopPropagation()} onChange={(event) => setEditingTitle(event.target.value)} onKeyDown={(event) => event.key === "Enter" && saveTitle()} autoFocus /> + ) : ( + + )} + {editing ? ( +
event.stopPropagation()}> + +
) : ( -
event.stopPropagation()}> - +
event.stopPropagation()}> + - +
)}
-
- {projectName || "自由画布"} - - {project.connections.length} 条连线 -
-

更新于 {formatProjectTime(project.updatedAt)}

+
{projectName || "自由画布"}
); @@ -110,25 +113,25 @@ function ProjectPreview({ project }: { project: CanvasProject }) { if (media) { const { node, url } = media; return ( -
+
{node.type === CanvasNodeType.Video - ?
+ ?
: {node.title}
); } const nodes = project.nodes.slice(0, 8); - if (!nodes.length) return
空白画布
; + if (!nodes.length) return
空白画布等待第一幕
; const previewNodes = buildNodePreviewLayout(nodes); return ( -
+
{previewNodes.map(({ node, style }) => { const presentation = getNodePresentation(node); return ( - - {presentation.icon} - {node.title || presentation.label} + + {presentation.icon} + {node.title || presentation.label} ); })} @@ -186,5 +189,15 @@ function isPreviewUrl(value?: string) { } function formatProjectTime(value: string) { - return new Date(value).toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" }); + const timestamp = new Date(value).getTime(); + if (!Number.isFinite(timestamp)) return "刚刚修改"; + const elapsed = Math.max(0, Date.now() - timestamp); + const minutes = Math.floor(elapsed / 60000); + if (minutes < 1) return "刚刚修改"; + if (minutes < 60) return `${minutes} 分钟前修改`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours} 小时前修改`; + const days = Math.floor(hours / 24); + if (days < 7) return `${days} 天前修改`; + return new Date(value).toLocaleDateString("zh-CN", { month: "numeric", day: "numeric" }) + " 修改"; } diff --git a/web/src/components/layout/client-root-init.tsx b/web/src/components/layout/client-root-init.tsx index a59405d5a..675344d3b 100644 --- a/web/src/components/layout/client-root-init.tsx +++ b/web/src/components/layout/client-root-init.tsx @@ -11,6 +11,21 @@ export function ClientRootInit({ children }: { children: ReactNode }) { const updateConfig = useConfigStore((state) => state.updateConfig); const config = useConfigStore((state) => state.config); + useEffect(() => { + const interactiveSelector = 'button, [role="button"], a, [class*="card"], [class*="Card"]'; + const blurPointerFocus = (event: PointerEvent) => { + if (event.pointerType === "mouse" && event.button !== 0) return; + const target = event.target instanceof Element ? event.target.closest(interactiveSelector) : null; + if (!target || target.hasAttribute("disabled") || target.getAttribute("aria-disabled") === "true") return; + // 浏览器可能把鼠标点击误判为 :focus-visible;下一帧只清掉这次指针点击产生的焦点。 + window.requestAnimationFrame(() => { + if (document.activeElement === target) target.blur(); + }); + }; + document.addEventListener("pointerdown", blurPointerFocus, true); + return () => document.removeEventListener("pointerdown", blurPointerFocus, true); + }, []); + useEffect(() => { if (handledConfigParams.current) return; const searchParams = new URLSearchParams(window.location.search); diff --git a/web/src/pages/assets/index.tsx b/web/src/pages/assets/index.tsx index 6afcab127..90e0998b0 100644 --- a/web/src/pages/assets/index.tsx +++ b/web/src/pages/assets/index.tsx @@ -83,6 +83,7 @@ export default function AssetsPage() { const selectedAssets = useMemo(() => validAssets.filter((asset) => selectedIds.includes(asset.id)), [selectedIds, validAssets]); const kindCounts = useMemo(() => new Map(kindOptions.map((option) => [option.value, option.value === "all" ? validAssets.length : validAssets.filter((asset) => asset.kind === option.value).length])), [validAssets]); const categoryCounts = useMemo(() => new Map(categoryOptions.map((option) => [option.value, option.value === "all" ? validAssets.length : validAssets.filter((asset) => (asset.category || "other") === option.value).length])), [validAssets]); + const canCreateAsset = !keyword.trim() && kindFilter === "all" && categoryFilter === "all"; const filteredAssets = useMemo(() => { const query = keyword.trim().toLowerCase(); @@ -255,7 +256,7 @@ export default function AssetsPage() { return ( <> - + , label: "导入素材包", onClick: () => assetInputRef.current?.click() }, { key: "model", icon: , label: "上传 3D 模型", onClick: () => modelInputRef.current?.click() }] }}> - )} /> - { setKeyword(""); setKindFilter("all"); setCategoryFilter("all"); setPage(1); }}> + { setKeyword(""); setKindFilter("all"); setCategoryFilter("all"); setPage(1); }}> } value={keyword} placeholder="搜索标题、内容、标签或来源" onChange={(event) => { setPage(1); setKeyword(event.target.value); }} /> @@ -287,20 +287,25 @@ export default function AssetsPage() {
) : null} - + + {canCreateAsset ? : null} {visibleAssets.map((asset) => ( 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)} /> ))} - {!visibleAssets.length ? } onClick={openCreate}>新增素材 : undefined} /> : null} + {!visibleAssets.length && (validAssets.length > 0 || !canCreateAsset) ? } onClick={openCreate}>新增素材 : undefined} /> : null} { setPage(nextPageSize !== pageSize ? 1 : nextPage); setPageSize(nextPageSize); }} />
- setIsAssetOpen(false)} onOk={() => void saveAsset()} okText="保存" cancelText="取消" destroyOnHidden> + setIsAssetOpen(false)} onOk={() => void saveAsset()} okText="保存" cancelText="取消" destroyOnHidden>
@@ -414,10 +419,10 @@ export default function AssetsPage() { void importAssetZip(event.target.files?.[0])} /> { void readModelFile(event.target.files?.[0]); event.currentTarget.value = ""; }} /> - setDeletingAsset(null)} onOk={() => void confirmDelete()} okText="删除" okButtonProps={{ danger: true }} cancelText="取消"> + setDeletingAsset(null)} onOk={() => void confirmDelete()} okText="删除" okButtonProps={{ danger: true }} cancelText="取消"> 确定删除「{deletingAsset?.title}」吗?删除后会从我的素材中移除。 - setBatchDeleteOpen(false)} onOk={() => void confirmBatchDelete()} okText="删除" okButtonProps={{ danger: true }} cancelText="取消"> + setBatchDeleteOpen(false)} onOk={() => void confirmBatchDelete()} okText="删除" okButtonProps={{ danger: true }} cancelText="取消"> 确定删除已选择的 {selectedAssets.length} 个素材吗?此操作会同步移除远端记录。 @@ -427,7 +432,7 @@ 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); return ( -
+
} /> @@ -485,7 +490,7 @@ function AssetFilterGroup({ title, options, value, counts, onChange, className = function AssetDrawer({ asset, onClose, onCopy, onDownload }: { asset: LibraryAsset | null; onClose: () => void; onCopy: (asset: LibraryAsset) => void; onDownload: (asset: LibraryAsset) => void }) { return ( - + {asset ? (
{asset.kind === "text" ? asset.data.content : "暂无封面"}
} /> @@ -502,7 +507,7 @@ function AssetDrawer({ asset, onClose, onCopy, onDownload }: { asset: LibraryAss ))}
-
+
内容 diff --git a/web/src/pages/canvas/index.tsx b/web/src/pages/canvas/index.tsx index 8c9873d35..aed458f46 100644 --- a/web/src/pages/canvas/index.tsx +++ b/web/src/pages/canvas/index.tsx @@ -1,16 +1,16 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { useNavigate, useSearchParams } from "react-router"; import { useQuery } from "@tanstack/react-query"; -import { App, Button, Dropdown, Input, Modal, Select } from "antd"; -import { Download, FileUp, MoreHorizontal, Plus, Search, Trash2 } from "lucide-react"; +import { App, Button, Dropdown, Modal, Select } from "antd"; +import { ArrowDownAZ, Clock3, Download, FileUp, ListFilter, MoreHorizontal, Search, SlidersHorizontal, Trash2, X } from "lucide-react"; -import { CollectionGrid, ListToolbar, PageHeader, PaginationBar, WorkspacePage } from "@/components/layout/workspace-page"; +import { CollectionGrid, PageHeader, PaginationBar, WorkspacePage } from "@/components/layout/workspace-page"; import { WorkspaceLoadingState, WorkspaceState } from "@/components/layout/workspace-state"; import { readZip } from "@/lib/zip"; import { setMediaBlob } from "@/services/file-storage"; import { setImageBlob } from "@/services/image-storage"; -import { CanvasProjectCard } from "@/components/canvas/canvas-project-card"; +import { CanvasCreateCard, CanvasProjectCard } from "@/components/canvas/canvas-project-card"; import type { CanvasExportFile } from "@/types/canvas-export"; import { useCanvasStore } from "@/stores/canvas/use-canvas-store"; import { useCanvasUiStore } from "@/stores/canvas/use-canvas-ui-store"; @@ -61,7 +61,20 @@ export default function CanvasPage() { }, [keyword, projectFilter, projects, sort]); const projectNames = useMemo(() => new Map((projectQuery.data?.projects || []).map(({ project }) => [project.id, project.name])), [projectQuery.data]); const visibleProjects = filteredProjects.slice((page - 1) * pageSize, page * pageSize); + const showCreateCard = !keyword.trim() && projectFilter === "all"; const selectedProjects = projects.filter((project) => selectedIds.includes(project.id)); + const projectFilterLabel = projectFilter === "all" ? "全部画布" : projectFilter === "independent" ? "自由画布" : projectNames.get(projectFilter) || "项目画布"; + const sortLabel = sort === "name" ? "按名称" : sort === "nodes" ? "按节点" : "最近更新"; + const projectFilterItems = useMemo(() => [ + { key: "all", label: "全部画布" }, + { key: "independent", label: "自由画布" }, + ...(projectQuery.data?.projects || []).map(({ project }) => ({ key: project.id, label: project.name })), + ], [projectQuery.data]); + const sortItems = [ + { key: "updated", label: "最近更新", icon: }, + { key: "name", label: "按名称", icon: }, + { key: "nodes", label: "按节点数量", icon: }, + ]; const associateSelected = async (nextProjectId = associationProjectId) => { const projectId = nextProjectId || undefined; selectedIds.forEach((id) => updateProject(id, { projectId })); @@ -153,33 +166,36 @@ export default function CanvasPage() { {hydrated ? `${filteredProjects.length} 个` : "载入中"}} + description="把镜头、素材和想法留在同一张画布里。" actions={( <> {projects.length ? ( , label: "删除全部画布", onClick: () => setDeleteIds(projects.map((project) => project.id)) }] }} trigger={["click"]}> - )} /> - 显示 {visibleProjects.length} / {filteredProjects.length}} - onReset={() => { setKeyword(""); setProjectFilter("all"); setSort("updated"); setPage(1); }} - > -
- } value={keyword} placeholder="搜索画布" aria-label="搜索画布" onChange={(event) => { setKeyword(event.target.value); setPage(1); }} /> +
+
+
- { setSort(value); setPage(1); }} options={[{ label: "最近更新", value: "updated" }, { label: "名称排序", value: "name" }, { label: "节点数量", value: "nodes" }]} /> - +
+ { setProjectFilter(String(key)); setPage(1); } }}> + + + { setSort(key as typeof sort); setPage(1); } }}> + + + {keyword || projectFilter !== "all" || sort !== "updated" ? : null} +
+ {String(filteredProjects.length).padStart(2, "0")}/ {String(projects.length).padStart(2, "0")} 画布 +
{selectedIds.length ? (
@@ -193,14 +209,15 @@ export default function CanvasPage() { {!hydrated ? ( - ) : visibleProjects.length ? ( + ) : showCreateCard || visibleProjects.length ? ( + {showCreateCard ? : null} {visibleProjects.map((project) => ( ))} ) : ( - } onClick={createAndEnter}>新建画布 : undefined} /> + )} { setPage(nextPageSize !== pageSize ? 1 : nextPage); setPageSize(nextPageSize); }} /> diff --git a/web/src/pages/create/creation-assets.ts b/web/src/pages/create/creation-assets.ts index f868a2276..d6c994284 100644 --- a/web/src/pages/create/creation-assets.ts +++ b/web/src/pages/create/creation-assets.ts @@ -2,8 +2,9 @@ import type { UploadedFile } from "@/services/file-storage"; import type { UploadedImage } from "@/services/image-storage"; import type { Asset, ImageAsset, NewAsset } from "@/stores/use-asset-store"; import type { ReferenceImage } from "@/types/image"; +import type { ReferenceVideo } from "@/types/media"; -export type CreationAttachment = ReferenceImage & { previewUrl: string }; +export type CreationAttachment = (ReferenceImage | ReferenceVideo) & { previewUrl: string }; export type CreationAssetIdentity = { taskId?: string; @@ -45,6 +46,21 @@ export function creationAttachmentFromImage(file: File, uploaded: UploadedImage) }; } +export function creationAttachmentFromVideo(file: File, uploaded: UploadedFile): CreationAttachment { + return { + id: `upload:${file.name}:${uploaded.storageKey}`, + name: file.name, + type: uploaded.mimeType || file.type || "video/mp4", + url: uploaded.url, + storageKey: uploaded.storageKey, + bytes: uploaded.bytes, + width: uploaded.width, + height: uploaded.height, + durationMs: uploaded.durationMs, + previewUrl: uploaded.url, + }; +} + export function creationAttachmentFromAsset(asset: ImageAsset): CreationAttachment { const url = asset.data.dataUrl || asset.coverUrl; return { @@ -61,6 +77,21 @@ export function creationAttachmentFromAsset(asset: ImageAsset): CreationAttachme }; } +export function creationAttachmentFromVideoAsset(asset: Extract): CreationAttachment { + return { + id: `asset:${asset.id}`, + name: asset.title || "素材视频", + type: asset.data.mimeType || "video/mp4", + url: asset.data.url, + storageKey: asset.data.storageKey, + bytes: asset.data.bytes, + width: asset.data.width, + height: asset.data.height, + durationMs: asset.data.durationMs, + previewUrl: asset.coverUrl || asset.data.url, + }; +} + export function creationImageAsset({ title, uploaded, metadata }: { title: string; uploaded: UploadedImage; metadata?: Record }): NewAsset { return { kind: "image", diff --git a/web/src/pages/create/creation-references.ts b/web/src/pages/create/creation-references.ts index d91360bc4..c1320279e 100644 --- a/web/src/pages/create/creation-references.ts +++ b/web/src/pages/create/creation-references.ts @@ -1,13 +1,13 @@ import { buildSkillMentionReferences, renderSkillPrompt } from "@/lib/canvas/canvas-skill-mentions"; import { canvasResourceMentionToken, type CanvasResourceReference } from "@/lib/canvas/canvas-resource-references"; import type { Skill } from "@/services/api/skills"; -import type { ReferenceImage } from "@/types/image"; +import type { CreationAttachment } from "./creation-assets"; export type CreationReference = CanvasResourceReference & { attachmentId?: string; }; -export function buildCreationMentionReferences(skills: Skill[], attachments: ReferenceImage[] = [], snapshots: CreationReference[] = []) { +export function buildCreationMentionReferences(skills: Skill[], attachments: CreationAttachment[] = [], snapshots: CreationReference[] = []) { const attachmentReferences = attachments.map(attachmentReference); const skillReferences = buildSkillMentionReferences(skills) as CreationReference[]; const current = [...attachmentReferences, ...skillReferences]; @@ -24,7 +24,7 @@ export function displayCreationPrompt(prompt: string, references: CreationRefere return references.reduce((value, reference) => value.split(canvasResourceMentionToken(reference)).join(`@${reference.label}`), prompt); } -export function expandCreationPrompt(prompt: string, references: CreationReference[], attachments: ReferenceImage[] = []) { +export function expandCreationPrompt(prompt: string, references: CreationReference[], attachments: CreationAttachment[] = []) { const visiblePrompt = displayCreationPrompt(prompt, references).trim(); if (!references.length) return visiblePrompt; @@ -38,7 +38,7 @@ export function expandCreationPrompt(prompt: string, references: CreationReferen } if (reference.attachmentId) { const position = attachmentPositions.get(reference.attachmentId); - mediaMappings.push(`- @${reference.label}:参考图片 ${position || 1}`); + mediaMappings.push(`- @${reference.label}:参考${reference.kind === "video" ? "视频" : "图片"} ${position || 1}`); return; } }); @@ -53,14 +53,15 @@ export function creationReferenceMetadata(references: CreationReference[]) { }; } -function attachmentReference(attachment: ReferenceImage, index: number): CreationReference { +function attachmentReference(attachment: CreationAttachment, index: number): CreationReference { + const isVideo = attachment.type.startsWith("video/"); return { id: `upload:${attachment.id}`, nodeId: `upload:${attachment.id}`, - kind: "image", - label: `图片${index + 1}`, + kind: isVideo ? "video" : "image", + label: `${isVideo ? "视频" : "图片"}${index + 1}`, title: "当前参考内容", - previewUrl: attachment.dataUrl || attachment.url, + previewUrl: attachment.previewUrl || ("dataUrl" in attachment ? attachment.dataUrl : attachment.url), storageKey: attachment.storageKey, active: true, attachmentId: attachment.id, diff --git a/web/src/pages/create/index.tsx b/web/src/pages/create/index.tsx index 8b461f67f..8cd1d893e 100644 --- a/web/src/pages/create/index.tsx +++ b/web/src/pages/create/index.tsx @@ -1,10 +1,11 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, 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, History, Image as ImageIcon, Maximize2, MessageSquareText, Music2, Plus, RefreshCw, SlidersHorizontal, Sparkles, Square, X } from "lucide-react"; +import { ArrowUp, Check, ChevronDown, Clock3, Download, FileText, Film, FolderOpen, History, Image as ImageIcon, Maximize2, MessageSquareText, Music2, Plus, RefreshCw, Search, SlidersHorizontal, Sparkles, Square, Upload, X } from "lucide-react"; import ReactMarkdown from "react-markdown"; import { Link } from "react-router"; +import { AssetMediaPreview } from "@/components/asset-media-preview"; import { CanvasResourceMentionTextarea } from "@/components/canvas/canvas-resource-mention-textarea"; import { ModelPicker } from "@/components/model-picker"; import { canvasResourceMentionToken } from "@/lib/canvas/canvas-resource-references"; @@ -17,11 +18,12 @@ import { requestImageQuestion } from "@/services/api/image"; import { listAddedSkills, type Skill } from "@/services/api/skills"; import { listGenerationTasks, queryGenerationTask, type GenerationTask } from "@/services/api/task-center"; import { storeGeneratedVideo } from "@/services/api/video"; +import { uploadMediaFile } from "@/services/file-storage"; import { resolveImageUrl, uploadImage, type UploadedImage } from "@/services/image-storage"; import { modelDisplayName, modelOptionName, selectableModelsByCapability, useConfigStore, useEffectiveConfig } from "@/stores/use-config-store"; -import { useAssetStore, type NewAsset } from "@/stores/use-asset-store"; +import { useAssetStore, type Asset, type NewAsset } from "@/stores/use-asset-store"; import { buildCreationMentionReferences, creationReferenceMetadata, displayCreationPrompt, expandCreationPrompt, selectedCreationReferences, type CreationReference } from "./creation-references"; -import { creationAssetKey, creationAttachmentFromImage, creationImageAsset, creationVideoAsset, isSameCreationAsset, type CreationAssetIdentity, type CreationAttachment } from "./creation-assets"; +import { creationAssetKey, creationAttachmentFromAsset, creationAttachmentFromImage, creationAttachmentFromVideo, creationAttachmentFromVideoAsset, creationImageAsset, creationVideoAsset, isSameCreationAsset, type CreationAssetIdentity, type CreationAttachment } from "./creation-assets"; type CreationMode = "text" | "image" | "video"; type CreationStatus = "streaming" | "pending" | "done" | "error" | "cancelled"; @@ -116,6 +118,7 @@ export default function CreatePage() { const [count, setCount] = useState(String(Math.max(1, Math.min(4, Number(config.count) || 1)))); const [busy, setBusy] = useState(false); const [historyOpen, setHistoryOpen] = useState(false); + const [libraryOpen, setLibraryOpen] = useState(false); const abortRef = useRef(null); const fileInputRef = useRef(null); const threadScrollRef = useRef(null); @@ -237,24 +240,30 @@ export default function CreatePage() { } }; + const maxReferences = mode === "video" ? videoProfile.operations.includes("image_to_video") ? videoProfile.references.maxImages : 0 : 6; const addAttachments = (files: FileList | File[]) => { - const maxImages = mode === "video" ? videoProfile.operations.includes("image_to_video") ? videoProfile.references.maxImages : 0 : 6; - if (mode === "video" && maxImages === 0) { + if (mode === "video" && maxReferences === 0) { toast.warning("当前模型不支持图生视频"); return; } - const next = Array.from(files).filter((file) => file.type.startsWith("image/")).slice(0, Math.max(0, maxImages - attachments.length)); + const next = Array.from(files) + .filter((file) => file.type.startsWith("image/") || (mode === "video" && file.type.startsWith("video/"))) + .slice(0, Math.max(0, maxReferences - attachments.length)); if (!next.length) return; void Promise.allSettled(next.map(async (file) => { + if (file.type.startsWith("video/")) { + const uploaded = await uploadMediaFile(file, "create-upload"); + addAsset(creationVideoAsset({ title: file.name, uploaded, metadata: { source: "create-upload", fileName: file.name } })); + return creationAttachmentFromVideo(file, uploaded); + } const uploaded = await uploadImage(file); - const attachment = creationAttachmentFromImage(file, uploaded); addAsset(creationImageAsset({ title: file.name, uploaded, metadata: { source: "create-upload", fileName: file.name } })); - return attachment; + return creationAttachmentFromImage(file, uploaded); })).then((settled) => { const items = settled.flatMap((entry) => entry.status === "fulfilled" ? [entry.value] : []); const failed = settled.filter((entry) => entry.status === "rejected"); - if (items.length) setAttachments((current) => [...current, ...items].slice(0, mode === "video" ? videoProfile.references.maxImages : 6)); - if (failed.length) toast.error(`${failed.length} 张参考图片上传失败,请重试`); + if (items.length) setAttachments((current) => [...current, ...items].slice(0, maxReferences)); + if (failed.length) toast.error(`${failed.length} 个参考素材上传失败,请重试`); }); }; @@ -263,6 +272,17 @@ export default function CreatePage() { event.target.value = ""; }; + const handleLibrarySelect = (selected: Asset[]) => { + const next = selected.flatMap((asset): CreationAttachment[] => { + if (asset.kind === "image") return [creationAttachmentFromAsset(asset)]; + if (asset.kind === "video" && mode === "video") return [creationAttachmentFromVideoAsset(asset)]; + return []; + }); + if (!next.length) return; + setAttachments((current) => [...current.filter((item) => !next.some((candidate) => candidate.id === item.id)), ...next].slice(0, maxReferences)); + setLibraryOpen(false); + }; + const removeAttachment = (id: string) => { const reference = mentionReferences.find((item) => item.attachmentId === id); setAttachments((current) => current.filter((item) => item.id !== id)); @@ -286,6 +306,9 @@ export default function CreatePage() { } const settings = { ratio, seconds, quality, videoQuality, count }; const references = selectedCreationReferences(text, mentionReferences); + // 后端对图片和视频使用不同的参考字段;这里先拆分,避免媒体类型在写入任务时被误判。 + const referenceImages = attachments.filter(isImageAttachment); + const referenceVideos = attachments.filter(isVideoAttachment); const expandedPrompt = expandCreationPrompt(text, references, attachments); if (mode === "video" && Array.from(expandedPrompt).length > videoProfile.references.promptMaxChars) { toast.error(`引用展开后提示词超过当前模型限制(最多 ${videoProfile.references.promptMaxChars} 字)`); @@ -331,7 +354,7 @@ export default function CreatePage() { mode: "image", prompt: expandedPrompt, config: { ...requestConfig, count: "1" }, - referenceImages: attachments, + referenceImages, signal: controller.signal, metadata: { source: "create-page", conversationId: activeConversation.id, messageId: assistantMessage.id, ...referenceMetadata }, onTaskUpdate: bindTask, @@ -367,7 +390,8 @@ export default function CreatePage() { mode: "video", prompt: expandedPrompt, config: requestConfig, - referenceImages: attachments, + referenceImages, + referenceVideos, signal: controller.signal, metadata: { source: "create-page", conversationId: activeConversation.id, messageId: assistantMessage.id, videoEditOperation: attachments.length ? "image_to_video" : "text_to_video", ...referenceMetadata }, onTaskUpdate: bindTask, @@ -469,6 +493,7 @@ export default function CreatePage() { attachments, references: mentionReferences, onRemoveAttachment: removeAttachment, + onOpenLibrary: () => setLibraryOpen(true), fileInputRef, onFileChange: handleFileChange, onModeChange: selectMode, @@ -498,6 +523,7 @@ export default function CreatePage() {
{isEmpty ?
+
: <> @@ -509,25 +535,103 @@ export default function CreatePage() {
setHistoryOpen(false)} onSelect={selectConversation} /> + item.id.startsWith("asset:")).map((item) => item.id.slice(6)))} onClose={() => setLibraryOpen(false)} onConfirm={handleLibrarySelect} onUpload={() => fileInputRef.current?.click()} /> ; } +const creationAssetCategoryLabels: Record = { all: "全部素材", character: "角色", environment: "场景", wardrobe: "服饰", prop: "道具", weapon: "武器", style: "画风", other: "其他" }; + +function CreationAssetLibraryModal({ open, assets, mode, selectedIds, onClose, onConfirm, onUpload }: { open: boolean; assets: Asset[]; mode: CreationMode; selectedIds: Set; onClose: () => void; onConfirm: (assets: Asset[]) => void; onUpload: () => void }) { + const [category, setCategory] = useState("all"); + const [keyword, setKeyword] = useState(""); + const [selected, setSelected] = useState>(new Set()); + const mediaAssets = useMemo(() => assets.filter((asset): asset is Extract => asset.kind === "image" || asset.kind === "video"), [assets]); + const categories = useMemo(() => ["all", ...Array.from(new Set(mediaAssets.map((asset) => asset.category || "other")))], [mediaAssets]); + const visibleAssets = useMemo(() => { + const query = keyword.trim().toLowerCase(); + return mediaAssets.filter((asset) => (category === "all" || (asset.category || "other") === category) && (!query || [asset.title, ...(asset.tags || [])].join(" ").toLowerCase().includes(query))); + }, [category, keyword, mediaAssets]); + + useEffect(() => { + if (!open) return; + setCategory("all"); + setKeyword(""); + setSelected(new Set(selectedIds)); + }, [open]); + + const toggle = (asset: Asset) => { + if (mode !== "video" && asset.kind === "video") return; + setSelected((current) => { + const next = new Set(current); + if (next.has(asset.id)) next.delete(asset.id); else next.add(asset.id); + return next; + }); + }; + const selectedAssets = mediaAssets.filter((asset) => selected.has(asset.id) && (mode === "video" || asset.kind === "image")); + const count = category === "all" ? mediaAssets.length : mediaAssets.filter((asset) => (asset.category || "other") === category).length; + + return +
+
参考内容素材库
setKeyword(event.target.value)} placeholder="搜索素材名称或标签" aria-label="搜索素材" />
已选 {selectedAssets.length} · {count} 个素材
+
+ +
{visibleAssets.length ? visibleAssets.map((asset) => toggle(asset)} />) :
这个分类还没有素材换个分类,或从底部上传一份新素材。
}
+
+
+
+
; +} + +function CreationLibraryCard({ asset, selected, disabled, onToggle }: { asset: Extract; selected: boolean; disabled: boolean; onToggle: () => void }) { + const isVideo = asset.kind === "video"; + return
+
{asset.title || "未命名素材"}
+ ; +} + function CreationHistoryDrawer({ open, conversations, activeId, onClose, onSelect }: { open: boolean; conversations: CreationConversation[]; activeId: string; onClose: () => void; onSelect: (conversation: CreationConversation) => void }) { + const [keyword, setKeyword] = useState(""); + + useEffect(() => { + if (open) setKeyword(""); + }, [open]); + + const visibleConversations = useMemo(() => { + const query = keyword.trim().toLowerCase(); + if (!query) return conversations; + return conversations.filter((conversation) => { + const latest = conversationPreviewMessage(conversation); + const searchable = [ + conversation.title, + ...conversation.messages.flatMap((message) => [message.content, displayCreationPrompt(message.content, message.references || [])]), + latest?.mode ? modeLabels[latest.mode] : "创作", + formatConversationTime(conversation.updatedAt), + ].filter(Boolean).join(" ").toLowerCase(); + return searchable.includes(query); + }); + }, [conversations, keyword]); + return } className="creation-history-drawer" rootClassName="creation-history-drawer-root" styles={{ body: { padding: 0 } }} title={
历史对话{conversations.length} 个对话
}> -
    - {conversations.map((conversation) => { - const latest = conversationPreviewMessage(conversation); - const active = conversation.id === activeId; - return
  1. -
  2. ; - })} -
+
+ + {visibleConversations.length ?
    + {visibleConversations.map((conversation) => { + const latest = conversationPreviewMessage(conversation); + const active = conversation.id === activeId; + return
  • + +
  • ; + })} +
:
{keyword.trim() ? "没有找到匹配的对话" : "暂无历史对话"}
} +
; } @@ -540,10 +644,11 @@ function CreationMessageView({ item, modelName, onRetryFailure, onCreateVariant function CreationUserMessage({ item }: { item: CreationMessage }) { const [previewUrl, setPreviewUrl] = useState(""); + const [previewType, setPreviewType] = useState<"image" | "video">("image"); return
{displayCreationPrompt(item.content, item.references || [])}
{item.references?.length ? : null}{item.attachments?.length ?
{item.attachments.map((attachment) => { - const url = attachment.previewUrl || attachment.dataUrl || attachment.url || ""; - return ; - })}
: null} setPreviewUrl("")} />
; + const url = attachment.previewUrl || ("dataUrl" in attachment ? attachment.dataUrl : attachment.url) || ""; + return ; + })}
: null} setPreviewUrl("")} />
; } function CreationMessageReferences({ references }: { references: CreationReference[] }) { @@ -577,6 +682,7 @@ type ComposerProps = { attachments: CreationAttachment[]; references: CreationReference[]; onRemoveAttachment: (id: string) => void; + onOpenLibrary: () => void; fileInputRef: RefObject; onFileChange: (event: ChangeEvent) => void; onModeChange: (mode: CreationMode) => void; @@ -608,11 +714,11 @@ function CreationComposer(props: ComposerProps) { const emptyPlaceholder = "输入你的镜头、画面或故事。也可以添加参考图开始创作"; return
- - + +
- {props.attachments.length ?
{props.attachments.map((item) =>
{item.name}
)}
: null} + {props.attachments.length ?
{props.attachments.map((item) =>
{isVideoAttachment(item) ?
)}
: null}
@@ -674,9 +780,42 @@ function DurationMenu({ profile, seconds, onChange }: { profile: VideoCapability ; } +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}`); + +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
+
+
你的下一帧,从这里开始
+
+
{String(activeIndex + 1).padStart(2, "0")}镜头 · 氛围 · 故事
+
; +} + +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; +} + function CreationIntro({ mode }: { mode: CreationMode }) { const copy = mode === "video" ? ["让", "想象", ",先在镜头里发生", "影策 · AI 叙事创作"] : mode === "image" ? ["让", "画面", ",从一个想法开始", "影策 · 视觉创作"] : ["把", "故事", ",写在第一句话里", "影策 · 叙事创作"]; - return
; + return
; } function videoResolutionLabel(value: string | number) { @@ -696,11 +835,19 @@ function conversationPreviewMessage(conversation: CreationConversation) { function buildTextMessageContent(item: CreationMessage) { const content = expandCreationPrompt(item.content, item.references || [], item.attachments || []); - const images = item.attachments || []; + const images = (item.attachments || []).filter(isImageAttachment); if (!images.length) return content; return [{ type: "text" as const, text: content }, ...images.map((image) => ({ type: "image_url" as const, image_url: { url: image.dataUrl || image.url || "" } }))]; } +function isVideoAttachment(attachment: CreationAttachment): attachment is CreationAttachment & { url: string } { + return attachment.type.startsWith("video/"); +} + +function isImageAttachment(attachment: CreationAttachment): attachment is CreationAttachment & { dataUrl: string } { + return !isVideoAttachment(attachment); +} + function removeReferenceTokens(value: string, references: CreationReference[]) { return references.reduce((current, reference) => current.split(canvasResourceMentionToken(reference)).join(""), value); } diff --git a/web/src/pages/projects/index.tsx b/web/src/pages/projects/index.tsx index 1c70ae459..2894ee09b 100644 --- a/web/src/pages/projects/index.tsx +++ b/web/src/pages/projects/index.tsx @@ -4,7 +4,7 @@ import { App, Button, Form, Input, Modal, Select } from "antd"; import { ArrowRight, BookOpenText, FolderKanban, Images, LayoutGrid, Plus, Search } from "lucide-react"; import { Link, useNavigate, useSearchParams } from "react-router"; -import { ListToolbar, PageHeader, TableSurface, WorkspacePage } from "@/components/layout/workspace-page"; +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 { projectSummaryCompletion, projectSummaryStage } from "@/lib/project-workbench"; @@ -50,17 +50,18 @@ export default function ProjectsPage() { return right.project.updatedAt.localeCompare(left.project.updatedAt); }); }, [keyword, query.data, sort, status]); + const canCreateProject = !keyword.trim() && status === "all"; return ( - + {rows.length} 个} - actions={} + actions={null} /> - { setKeyword(""); setStatus("all"); setSort("updated"); }}> + { setKeyword(""); setStatus("all"); setSort("updated"); }}> } value={keyword} placeholder="搜索项目、简介或画风" onChange={(event) => setKeyword(event.target.value)} /> @@ -68,17 +69,17 @@ export default function ProjectsPage() { {query.isError ? void query.refetch()} /> : null} {query.isLoading ? : null} - {!query.isLoading && rows.length ? ( - -
- 项目当前阶段章节进度内容最近更新 -
-
- {rows.map((row) => )} -
-
+ {!query.isLoading && !query.isError && (rows.length || canCreateProject) ? ( + + {canCreateProject ? : null} + {rows.map((row) => )} + ) : null} - {!query.isLoading && !rows.length && !query.isError ? ( + {!query.isLoading && !rows.length && !query.isError && (keyword || status !== "all") ? ( ) : null} - setCreateOpen(false)} width={500} styles={{ body: { paddingTop: 12 } }}> + setCreateOpen(false)} width={560} styles={{ body: { paddingTop: 12 } }}> layout="vertical" initialValues={{ aspectRatio: "9:16", sourceType: "blank" }} onFinish={(values) => mutation.mutate({ ...values, type: "short-drama" })}>
@@ -107,29 +108,14 @@ function ProjectRow({ row }: { row: ProjectSummary }) { const stage = projectSummaryStage(row); const styleTitle = resolveCanvasStylePreset(row.project.stylePresetId)?.title || (row.project.stylePresetId ? "自定义画风" : "未设置画风"); return ( - - - - {row.project.name}{row.project.status === "archived" ? 已归档 : null}{styleTitle} · {row.project.aspectRatio} · {sourceTypeLabel(row.project.sourceType)} + + {row.project.aspectRatio}{stage.label} + + {row.project.name}{row.project.status === "archived" ? 已归档 : null} + {styleTitle} · {sourceTypeLabel(row.project.sourceType)} + {row.completedUnitCount}/{row.unitCount} 章{completion}% + } label="章节" value={row.unitCount} />} label="画布" value={row.canvasCount} />} label="资产" value={row.assetCount} /> - - - 当前阶段 - {stage.label}{stage.detail} - - - - 章节进度 - {row.completedUnitCount}/{row.unitCount} 章{completion}% - - - - } label="章节" value={row.unitCount} /> - } label="画布" value={row.canvasCount} /> - } label="资产" value={row.assetCount} /> - - {formatProjectTime(row.project.updatedAt)} - ); } @@ -137,7 +123,3 @@ function ProjectRow({ row }: { row: ProjectSummary }) { function ProjectCount({ icon, label, value }: { icon: ReactNode; label: string; value: number }) { return {icon}{value}{label}; } - -function formatProjectTime(value: string) { - return new Intl.DateTimeFormat("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" }).format(new Date(value)); -} diff --git a/web/src/pages/skills/index.tsx b/web/src/pages/skills/index.tsx index 27cc8b79e..4bbe41e1f 100644 --- a/web/src/pages/skills/index.tsx +++ b/web/src/pages/skills/index.tsx @@ -162,8 +162,8 @@ export default function SkillsPage() { return ( <> - - {total} 个技能} actions={} /> + + {total} 个技能} actions={null} />
-
+
} value={search} allowClear placeholder="搜索技能或作者" onChange={(event) => { setSearch(event.target.value); setPage(1); }} /> { setSort(value); setPage(1); }} /> @@ -206,7 +206,8 @@ export default function SkillsPage() {

{group.label}

{group.skills.length} 个
-
+
+ {groupedSkills[0] === group ? : null} {group.skills.map((skill) => void openSkill(skill)} onAdd={() => void toggleAdded(skill)} onLike={() => void toggleLiked(skill)} onEdit={() => void openEditor(skill)} onDelete={() => confirmDelete(skill)} />)}
@@ -238,7 +239,7 @@ export default function SkillsPage() { function SkillCard({ skill, loading, onOpen, onAdd, onLike, onEdit, onDelete }: { skill: Skill; loading: boolean; onOpen: () => void; onAdd: () => void; onLike: () => void; onEdit: () => void; onDelete: () => void }) { return ( -
+
: undefined}> + } onClick={() => onEdit(skill)}>编辑 : undefined}> {skill ? (
diff --git a/web/src/pages/skills/skill-editor-drawer.tsx b/web/src/pages/skills/skill-editor-drawer.tsx index 4ac7e1059..90093dc17 100644 --- a/web/src/pages/skills/skill-editor-drawer.tsx +++ b/web/src/pages/skills/skill-editor-drawer.tsx @@ -61,7 +61,7 @@ export function SkillEditorDrawer({ open, skill, onClose, onSaved }: { open: boo }; return ( - } onClick={() => form.submit()}>保存技能}> + } onClick={() => form.submit()}>保存技能}> setDirty(true)}>
diff --git a/web/src/pages/tasks/index.tsx b/web/src/pages/tasks/index.tsx index 6122a1228..35249a3fa 100644 --- a/web/src/pages/tasks/index.tsx +++ b/web/src/pages/tasks/index.tsx @@ -1,10 +1,9 @@ -import { App, Button, Drawer, Form, Input, Modal, Progress, Segmented, Select, Space, Table, Typography } from "antd"; -import type { ColumnsType } from "antd/es/table"; -import { 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, Tooltip, Typography } from "antd"; +import { Coins, Eye, FileText, FolderKanban, Image as ImageIcon, Play, Plus, RefreshCw, RotateCcw, Search, Video, X } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useSearchParams } from "react-router"; -import { ListToolbar, PageHeader, TableSurface, WorkspacePage } from "@/components/layout/workspace-page"; +import { ListToolbar, PageHeader, PaginationBar, WorkspacePage } from "@/components/layout/workspace-page"; import { WorkspaceState } from "@/components/layout/workspace-state"; import { CONTENT_MODERATION_ERROR_CODE, generationErrorMessage, isContentModerationError } from "@/lib/generation-error"; import { formatTaskKind, operationOptions, statusLabel } from "@/lib/generation-task-display"; @@ -18,8 +17,8 @@ import { useUserStore } from "@/stores/use-user-store"; import { formatCredits } from "@/constant/credits"; import { listProjects, type ProjectSummary } from "@/services/api/projects"; -const taskTableClassName = "app-data-table"; type TaskStatusFilter = "all" | "failed" | "active" | "succeeded"; +type TaskKindFilter = "all" | "text" | "image" | "video"; function taskStatusFilter(value: string | null): TaskStatusFilter { return value === "failed" || value === "active" || value === "succeeded" ? value : "all"; @@ -48,6 +47,8 @@ export default function TasksPage() { }; const [keyword, setKeyword] = useState(""); const [projectFilter, setProjectFilter] = useState("all"); + const [kindFilter, setKindFilter] = useState("all"); + const [modelFilter, setModelFilter] = useState("all"); const [page, setPage] = useState(1); const [pageSize, setPageSize] = useState(20); const [detailTask, setDetailTask] = useState(null); @@ -64,6 +65,7 @@ export default function TasksPage() { const projectName = project.projectId ? domainProjectNameById.get(project.projectId) : ""; return { label: projectName ? `${project.title || "未命名画布"} · ${projectName}` : project.title || "未命名画布", value: project.id }; }), [domainProjectNameById, projects]); + const modelOptions = useMemo(() => Array.from(new Set(tasks.map((task) => formatModelName(effectiveConfig, task)).filter(Boolean))).sort((left, right) => left.localeCompare(right, "zh-CN")), [effectiveConfig, tasks]); const filteredTasks = useMemo(() => tasks.filter((task) => { if (statusFilter === "all") return true; if (statusFilter === "active") return task.status === "queued" || task.status === "running"; @@ -72,10 +74,13 @@ export default function TasksPage() { return false; }).filter((task) => { if (projectFilter !== "all" && task.projectId !== projectFilter) return false; + if (kindFilter !== "all" && taskMediaKind(task) !== kindFilter) return false; + if (modelFilter !== "all" && formatModelName(effectiveConfig, task) !== modelFilter) return false; const query = keyword.trim().toLowerCase(); const context = getTaskCanvasContext(task, canvasById, domainProjectNameById); return !query || `${task.prompt} ${task.model || ""} ${formatTaskKind(task)} ${context.canvasName} ${context.projectName}`.toLowerCase().includes(query); - }), [canvasById, domainProjectNameById, keyword, projectFilter, statusFilter, tasks]); + }), [canvasById, domainProjectNameById, effectiveConfig, keyword, kindFilter, modelFilter, projectFilter, statusFilter, tasks]); + const visibleTasks = useMemo(() => filteredTasks.slice((page - 1) * pageSize, page * pageSize), [filteredTasks, page, pageSize]); useEffect(() => { if (!shortDramaEnabled) { @@ -276,162 +281,45 @@ export default function TasksPage() { } }; - const columns = useMemo>( - () => [ - { - title: "画布(项目)", - width: 190, - responsive: ["lg"], - render: (_, task) => { - const context = getTaskCanvasContext(task, canvasById, domainProjectNameById); - return ( -
-
{context.canvasName}
-
- - {context.projectName || "未加入项目"} -
-
- ); - }, - }, - { - title: "类型|模型", - width: 180, - responsive: ["md"], - render: (_, task) =>
{formatTaskKind(task)}
{formatModelName(effectiveConfig, task)}
, - }, - { - title: "任务名称", - dataIndex: "prompt", - width: 340, - render: (prompt, task) => { - const context = getTaskCanvasContext(task, canvasById, domainProjectNameById); - return
- task.previewUrl && setMediaPreview({ url: task.previewUrl, kind: task.previewKind === "video" ? "video" : "image", title: prompt || formatTaskKind(task) })} /> -
-
{prompt || "未命名任务"}
- -
-
{formatTaskKind(task)} · {formatModelName(effectiveConfig, task)}
-
{context.canvasName}{context.projectName ? ` · ${context.projectName}` : ""}
-
-
{statusLabel[task.status]}{creditsEnabled ? : null}
- -
-
-
-
; - }, - }, - { - title: "状态", - width: 135, - responsive: ["md"], - render: (_, task) => ( -
-
- - {statusLabel[task.status]} - {task.status === "queued" || task.status === "running" ? {task.progress || 0}% : null} -
- {task.status === "queued" || task.status === "running" ? : null} -
- ), - }, - { - title: "重试", - dataIndex: "attempts", - width: 75, - align: "center", - responsive: ["lg"], - render: (attempts: number) => {Math.max(0, (attempts || 1) - 1)} 次, - }, - { - title: "创建时间", - dataIndex: "createdAt", - width: 145, - responsive: ["lg"], - render: (value) => , - }, - ...(creditsEnabled ? [{ - title: "消耗积分", - width: 130, - align: "right", - responsive: ["md"], - render: (_, task) => , - }] as ColumnsType : []), - { - title: "操作", - width: 168, - align: "right", - responsive: ["md"], - render: (_, task) => ( - - - {task.status === "failed" || task.status === "cancelled" ? : null} - {task.status === "queued" || task.status === "running" ? : null} - - ), - }, - ], - [actingId, canvasById, creditsEnabled, domainProjectNameById, effectiveConfig, openTaskDetail], - ); - return ( <> - + {filteredTasks.length} 个任务{loading ? " · 正在同步" : ""}} - actions={( - <> + actions={( + <> - - - )} + + + )} /> - { setKeyword(""); setProjectFilter("all"); setPage(1); }}> - } value={keyword} placeholder="搜索任务、模型、画布或项目" onChange={(event) => { setKeyword(event.target.value); setPage(1); }} /> + { setKeyword(""); setProjectFilter("all"); setKindFilter("all"); setModelFilter("all"); setStatusFilter("all"); setPage(1); }}> + } value={keyword} placeholder="搜索任务、模型或画布" onChange={(event) => { setKeyword(event.target.value); setPage(1); }} /> { setKindFilter(value as TaskKindFilter); setPage(1); }} options={[{ label: "全部类型", value: "all" }, { label: "文本", value: "text" }, { label: "图片", value: "image" }, { label: "视频", value: "video" }]} /> + @@ -447,7 +335,7 @@ export default function TasksPage() {
- setDetailTask(null)} size="large" destroyOnHidden> + setDetailTask(null)} size="large" destroyOnHidden> {detailTask ? (
@@ -482,7 +370,7 @@ export default function TasksPage() { centered width="min(1040px, calc(100vw - 32px))" destroyOnHidden - className="task-media-preview-modal" + className="library-modal task-media-preview-modal" > {mediaPreview?.kind === "video" ?