feat(*): 分镜与资产 - 完善五类资产、候选去重、搜索、中文镜头编号与分类整理
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import { useMutation, useMutationState, useQuery } from "@tanstack/react-query";
|
||||
import { App, Button, Dropdown, Form, Input, Modal, Popconfirm, Tabs, type FormInstance } from "antd";
|
||||
import { Box, Check, ChevronDown, Download, FileText, FolderOpen, FolderPlus, Image as ImageIcon, Link2, MoreHorizontal, MoveRight, Music2, Pencil, Plus, RefreshCw, Sparkles, Trash2, Upload, UserRound, Video, VolumeX } from "lucide-react";
|
||||
import { Box, Check, ChevronDown, Download, FileText, FolderOpen, FolderPlus, Image as ImageIcon, Link2, MoreHorizontal, MoveRight, Music2, Pencil, Plus, RefreshCw, Search, Sparkles, Trash2, Upload, UserRound, Video, VolumeX } from "lucide-react";
|
||||
|
||||
import { WorkspaceState } from "@/components/layout/workspace-state";
|
||||
import { PaginationBar } from "@/components/layout/workspace-page";
|
||||
@@ -10,10 +10,12 @@ import { CachedResourceImage } from "@/components/cached-resource-image";
|
||||
import { AssetLibraryCard, AssetLibraryCardMedia } from "@/components/assets/asset-library-card";
|
||||
import { AssetLibraryPickerModal, type AssetLibraryPickerItem } from "@/components/assets/asset-library-picker-modal";
|
||||
import { useExternalAssetSources } from "@/hooks/use-external-asset-sources";
|
||||
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
||||
import { CanvasFolderPreview } from "@/components/canvas/canvas-folder-preview";
|
||||
import { CANVAS_FOLDER_THEME_OPTIONS, resolveCanvasFolderTheme } from "@/lib/canvas/canvas-folder-theme";
|
||||
import { resolveProjectCanvasStyle } from "@/components/canvas/canvas-style-picker-modal";
|
||||
import { CHARACTER_VOICE_FORMAT_LABEL, CHARACTER_VOICE_UPLOAD_ACCEPT, characterVoiceFormatName, characterVoiceTitleFromFileName, isSupportedCharacterVoiceFile } from "@/lib/character-voice-formats";
|
||||
import { ASSET_CATEGORIES, defaultAssetCategoryForKind, normalizeAssetCategory } from "@/lib/asset-category";
|
||||
import { resourceFileUrl, resourceIdFromStorageKey } from "@/services/api/resources";
|
||||
import { uploadMediaFile } from "@/services/file-storage";
|
||||
import {
|
||||
@@ -49,7 +51,7 @@ import { linkSelectedProjectAssets } from "./project-asset-linking";
|
||||
import { generateCharacterTurnaround } from "./project-character-media";
|
||||
import { categoryLabels, categoryLabel, mediaLabel, StatusPill, formatTime, textValue, type ProjectDetailViewProps } from "./shared";
|
||||
|
||||
const categories = ["all", "character", "environment", "wardrobe", "prop", "weapon", "style", "other"];
|
||||
const categories = ["all", ...ASSET_CATEGORIES];
|
||||
const ALL_FOLDERS = "__all_folders__";
|
||||
const pickerCategoryLabels = { all: "全部素材", ...categoryLabels };
|
||||
const characterFields = [
|
||||
@@ -73,6 +75,8 @@ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDet
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(40);
|
||||
const [candidatePage, setCandidatePage] = useState(1);
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const debouncedKeyword = useDebouncedValue(keyword.trim(), 250);
|
||||
const candidatePageSize = 24;
|
||||
const [folderEditor, setFolderEditor] = useState<{ folder?: ProjectAssetFolder; parentId: string } | null>(null);
|
||||
const [folderName, setFolderName] = useState("");
|
||||
@@ -88,19 +92,20 @@ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDet
|
||||
const [form] = Form.useForm<CharacterForm>();
|
||||
|
||||
const assetsQuery = useQuery({
|
||||
queryKey: ["project", detail.project.id, "assets", page, pageSize, category, folderId],
|
||||
queryKey: ["project", detail.project.id, "assets", page, pageSize, category, folderId, debouncedKeyword],
|
||||
queryFn: () => listProjectAssetsPage(detail.project.id, {
|
||||
page,
|
||||
pageSize,
|
||||
category: category === "all" ? undefined : category,
|
||||
folderId: folderId === ALL_FOLDERS ? undefined : folderId,
|
||||
query: debouncedKeyword || undefined,
|
||||
}),
|
||||
});
|
||||
const foldersQuery = useQuery({ queryKey: ["project", detail.project.id, "asset-folders"], queryFn: () => listProjectAssetFolders(detail.project.id) });
|
||||
const showPendingCandidates = folderId === ALL_FOLDERS && (category === "all" || category === "character");
|
||||
const candidatesQuery = useQuery({
|
||||
queryKey: ["project", detail.project.id, "asset-candidates", candidatePage, candidatePageSize, "character", "pending_confirmation"],
|
||||
queryFn: () => listProjectAssetCandidates(detail.project.id, { page: candidatePage, pageSize: candidatePageSize, category: "character", status: "pending_confirmation" }),
|
||||
queryKey: ["project", detail.project.id, "asset-candidates", candidatePage, candidatePageSize, "character", "pending_confirmation", debouncedKeyword],
|
||||
queryFn: () => listProjectAssetCandidates(detail.project.id, { page: candidatePage, pageSize: candidatePageSize, category: "character", status: "pending_confirmation", query: debouncedKeyword || undefined }),
|
||||
});
|
||||
const assets = assetsQuery.data?.assets || [];
|
||||
const assetFolders = foldersQuery.data?.folders || [];
|
||||
@@ -121,6 +126,10 @@ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDet
|
||||
const lastPage = Math.max(1, Math.ceil(candidatesQuery.data.total / candidatePageSize));
|
||||
if (candidatePage > lastPage) setCandidatePage(lastPage);
|
||||
}, [candidatePage, candidatesQuery.data]);
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
setCandidatePage(1);
|
||||
}, [debouncedKeyword]);
|
||||
|
||||
const selectFolder = (nextFolderId: string) => {
|
||||
setFolderId(nextFolderId);
|
||||
@@ -140,7 +149,7 @@ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDet
|
||||
...availableAssets.map((asset) => ({
|
||||
id: asset.id,
|
||||
title: asset.title,
|
||||
category: asset.kind === "entity" ? "character" : asset.category || "other",
|
||||
category: normalizeAssetCategory(asset.category, defaultAssetCategoryForKind(asset.kind)),
|
||||
kindLabel: mediaLabel(asset.kind),
|
||||
asset,
|
||||
description: asset.note,
|
||||
@@ -152,7 +161,7 @@ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDet
|
||||
...imageAssets.map((asset) => ({
|
||||
id: asset.id,
|
||||
title: asset.title,
|
||||
category: asset.category || "other",
|
||||
category: normalizeAssetCategory(asset.category, defaultAssetCategoryForKind(asset.kind)),
|
||||
kindLabel: "图片",
|
||||
asset,
|
||||
searchText: (asset.tags || []).join(" "),
|
||||
@@ -210,7 +219,7 @@ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDet
|
||||
const assetId = addAsset(imported);
|
||||
return linkProjectAsset(detail.project.id, {
|
||||
assetId,
|
||||
category: imported.kind === "entity" ? "character" : imported.category || "other",
|
||||
category: normalizeAssetCategory(imported.category, defaultAssetCategoryForKind(imported.kind)),
|
||||
folderId: nextFolderId,
|
||||
});
|
||||
}
|
||||
@@ -218,7 +227,7 @@ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDet
|
||||
if (!selected) throw new Error("所选素材已不存在,请重新选择");
|
||||
return linkProjectAsset(detail.project.id, {
|
||||
assetId: selected.id,
|
||||
category: selected.kind === "entity" ? "character" : selected.category || "other",
|
||||
category: normalizeAssetCategory(selected.category, defaultAssetCategoryForKind(selected.kind)),
|
||||
folderId: nextFolderId,
|
||||
});
|
||||
});
|
||||
@@ -238,7 +247,7 @@ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDet
|
||||
});
|
||||
const versionMutation = useMutation({ mutationFn: (id: string) => createProjectAssetVersion(detail.project.id, id, {}), onSuccess: () => done("已创建新版本"), onError: failed("版本创建失败") });
|
||||
const unlinkMutation = useMutation({ mutationFn: (id: string) => unlinkProjectAsset(detail.project.id, id), onSuccess: () => done("资产已移出项目"), onError: failed("资产移除失败") });
|
||||
const categoryMutation = useMutation({ mutationFn: ({ id, next }: { id: string; next: string }) => updateProjectAssetCategory(detail.project.id, id, next), onSuccess: ({ asset }) => { updatePersonalAsset(asset.id, { category: asset.category as AssetCategory }); done("资产分类已更新"); }, onError: failed("资产分类更新失败") });
|
||||
const categoryMutation = useMutation({ mutationFn: ({ id, next }: { id: string; next: AssetCategory }) => updateProjectAssetCategory(detail.project.id, id, next), onSuccess: ({ asset }) => { updatePersonalAsset(asset.id, { category: asset.category }); done("资产分类已更新"); }, onError: failed("资产分类更新失败") });
|
||||
const moveMutation = useMutation({ mutationFn: ({ id, nextFolderId }: { id: string; nextFolderId: string }) => moveProjectAsset(detail.project.id, id, nextFolderId), onSuccess: () => done("资产已移动"), onError: failed("资产移动失败") });
|
||||
const createFolderMutation = useMutation({
|
||||
mutationFn: ({ name, parentId }: { name: string; parentId: string }) => createProjectAssetFolder(detail.project.id, { name, parentId: parentId || undefined }),
|
||||
@@ -392,7 +401,10 @@ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDet
|
||||
<button type="button" className="truncate rounded px-1.5 py-1 hover:bg-surface-hover" onClick={() => selectFolder("")}>素材库</button>
|
||||
{folderId === ALL_FOLDERS ? <><span>/</span><span className="font-medium text-foreground">全部资产</span></> : folderPath.map((folder) => <span key={folder.id} className="contents"><span>/</span><button type="button" className="truncate rounded px-1.5 py-1 font-medium text-foreground hover:bg-surface-hover" onClick={() => selectFolder(folder.id)}>{folder.name}</button></span>)}
|
||||
</div>
|
||||
{folderId !== ALL_FOLDERS ? <Button type="text" size="small" icon={<FolderPlus className="size-3.5" />} onClick={() => openFolderEditor(undefined, folderId)}>新建子文件夹</Button> : null}
|
||||
<div className="flex items-center gap-2">
|
||||
<Input allowClear value={keyword} onChange={(event) => setKeyword(event.target.value)} prefix={<Search className="size-3.5 text-foreground/35" />} placeholder="搜索资产名称" aria-label="搜索项目资产" className="w-52" />
|
||||
{folderId !== ALL_FOLDERS ? <Button type="text" size="small" icon={<FolderPlus className="size-3.5" />} onClick={() => openFolderEditor(undefined, folderId)}>新建子文件夹</Button> : null}
|
||||
</div>
|
||||
</div>
|
||||
{childFolders.length ? <div className="project-asset-folder-grid mb-5">{childFolders.map((folder) => <ProjectAssetFolderCard key={folder.id} folder={folder} folders={assetFolders} assets={assets} folderCounts={folderCountMap} personalAssets={personalAssets} onOpen={() => selectFolder(folder.id)} onRename={() => openFolderEditor(folder)} onMove={(parentId) => moveFolderMutation.mutate({ id: folder.id, parentId })} onStyle={(style) => styleFolderMutation.mutate({ id: folder.id, style })} onTheme={(theme) => themeFolderMutation.mutate({ id: folder.id, theme })} onDelete={() => modal.confirm({ title: `删除文件夹“${folder.name}”?`, content: "仅空文件夹可以删除,素材和子文件夹不会被级联删除。", okText: "删除", okButtonProps: { danger: true }, cancelText: "取消", onOk: () => deleteFolderMutation.mutateAsync(folder.id) })} deleting={(deleteFolderMutation.isPending && deleteFolderMutation.variables === folder.id) || (moveFolderMutation.isPending && moveFolderMutation.variables?.id === folder.id) || (styleFolderMutation.isPending && styleFolderMutation.variables?.id === folder.id) || (themeFolderMutation.isPending && themeFolderMutation.variables?.id === folder.id)} />)}</div> : null}
|
||||
{folderId === ALL_FOLDERS && (category === "all" || category === "character") && pendingCandidates.length ? (
|
||||
@@ -423,7 +435,7 @@ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDet
|
||||
</section>
|
||||
) : null}
|
||||
<div className="mb-2 flex items-center justify-between text-xs text-foreground/45"><span>{currentFolder?.name || (folderId === ALL_FOLDERS ? (category === "all" ? "全部资产" : categoryLabel(category)) : "根目录内容")}</span><span>{assetsQuery.data?.total || 0} 项已确认</span></div>
|
||||
{assetsQuery.isLoading ? <WorkspaceState icon="assets" compact title="正在读取资产" description="按当前目录和分类加载这一页。" /> : visibleAssets.length ? <><div className="project-assets-grid assets-library-grid">{visibleAssets.map((asset) => asset.category === "character" ? <ProjectCharacterCard key={asset.id} asset={asset} folderItems={folderMoveItems} generating={generatingAssetIds.has(asset.id)} removing={(moveMutation.isPending && moveMutation.variables?.id === asset.id) || (unlinkMutation.isPending && unlinkMutation.variables === asset.id)} onOpen={() => setPreviewAsset(asset)} onEdit={() => openCharacterEditor(asset)} onGenerate={() => generateMutation.mutate(asset)} onBindImages={() => openImages(asset)} onBindVoice={() => openVoice(asset)} onMove={(nextFolderId) => moveMutation.mutate({ id: asset.id, nextFolderId })} onRemove={() => unlinkMutation.mutate(asset.id)} /> : <MediaAssetCard key={asset.id} asset={asset} personalAsset={personalAssets.find((item) => item.id === asset.id)} folderItems={folderMoveItems} onOpen={() => setPreviewAsset(asset)} onMove={(nextFolderId) => moveMutation.mutate({ id: asset.id, nextFolderId })} onCategoryChange={(next) => categoryMutation.mutate({ id: asset.id, next })} onVersion={() => versionMutation.mutate(asset.id)} onRemove={() => unlinkMutation.mutate(asset.id)} loading={(moveMutation.isPending && moveMutation.variables?.id === asset.id) || (categoryMutation.isPending && categoryMutation.variables?.id === asset.id) || (versionMutation.isPending && versionMutation.variables === asset.id) || (unlinkMutation.isPending && unlinkMutation.variables === asset.id)} />)}</div><PaginationBar current={page} pageSize={pageSize} total={assetsQuery.data?.total || 0} itemLabel="项" pageSizeOptions={[20, 40, 80]} onChange={(nextPage, nextPageSize) => { setPage(nextPageSize !== pageSize ? 1 : nextPage); setPageSize(nextPageSize); }} /></> : childFolders.length || (showPendingCandidates && pendingCandidates.length) ? null : <WorkspaceState icon="assets" compact title="这个文件夹还没有内容" description="可以新建子文件夹、引用个人素材,或把画布产物归档到这里。" />}
|
||||
{assetsQuery.isLoading ? <WorkspaceState icon="assets" compact title="正在读取资产" description="按当前目录、分类和关键词加载这一页。" /> : visibleAssets.length ? <><div className="project-assets-grid assets-library-grid">{visibleAssets.map((asset) => asset.category === "character" ? <ProjectCharacterCard key={asset.id} asset={asset} folderItems={folderMoveItems} generating={generatingAssetIds.has(asset.id)} removing={(moveMutation.isPending && moveMutation.variables?.id === asset.id) || (unlinkMutation.isPending && unlinkMutation.variables === asset.id)} onOpen={() => setPreviewAsset(asset)} onEdit={() => openCharacterEditor(asset)} onGenerate={() => generateMutation.mutate(asset)} onBindImages={() => openImages(asset)} onBindVoice={() => openVoice(asset)} onMove={(nextFolderId) => moveMutation.mutate({ id: asset.id, nextFolderId })} onRemove={() => unlinkMutation.mutate(asset.id)} /> : <MediaAssetCard key={asset.id} asset={asset} personalAsset={personalAssets.find((item) => item.id === asset.id)} folderItems={folderMoveItems} onOpen={() => setPreviewAsset(asset)} onMove={(nextFolderId) => moveMutation.mutate({ id: asset.id, nextFolderId })} onCategoryChange={(next) => categoryMutation.mutate({ id: asset.id, next })} onVersion={() => versionMutation.mutate(asset.id)} onRemove={() => unlinkMutation.mutate(asset.id)} loading={(moveMutation.isPending && moveMutation.variables?.id === asset.id) || (categoryMutation.isPending && categoryMutation.variables?.id === asset.id) || (versionMutation.isPending && versionMutation.variables === asset.id) || (unlinkMutation.isPending && unlinkMutation.variables === asset.id)} />)}</div><PaginationBar current={page} pageSize={pageSize} total={assetsQuery.data?.total || 0} itemLabel="项" pageSizeOptions={[20, 40, 80]} onChange={(nextPage, nextPageSize) => { setPage(nextPageSize !== pageSize ? 1 : nextPage); setPageSize(nextPageSize); }} /></> : childFolders.length || (showPendingCandidates && pendingCandidates.length) ? null : <WorkspaceState icon="assets" compact title={debouncedKeyword ? "没有找到匹配资产" : "这个文件夹还没有内容"} description={debouncedKeyword ? "换一个关键词,或调整左侧分类与目录筛选。" : "可以新建子文件夹、引用个人素材,或把画布产物归档到这里。"} />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -648,8 +660,8 @@ function CharacterEditorModal({ open, editing, form, loading, onClose, onSave }:
|
||||
|
||||
type CharacterFormKey = (typeof characterFields)[number][0];
|
||||
|
||||
function MediaAssetCard({ asset, personalAsset, folderItems, onOpen, onMove, onCategoryChange, onVersion, onRemove, loading }: { asset: ProjectAsset; personalAsset?: Asset; folderItems: Array<{ key: string; label: string }>; onOpen: () => void; onMove: (folderId: string) => 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}`}><ProjectAssetMedia asset={asset} personalAsset={personalAsset} /><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-foreground/42" /></div><div className="mt-2 flex items-center justify-between gap-2 pt-1"><span className="text-[var(--fs-micro)] text-foreground/38">{mediaLabel(asset.mediaType)}</span><div className="flex items-center"><Dropdown trigger={["click"]} menu={{ selectedKeys: [asset.folderId || ""], items: folderItems, onClick: ({ key }) => onMove(key) }}><Button type="text" size="small" icon={<MoveRight className="size-3.5" />} loading={loading} aria-label={`移动 ${asset.title}`} /></Dropdown><Button type="text" size="small" icon={<RefreshCw className="size-3.5" />} loading={loading} onClick={onVersion} aria-label={`为 ${asset.title} 创建版本`} /><Popconfirm title="移出项目资产?" okText="移出" cancelText="取消" onConfirm={onRemove}><Button type="text" danger size="small" icon={<Trash2 className="size-3.5" />} loading={loading} aria-label={`移出 ${asset.title}`} /></Popconfirm></div></div></div></AssetLibraryCard>;
|
||||
function MediaAssetCard({ asset, personalAsset, folderItems, onOpen, onMove, onCategoryChange, onVersion, onRemove, loading }: { asset: ProjectAsset; personalAsset?: Asset; folderItems: Array<{ key: string; label: string }>; onOpen: () => void; onMove: (folderId: string) => void; onCategoryChange: (category: AssetCategory) => 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}`}><ProjectAssetMedia asset={asset} personalAsset={personalAsset} /><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(normalizeAssetCategory(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-foreground/42" /></div><div className="mt-2 flex items-center justify-between gap-2 pt-1"><span className="text-[var(--fs-micro)] text-foreground/38">{mediaLabel(asset.mediaType)}</span><div className="flex items-center"><Dropdown trigger={["click"]} menu={{ selectedKeys: [asset.folderId || ""], items: folderItems, onClick: ({ key }) => onMove(key) }}><Button type="text" size="small" icon={<MoveRight className="size-3.5" />} loading={loading} aria-label={`移动 ${asset.title}`} /></Dropdown><Button type="text" size="small" icon={<RefreshCw className="size-3.5" />} loading={loading} onClick={onVersion} aria-label={`为 ${asset.title} 创建版本`} /><Popconfirm title="移出项目资产?" okText="移出" cancelText="取消" onConfirm={onRemove}><Button type="text" danger size="small" icon={<Trash2 className="size-3.5" />} loading={loading} aria-label={`移出 ${asset.title}`} /></Popconfirm></div></div></div></AssetLibraryCard>;
|
||||
}
|
||||
|
||||
function ProjectAssetMedia({ asset, personalAsset }: { asset: ProjectAsset; personalAsset?: Asset }) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { StoryboardRow } from "@/types/canvas";
|
||||
import { formatShotOrdinal } from "@/lib/shot-label";
|
||||
import type { ProjectDetail, ShotRevisionInput } from "@/services/api/projects";
|
||||
import { ensureShotAssetMentionPrompt } from "./workflow-shot-references";
|
||||
|
||||
@@ -90,7 +91,7 @@ export function storyboardRowsToProjectShots(rows: StoryboardRow[], detail?: Pro
|
||||
return reference ? [reference] : [];
|
||||
});
|
||||
return {
|
||||
title: `SC.${String(shotNumber).padStart(2, "0")}`,
|
||||
title: formatShotOrdinal(index),
|
||||
description,
|
||||
durationMs,
|
||||
assetVersionIds: storyboardRowAssetVersionIds(row, versionByAssetId),
|
||||
|
||||
@@ -347,17 +347,17 @@ export default function ProjectChaptersView({ detail, refreshProject }: ProjectD
|
||||
setCompletedChapterOperations((current) => ({ ...current, [chapterOperationKey(unitId, kind)]: true }));
|
||||
};
|
||||
const storeExtractedCharacters = async (unitId: string, characters: Awaited<ReturnType<typeof extractChapterCharacters>>) => {
|
||||
// 已确认角色允许再次提取并作为候选归并,只有尚待处理的同名候选需要去重。
|
||||
const knownNames = new Set(detail.assetCandidates
|
||||
.filter((candidate) => candidate.category === "character" && candidate.status === "pending_confirmation")
|
||||
.map((candidate) => normalizeCharacterName(candidate.name)));
|
||||
const knownNames = new Set([
|
||||
...detail.assetCandidates.filter((candidate) => candidate.category === "character").map((candidate) => normalizeCharacterName(candidate.name)),
|
||||
...detail.assets.filter((asset) => asset.category === "character").map((asset) => normalizeCharacterName(asset.title)),
|
||||
]);
|
||||
const fresh = characters.filter((character) => ![character.name, ...character.aliases].map(normalizeCharacterName).some((name) => knownNames.has(name)));
|
||||
if (fresh.length) {
|
||||
await createProjectAssetCandidates(detail.project.id, fresh.map((character) => ({ unitId, name: character.name, category: "character", details: { ...character } })));
|
||||
}
|
||||
const created = fresh.length
|
||||
? await createProjectAssetCandidates(detail.project.id, fresh.map((character) => ({ unitId, name: character.name, category: "character", details: { ...character } })), "chapter_character_extract")
|
||||
: { candidates: [] };
|
||||
markChapterOperationCompleted(unitId, "characters");
|
||||
refreshProject();
|
||||
return fresh.length;
|
||||
return created.candidates.length;
|
||||
};
|
||||
const storeGeneratedStoryboard = async (unitId: string, rows: ReturnType<typeof chapterStoryboardFromGenerationTask>["rows"]) => {
|
||||
const shots = storyboardRowsToProjectShots(rows, detail);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Tag } from "antd";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import type { ProjectDetail, ProjectUnit } from "@/services/api/projects";
|
||||
import { ASSET_CATEGORY_LABELS, assetCategoryLabel as sharedAssetCategoryLabel } from "@/lib/asset-category";
|
||||
|
||||
export type ProjectDetailViewProps = {
|
||||
detail: ProjectDetail;
|
||||
@@ -9,15 +10,7 @@ export type ProjectDetailViewProps = {
|
||||
onCreateCanvas: () => void;
|
||||
};
|
||||
|
||||
export const categoryLabels: Record<string, string> = {
|
||||
character: "角色",
|
||||
environment: "场景",
|
||||
wardrobe: "服饰",
|
||||
prop: "道具",
|
||||
weapon: "武器",
|
||||
style: "画风",
|
||||
other: "其他",
|
||||
};
|
||||
export const categoryLabels: Record<string, string> = ASSET_CATEGORY_LABELS;
|
||||
|
||||
export const mediaLabels: Record<string, string> = {
|
||||
image: "图片",
|
||||
@@ -64,7 +57,7 @@ const sourceTypeLabels: Record<string, string> = {
|
||||
};
|
||||
|
||||
export function categoryLabel(value: string) {
|
||||
return categoryLabels[value] || "其他";
|
||||
return sharedAssetCategoryLabel(value);
|
||||
}
|
||||
|
||||
export function statusLabel(value: string) {
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { App, Button, Empty, Form, Image, Input, InputNumber, Segmented, Select, Tag } from "antd";
|
||||
import { Box, ChevronDown, ChevronLeft, ChevronRight, Download, Film, Image as ImageIcon, Layers3, List, Play, Plus, RefreshCcw, Save, SlidersHorizontal, Trash2, UsersRound, WandSparkles, X } from "lucide-react";
|
||||
import { Box, ChevronDown, ChevronLeft, ChevronRight, Download, Film, Image as ImageIcon, Layers3, List, Play, Plus, RefreshCcw, Save, Search, SlidersHorizontal, Trash2, UsersRound, WandSparkles, X } from "lucide-react";
|
||||
import { Link, useNavigate } from "react-router";
|
||||
|
||||
import { CanvasResourceMentionTextarea } from "@/components/canvas/canvas-resource-mention-textarea";
|
||||
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
||||
import { ModelPicker } from "@/components/model-picker";
|
||||
import { CreditSymbol, requestCreditCost } from "@/constant/credits";
|
||||
import { modelCapabilityConfigFor, normalizeImageValue, normalizeVideoValue, videoDurationOptions } from "@/lib/model-capabilities";
|
||||
import { modelQuoteRequest } from "@/lib/model-pricing";
|
||||
import { customShotTitle, formatShotOrdinal, normalizeDefaultShotTitle } from "@/lib/shot-label";
|
||||
import { modelCompatibilityError, resolveCompatibleModel, type ModelRequirements } from "@/lib/model-selection";
|
||||
import { formatVideoResolutionLabel } from "@/lib/video-generation-options";
|
||||
import { submitBackendGenerationTask } from "@/services/api/generation-task";
|
||||
@@ -80,7 +82,7 @@ export default function WorkflowProductionWorkbench(props: Props) {
|
||||
const [form] = Form.useForm<ShotEditorValues>();
|
||||
const watchedDuration = Form.useWatch("durationSeconds", form);
|
||||
const watchedTitle = Form.useWatch("title", form);
|
||||
const [leftTab, setLeftTab] = useState<"assets" | "episodes" | "shots">("assets");
|
||||
const [leftTab, setLeftTab] = useState<"assets" | "episodes" | "shots">("episodes");
|
||||
const [previewTab, setPreviewTab] = useState<"latest" | "history">("latest");
|
||||
const [previewArtifactId, setPreviewArtifactId] = useState("");
|
||||
const [editorDirty, setEditorDirty] = useState(false);
|
||||
@@ -219,7 +221,7 @@ export default function WorkflowProductionWorkbench(props: Props) {
|
||||
: shotDurationSeconds;
|
||||
const videoPrompt = ensureShotAssetMentionPrompt(revision?.videoPrompt || "", shotAssetReferenceContext.mentionReferences);
|
||||
form.setFieldsValue({
|
||||
title: selectedShot?.title || "",
|
||||
title: normalizeDefaultShotTitle(selectedShot?.title, Math.max(0, shotIndex)),
|
||||
plotDescription: revision?.plotDescription || selectedShot?.description || "",
|
||||
action: revision?.action || "",
|
||||
dialogue: revision?.dialogue || "",
|
||||
@@ -438,7 +440,7 @@ export default function WorkflowProductionWorkbench(props: Props) {
|
||||
size="small"
|
||||
value={leftTab}
|
||||
onChange={(value) => setLeftTab(value as typeof leftTab)}
|
||||
options={[{ value: "assets", label: "资产" }, { value: "episodes", label: "章节" }, { value: "shots", label: "镜头" }]}
|
||||
options={[{ value: "episodes", label: "章节" }, { value: "shots", label: "镜头" }, { value: "assets", label: "资产" }]}
|
||||
/>
|
||||
<div className="workflow-library-scroll thin-scrollbar">
|
||||
{leftTab === "assets" ? <AssetLibrary detail={detail} referenceByVersionId={referenceByVersionId} changing={changeAssetBinding.isPending} onToggle={(asset, reference) => changeAssetBinding.mutate({ asset, reference })} /> : null}
|
||||
@@ -450,8 +452,8 @@ export default function WorkflowProductionWorkbench(props: Props) {
|
||||
<section className="workflow-shot-editor">
|
||||
<header className="workflow-panel-header">
|
||||
<div className="workflow-shot-heading">
|
||||
<span className="workflow-shot-number">SC.{String(shotIndex + 1).padStart(2, "0")}</span>
|
||||
<h2>{watchedTitle || selectedShot.title || "未命名镜头"}</h2>
|
||||
<span className="workflow-shot-number">{formatShotOrdinal(shotIndex)}</span>
|
||||
{customShotTitle(watchedTitle || selectedShot.title, shotIndex) ? <h2>{customShotTitle(watchedTitle || selectedShot.title, shotIndex)}</h2> : null}
|
||||
<Tag className="!m-0" color={saveShot.isPending ? "blue" : editorDirty ? "orange" : revision ? "green" : undefined}>{saveShot.isPending ? "保存中" : editorDirty ? "有未保存修改" : revision ? "已保存" : "草稿"}</Tag>
|
||||
</div>
|
||||
<div className="flex items-center gap-1"><span className="mr-1 text-[var(--fs-micro)] text-foreground/45">{shotIndex + 1} / {shots.length}</span><Button type="text" size="small" icon={<ChevronLeft className="size-4" />} disabled={shotIndex <= 0} onClick={() => selectRelativeShot(-1)} aria-label="上一个镜头" /><Button type="text" size="small" icon={<ChevronRight className="size-4" />} disabled={shotIndex >= shots.length - 1} onClick={() => selectRelativeShot(1)} aria-label="下一个镜头" /></div>
|
||||
@@ -590,12 +592,15 @@ function WorkflowDisclosure({ icon, title, description, summary, className = "",
|
||||
|
||||
function AssetLibrary({ detail, referenceByVersionId, changing, onToggle }: { detail: ProjectDetail; referenceByVersionId: Map<string, ShotAssetReference>; changing: boolean; onToggle: (asset: ProjectAsset, reference?: ShotAssetReference) => void }) {
|
||||
const [category, setCategory] = useState("all");
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const debouncedKeyword = useDebouncedValue(keyword.trim(), 250);
|
||||
const [page, setPage] = useState(1);
|
||||
const pageSize = 30;
|
||||
const assetsQuery = useQuery({
|
||||
queryKey: ["project", detail.project.id, "assets", "workflow-library", category, page, pageSize],
|
||||
queryFn: () => listProjectAssetsPage(detail.project.id, { page, pageSize, category: category === "all" ? undefined : category }),
|
||||
queryKey: ["project", detail.project.id, "assets", "workflow-library", category, debouncedKeyword, page, pageSize],
|
||||
queryFn: () => listProjectAssetsPage(detail.project.id, { page, pageSize, category: category === "all" ? undefined : category, query: debouncedKeyword || undefined }),
|
||||
});
|
||||
useEffect(() => setPage(1), [debouncedKeyword]);
|
||||
const assetsPage = assetsQuery.data?.assets || [];
|
||||
const groups = useMemo(() => {
|
||||
const map = new Map<string, ProjectAsset[]>();
|
||||
@@ -604,7 +609,7 @@ function AssetLibrary({ detail, referenceByVersionId, changing, onToggle }: { de
|
||||
}, [assetsPage]);
|
||||
const total = assetsQuery.data?.total || 0;
|
||||
const pages = Math.max(1, Math.ceil(total / pageSize));
|
||||
return <div className="workflow-asset-groups"><Select size="small" className="mb-2 w-full" value={category} options={[{ value: "all", label: `全部资产(${Object.values(assetsQuery.data?.categoryCounts || {}).reduce((sum, count) => sum + count, 0)})` }, ...Object.entries(assetsQuery.data?.categoryCounts || {}).filter(([, count]) => count > 0).map(([value, count]) => ({ value, label: `${assetCategoryLabel(value)}(${count})` }))]} onChange={(value) => { setCategory(value); setPage(1); }} />{assetsQuery.isLoading ? <div className="py-6 text-center text-xs text-foreground/45">正在读取资产…</div> : groups.length ? groups.map(([groupCategory, assets]) => <section key={groupCategory}><h3>{assetCategoryLabel(groupCategory)} <span>({assets.length})</span></h3><div className="workflow-asset-list">{assets.map((asset) => { const reference = asset.primaryVersionId ? referenceByVersionId.get(asset.primaryVersionId) : undefined; const active = Boolean(reference); const previewUrl = assetPreviewUrl(asset); return <button key={asset.id} type="button" className={`workflow-asset-row ${active ? "is-active" : ""}`} disabled={changing || !asset.primaryVersionId} aria-pressed={active} onClick={() => onToggle(asset, reference)}><span className="workflow-asset-thumb">{previewUrl ? <img src={previewUrl} alt="" loading="lazy" /> : asset.category === "character" ? <UsersRound /> : asset.mediaType === "image" ? <ImageIcon /> : <Box />}</span><span className="min-w-0 flex-1"><strong>{asset.title}</strong><small>{active ? "已绑定 · 点击取消" : `${assetCategoryLabel(asset.category)} · v${Math.max(1, asset.versionCount)}`}</small></span>{active ? <span className="workflow-bound-dot" /> : null}</button>; })}</div></section>) : <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="项目还没有资产" />}{total > pageSize ? <div className="mt-3 flex items-center justify-between border-t border-border/60 pt-2 text-[var(--fs-micro)] text-foreground/45"><span>{page}/{pages} · 共 {total} 项</span><span className="flex gap-1"><Button type="text" size="small" icon={<ChevronLeft className="size-3.5" />} disabled={page <= 1} onClick={() => setPage((value) => Math.max(1, value - 1))} /><Button type="text" size="small" icon={<ChevronRight className="size-3.5" />} disabled={page >= pages} onClick={() => setPage((value) => Math.min(pages, value + 1))} /></span></div> : null}</div>;
|
||||
return <div className="workflow-asset-groups"><Input allowClear size="small" className="mb-2 w-full" value={keyword} onChange={(event) => setKeyword(event.target.value)} prefix={<Search className="size-3.5 text-foreground/35" />} placeholder="搜索资产名称" aria-label="搜索镜头资产" /><Select size="small" className="mb-2 w-full" value={category} options={[{ value: "all", label: `全部资产(${Object.values(assetsQuery.data?.categoryCounts || {}).reduce((sum, count) => sum + count, 0)})` }, ...Object.entries(assetsQuery.data?.categoryCounts || {}).filter(([, count]) => count > 0).map(([value, count]) => ({ value, label: `${assetCategoryLabel(value)}(${count})` }))]} onChange={(value) => { setCategory(value); setPage(1); }} />{assetsQuery.isLoading ? <div className="py-6 text-center text-xs text-foreground/45">正在读取资产…</div> : groups.length ? groups.map(([groupCategory, assets]) => <section key={groupCategory}><h3>{assetCategoryLabel(groupCategory)} <span>({assets.length})</span></h3><div className="workflow-asset-list">{assets.map((asset) => { const reference = asset.primaryVersionId ? referenceByVersionId.get(asset.primaryVersionId) : undefined; const active = Boolean(reference); const previewUrl = assetPreviewUrl(asset); return <button key={asset.id} type="button" className={`workflow-asset-row ${active ? "is-active" : ""}`} disabled={changing || !asset.primaryVersionId} aria-pressed={active} onClick={() => onToggle(asset, reference)}><span className="workflow-asset-thumb">{previewUrl ? <img src={previewUrl} alt="" loading="lazy" /> : asset.category === "character" ? <UsersRound /> : asset.mediaType === "image" ? <ImageIcon /> : <Box />}</span><span className="min-w-0 flex-1"><strong>{asset.title}</strong><small>{active ? "已绑定 · 点击取消" : `${assetCategoryLabel(asset.category)} · v${Math.max(1, asset.versionCount)}`}</small></span>{active ? <span className="workflow-bound-dot" /> : null}</button>; })}</div></section>) : <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={debouncedKeyword ? "没有找到匹配资产" : "项目还没有资产"} />}{total > pageSize ? <div className="mt-3 flex items-center justify-between border-t border-border/60 pt-2 text-[var(--fs-micro)] text-foreground/45"><span>{page}/{pages} · 共 {total} 项</span><span className="flex gap-1"><Button type="text" size="small" icon={<ChevronLeft className="size-3.5" />} disabled={page <= 1} onClick={() => setPage((value) => Math.max(1, value - 1))} /><Button type="text" size="small" icon={<ChevronRight className="size-3.5" />} disabled={page >= pages} onClick={() => setPage((value) => Math.min(pages, value + 1))} /></span></div> : null}</div>;
|
||||
}
|
||||
|
||||
function ShotAssetMentionTextarea({ value = "", onChange = () => undefined, references, variant = "motion" }: { value?: string; onChange?: (value: string) => void; references: ReturnType<typeof buildShotAssetReferenceContext>["mentionReferences"]; variant?: "scene" | "motion" }) {
|
||||
@@ -630,7 +635,7 @@ function EpisodeLibrary({ detail, activeUnitId, projectId, activeStage }: { deta
|
||||
}
|
||||
|
||||
function ShotLibrary({ detail, shots, selectedShotId, onSelectShot }: { detail: ProjectDetail; shots: ProjectShot[]; selectedShotId: string; onSelectShot: (id: string) => void }) {
|
||||
return <div className="workflow-simple-list">{shots.map((shot, index) => { const video = currentArtifact(detail, shot.id, "video"); return <button key={shot.id} type="button" className={shot.id === selectedShotId ? "is-active" : ""} onClick={() => onSelectShot(shot.id)}><span>{String(index + 1).padStart(2, "0")}</span><span className="min-w-0 flex-1"><strong>{shot.title}</strong><small>{formatDuration(shot.durationMs)}</small></span><ArtifactStatus artifact={video} compact /></button>; })}</div>;
|
||||
return <div className="workflow-simple-list">{shots.map((shot, index) => { const video = currentArtifact(detail, shot.id, "video"); return <button key={shot.id} type="button" className={shot.id === selectedShotId ? "is-active" : ""} onClick={() => onSelectShot(shot.id)}><span>{formatShotOrdinal(index)}</span><span className="min-w-0 flex-1"><strong>{customShotTitle(shot.title, index) || "未命名"}</strong><small>{formatDuration(shot.durationMs)}</small></span><ArtifactStatus artifact={video} compact /></button>; })}</div>;
|
||||
}
|
||||
|
||||
function BoundAssets({ detail, shotId, changing, onUnlink }: { detail: ProjectDetail; shotId: string; changing: boolean; onUnlink: (reference: ShotAssetReference) => void }) {
|
||||
@@ -693,7 +698,7 @@ function TimelineShot({ artifactType, detail, shot, task, submitting, index, sel
|
||||
const revision = currentRevision(detail, shot);
|
||||
const stageLabel = artifactType === "video" ? "镜头视频" : artifactType === "action_board" ? "动作预演" : "分镜画面";
|
||||
const cameraMeta = [revision?.shotSize, revision?.cameraMovement].filter(Boolean).join(" · ") || "等待补充镜头参数";
|
||||
return <button type="button" className={`workflow-timeline-shot ${selected ? "is-active" : ""}`} onClick={onSelect}><span className="workflow-timeline-media">{preview?.resourceId ? preview.type === "video" ? <video src={resourceFileUrl(preview.resourceId)} muted preload="metadata" /> : <img src={resourceFileUrl(preview.resourceId)} alt="" loading="lazy" /> : <Film />}</span><span className="workflow-timeline-copy"><span className="workflow-timeline-heading"><strong>SC.{String(index + 1).padStart(2, "0")}</strong><b>{formatDuration(shot.durationMs)}</b></span><em className="workflow-timeline-title">{shot.title}</em><small className="workflow-timeline-meta">{cameraMeta}</small><span className="workflow-timeline-status"><span>{stageLabel}{stateArtifact ? ` · v${stateArtifact.version}` : ""}</span><ArtifactStatus artifact={stateArtifact} taskStatus={submitting ? "queued" : task?.status} compact /></span></span></button>;
|
||||
return <button type="button" className={`workflow-timeline-shot ${selected ? "is-active" : ""}`} onClick={onSelect}><span className="workflow-timeline-media">{preview?.resourceId ? preview.type === "video" ? <video src={resourceFileUrl(preview.resourceId)} muted preload="metadata" /> : <img src={resourceFileUrl(preview.resourceId)} alt="" loading="lazy" /> : <Film />}</span><span className="workflow-timeline-copy"><span className="workflow-timeline-heading"><strong>{formatShotOrdinal(index)}</strong><b>{formatDuration(shot.durationMs)}</b></span>{customShotTitle(shot.title, index) ? <em className="workflow-timeline-title">{customShotTitle(shot.title, index)}</em> : null}<small className="workflow-timeline-meta">{cameraMeta}</small><span className="workflow-timeline-status"><span>{stageLabel}{stateArtifact ? ` · v${stateArtifact.version}` : ""}</span><ArtifactStatus artifact={stateArtifact} taskStatus={submitting ? "queued" : task?.status} compact /></span></span></button>;
|
||||
}
|
||||
|
||||
function revisionInput(values: ShotEditorValues): ShotRevisionInput {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Tag } from "antd";
|
||||
import { Check, CircleAlert } from "lucide-react";
|
||||
import { Link } from "react-router";
|
||||
|
||||
import { assetCategoryLabel as sharedAssetCategoryLabel } from "@/lib/asset-category";
|
||||
import type { ProjectDetail, ProjectShot, ShotArtifact, ShotRevision, WorkflowStep } from "@/services/api/projects";
|
||||
import type { TaskStatus } from "@/services/api/task-center";
|
||||
|
||||
@@ -71,7 +72,7 @@ export function artifactTypeForStage(stage: ShortDramaWorkflowStage) {
|
||||
}
|
||||
|
||||
export function assetCategoryLabel(category: string) {
|
||||
return ({ character: "角色", environment: "场景", wardrobe: "服饰", accessory: "配饰", prop: "道具", weapon: "武器", style: "画风" } as Record<string, string>)[category] || category || "其他";
|
||||
return sharedAssetCategoryLabel(category);
|
||||
}
|
||||
|
||||
export function formatDuration(durationMs: number) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
|
||||
import { assetCategoryLabel, normalizeAssetCategory } from "@/lib/asset-category";
|
||||
import { resourceFileUrl, resourceIdFromStorageKey, resourceStorageKey } from "@/services/api/resources";
|
||||
import type { CharacterRepresentation, ProjectAsset, ProjectDetail, ShotAssetReference } from "@/services/api/projects";
|
||||
import type { AssetCategory } from "@/stores/use-asset-store";
|
||||
@@ -23,8 +24,6 @@ export type ShotPromptAssetReference = {
|
||||
dialogue?: string;
|
||||
};
|
||||
|
||||
const assetCategories = new Set<AssetCategory>(["character", "environment", "wardrobe", "prop", "weapon", "style", "other"]);
|
||||
|
||||
export function buildShotAssetReferenceContext(detail: ProjectDetail, shotId: string): ShotAssetReferenceContext {
|
||||
const assetByVersionId = new Map(detail.assets.filter((asset) => asset.primaryVersionId).map((asset) => [asset.primaryVersionId as string, asset]));
|
||||
const seenAssetIds = new Set<string>();
|
||||
@@ -194,11 +193,11 @@ function dialogueForCharacter(dialogue: string, characterName: string) {
|
||||
}
|
||||
|
||||
function projectAssetCategory(value: string): AssetCategory {
|
||||
return assetCategories.has(value as AssetCategory) ? value as AssetCategory : "other";
|
||||
return normalizeAssetCategory(value);
|
||||
}
|
||||
|
||||
function projectAssetCategoryLabel(value: string) {
|
||||
return ({ environment: "场景", wardrobe: "服装", prop: "道具", weapon: "武器", style: "风格", other: "资产" } as Record<string, string>)[value] || "资产";
|
||||
return assetCategoryLabel(value);
|
||||
}
|
||||
|
||||
function characterVoiceDescription(asset: ProjectAsset) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { Clock3, Film, Layers3, PackageCheck } from "lucide-react";
|
||||
import { Link } from "react-router";
|
||||
|
||||
import { ASSET_CATEGORIES } from "@/lib/asset-category";
|
||||
import { listProjectAssetsPage, type ProjectDetail } from "@/services/api/projects";
|
||||
|
||||
import { assetCategoryLabel, formatDuration, MetricCard, StageHeading } from "./workflow-shared";
|
||||
@@ -16,8 +17,7 @@ export function AssetsStage({ detail, projectId, unitId }: { detail: ProjectDeta
|
||||
const candidates = detail.assetCandidates.filter((item) => !item.unitId || item.unitId === unitId);
|
||||
const assetCountsQuery = useQuery({ queryKey: ["project", projectId, "assets", "workflow-counts"], queryFn: () => listProjectAssetsPage(projectId, { page: 1, pageSize: 1 }) });
|
||||
const confirmedCounts = assetCountsQuery.data?.categoryCounts || {};
|
||||
const categories = ["character", "environment", "wardrobe", "prop", "weapon"];
|
||||
return <section className="mx-auto max-w-6xl"><StageHeading eyebrow="02 / 资产拆分" title="确认镜头真正会使用的资产" description="角色、场景、服饰、配饰与武器先建立稳定版本,镜头再绑定具体版本。" /><div className="mt-6 grid gap-3 sm:grid-cols-2 lg:grid-cols-5">{categories.map((category) => { const confirmed = confirmedCounts[category] || 0; const pending = candidates.filter((item) => item.category === category && item.status === "pending_confirmation").length; return <div key={category} className="border-t border-border/70 py-4"><div className="text-xs font-medium text-foreground/55">{assetCategoryLabel(category)}</div><div className="mt-3 text-2xl font-semibold">{assetCountsQuery.isLoading ? "—" : confirmed}</div><div className="mt-1 text-[var(--fs-micro)] text-foreground/42">已确认 · {pending} 待处理</div></div>; })}</div><div className="mt-5 flex items-center justify-between border-y border-border/70 py-5"><div><h3 className="text-sm font-semibold">资产库承担版本确认与设定维护</h3><p className="mt-1 text-xs text-foreground/48">确认后可直接在分镜工作台左栏绑定到镜头。</p></div><Link to={`/projects/${projectId}/assets`}><Button type="primary">打开资产库</Button></Link></div></section>;
|
||||
return <section className="mx-auto max-w-6xl"><StageHeading eyebrow="02 / 资产拆分" title="确认镜头真正会使用的资产" description="角色、场景、道具、素材与其他资产先建立稳定版本,镜头再绑定具体版本。" /><div className="mt-6 grid gap-3 sm:grid-cols-2 lg:grid-cols-5">{ASSET_CATEGORIES.map((category) => { const confirmed = confirmedCounts[category] || 0; const pending = candidates.filter((item) => item.category === category && item.status === "pending_confirmation").length; return <div key={category} className="border-t border-border/70 py-4"><div className="text-xs font-medium text-foreground/55">{assetCategoryLabel(category)}</div><div className="mt-3 text-2xl font-semibold">{assetCountsQuery.isLoading ? "—" : confirmed}</div><div className="mt-1 text-[var(--fs-micro)] text-foreground/42">已确认 · {pending} 待处理</div></div>; })}</div><div className="mt-5 flex items-center justify-between border-y border-border/70 py-5"><div><h3 className="text-sm font-semibold">资产库承担版本确认与设定维护</h3><p className="mt-1 text-xs text-foreground/48">确认后可直接在分镜工作台左栏绑定到镜头。</p></div><Link to={`/projects/${projectId}/assets`}><Button type="primary">打开资产库</Button></Link></div></section>;
|
||||
}
|
||||
|
||||
export function DeliveryStage({ detail, unitId }: { detail: ProjectDetail; unitId: string }) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { App, Button, Empty } from "antd";
|
||||
import { Link } from "react-router";
|
||||
|
||||
import { formatShotOrdinal } from "@/lib/shot-label";
|
||||
import { saveProjectShot, type ProjectDetail } from "@/services/api/projects";
|
||||
|
||||
import { AssetsStage, DeliveryStage, StoryStage } from "./workflow-stage-views";
|
||||
@@ -49,7 +50,7 @@ export default function ProjectWorkflowView({ detail, projectId, unitId, stage }
|
||||
if (!unit) throw new Error("请先添加章节");
|
||||
return saveProjectShot(projectId, {
|
||||
unitId: unit.id,
|
||||
title: `SC.${String(shots.length + 1).padStart(2, "0")}`,
|
||||
title: formatShotOrdinal(shots.length),
|
||||
description: "待补充分镜画面",
|
||||
position: shots.length,
|
||||
durationMs: 3000,
|
||||
|
||||
Reference in New Issue
Block a user