perf(platform): 短剧创作工作台 - 拆分长篇项目接口并优化章节与素材分页
This commit is contained in:
@@ -4,7 +4,7 @@ import { ArrowLeft, BookOpenText, Clapperboard, Images, LayoutDashboard, LayoutG
|
||||
import { Link, Navigate, useNavigate, useParams } from "react-router";
|
||||
|
||||
import { createCanvasProjectWithRemoteSync } from "@/services/user-data-sync";
|
||||
import { getProject, linkCanvasUnit } from "@/services/api/projects";
|
||||
import { getProjectCore, getProjectOverview, getProjectUnitWorkspace, linkCanvasUnit, listProjectUnits } from "@/services/api/projects";
|
||||
import { WorkspacePage } from "@/components/layout/workspace-page";
|
||||
import { WorkspaceErrorState, WorkspaceLoadingState } from "@/components/layout/workspace-state";
|
||||
import { useWorkspaceTopBarExtension } from "@/components/layout/workspace-top-bar-extension";
|
||||
@@ -36,24 +36,53 @@ export default function ProjectDetailPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const { message } = App.useApp();
|
||||
const activeView: DetailView = unitId || view === "workflow" ? "workflow" : chapterId ? "chapters" : views.some((item) => item.key === view) ? view as DetailView : "overview";
|
||||
const detail = useQuery({
|
||||
queryKey: ["project", projectId],
|
||||
queryFn: () => getProject(projectId),
|
||||
const coreQuery = useQuery({
|
||||
queryKey: ["project", projectId, "core"],
|
||||
queryFn: () => getProjectCore(projectId),
|
||||
enabled: Boolean(projectId),
|
||||
refetchOnMount: "always",
|
||||
});
|
||||
const unitsQuery = useQuery({ queryKey: ["project", projectId, "units"], queryFn: () => listProjectUnits(projectId), enabled: Boolean(projectId) });
|
||||
const units = unitsQuery.data?.units || [];
|
||||
const firstUnitId = units.slice().sort((left, right) => left.position - right.position)[0]?.id || "";
|
||||
const requestedUnitId = chapterId || unitId || "";
|
||||
const activeUnitId = units.some((unit) => unit.id === requestedUnitId) ? requestedUnitId : firstUnitId;
|
||||
const overviewQuery = useQuery({ queryKey: ["project", projectId, "overview"], queryFn: () => getProjectOverview(projectId), enabled: Boolean(projectId) && activeView === "overview" });
|
||||
const workspaceQuery = useQuery({
|
||||
queryKey: ["project", projectId, "unit-workspace", activeUnitId],
|
||||
queryFn: () => getProjectUnitWorkspace(projectId, activeUnitId),
|
||||
enabled: Boolean(projectId && activeUnitId) && (activeView === "chapters" || activeView === "workflow"),
|
||||
refetchInterval: (query) => (query.state.data?.tasks || []).some((task) => task.clientContext?.shotId && (task.status === "queued" || task.status === "running")) ? 2_000 : false,
|
||||
});
|
||||
const project = coreQuery.data?.project;
|
||||
const workspace = workspaceQuery.data;
|
||||
const detail: ProjectDetail | undefined = project ? {
|
||||
project,
|
||||
units: workspace?.unit ? units.map((unit) => unit.id === workspace.unit.id ? workspace.unit : unit) : units,
|
||||
canvases: [],
|
||||
canvasUnitLinks: [],
|
||||
unitCanvasCounts: unitsQuery.data?.canvasCounts || {},
|
||||
assets: workspace?.assets || [],
|
||||
assetFolders: [],
|
||||
workflows: workspace?.workflows || [],
|
||||
shots: workspace?.shots || [],
|
||||
shotRevisions: workspace?.shotRevisions || [],
|
||||
shotArtifacts: workspace?.shotArtifacts || [],
|
||||
shotReferences: workspace?.shotReferences || [],
|
||||
assetCandidates: workspace?.assetCandidates || [],
|
||||
tasks: workspace?.tasks || [],
|
||||
} : undefined;
|
||||
const refreshProject = () => { void queryClient.invalidateQueries({ queryKey: ["project", projectId] }); void queryClient.invalidateQueries({ queryKey: ["projects"] }); };
|
||||
const createCanvas = () => {
|
||||
if (detail.data?.project.status === "archived") { message.warning("项目已归档,请先在项目设置中恢复"); return; }
|
||||
if (detail?.project.status === "archived") { message.warning("项目已归档,请先在项目设置中恢复"); return; }
|
||||
const activeChapterId = chapterId || sessionStorage.getItem(`project-active-chapter:${projectId}`) || "";
|
||||
const unit = activeView === "chapters"
|
||||
? detail.data?.units.find((item) => item.id === activeChapterId) || detail.data?.units.slice().sort((left, right) => left.position - right.position)[0]
|
||||
? detail?.units.find((item) => item.id === activeChapterId) || detail?.units.slice().sort((left, right) => left.position - right.position)[0]
|
||||
: undefined;
|
||||
const shots = unit ? detail.data?.shots.filter((shot) => shot.unitId === unit.id) || [] : [];
|
||||
const shots = unit ? detail?.shots.filter((shot) => shot.unitId === unit.id) || [] : [];
|
||||
const seed = unit && shots.length ? upsertProjectChapterStoryboard([], [], { unit, shots }) : undefined;
|
||||
const initialContent = seed ? { nodes: seed.nodes, connections: seed.connections } : undefined;
|
||||
const title = unit ? `${unit.title} · ${shots.length ? "分镜画布" : "画布"}` : `${detail.data?.project.name || "项目"} · 新画布`;
|
||||
const title = unit ? `${unit.title} · ${shots.length ? "分镜画布" : "画布"}` : `${detail?.project.name || "项目"} · 新画布`;
|
||||
void createCanvasProjectWithRemoteSync(title, projectId, initialContent).then(async ({ id, syncError }) => {
|
||||
if (syncError) {
|
||||
message.warning(syncError instanceof Error ? `画布已保存在本地,项目关联稍后重试:${syncError.message}` : "画布已保存在本地,项目关联稍后重试");
|
||||
@@ -74,11 +103,11 @@ export default function ProjectDetailPage() {
|
||||
navigate(`/canvas/${id}`);
|
||||
}).catch((error) => message.error(error instanceof Error ? error.message : "画布创建失败"));
|
||||
};
|
||||
const chapterHref = detail.data ? projectChapterHref(detail.data.units, projectId, chapterId) : `/projects/${projectId}/chapters`;
|
||||
const workflowHref = detail.data ? projectWorkflowHref(detail.data.units, projectId, unitId, stage) : `/projects/${projectId}/workflow`;
|
||||
useWorkspaceTopBarExtension(detail.data ? (
|
||||
const chapterHref = detail ? projectChapterHref(detail.units, projectId, chapterId) : `/projects/${projectId}/chapters`;
|
||||
const workflowHref = detail ? projectWorkflowHref(detail.units, projectId, unitId, stage) : `/projects/${projectId}/workflow`;
|
||||
useWorkspaceTopBarExtension(detail ? (
|
||||
<ProjectWorkspaceTopBar
|
||||
detail={detail.data}
|
||||
detail={detail}
|
||||
projectId={projectId}
|
||||
activeView={activeView}
|
||||
unitId={unitId}
|
||||
@@ -88,23 +117,25 @@ export default function ProjectDetailPage() {
|
||||
onCreateCanvas={createCanvas}
|
||||
/>
|
||||
) : null);
|
||||
if (detail.isLoading) return <WorkspacePage><WorkspaceLoadingState label="正在打开项目工作台" detail="读取章节、画布、资产和当前进度" /></WorkspacePage>;
|
||||
if (detail.isError || !detail.data) return <WorkspacePage><WorkspaceErrorState title="项目不可用" description="项目不存在、已被删除,或当前账号没有访问权限。" actionLabel="返回项目中心" onRetry={() => navigate("/projects")} /></WorkspacePage>;
|
||||
if (coreQuery.isLoading || unitsQuery.isLoading) return <WorkspacePage><WorkspaceLoadingState label="正在打开项目工作台" detail="读取项目与章节索引" /></WorkspacePage>;
|
||||
if (coreQuery.isError || unitsQuery.isError || !detail) return <WorkspacePage><WorkspaceErrorState title="项目不可用" description="项目不存在、已被删除,或当前账号没有访问权限。" actionLabel="返回项目中心" onRetry={() => navigate("/projects")} /></WorkspacePage>;
|
||||
if (!chapterId && !unitId && (!view || !views.some((item) => item.key === view))) return <Navigate to={`/projects/${projectId}/overview`} replace />;
|
||||
if (activeView === "workflow" && !unitId && detail.data.units.length) return <Navigate to={`/projects/${projectId}/workflow/${detail.data.units.slice().sort((left, right) => left.position - right.position)[0].id}/video`} replace />;
|
||||
if (chapterId && !units.some((unit) => unit.id === chapterId)) return <Navigate to={firstUnitId ? `/projects/${projectId}/chapters/${firstUnitId}` : `/projects/${projectId}/chapters`} replace />;
|
||||
if (unitId && !units.some((unit) => unit.id === unitId)) return <Navigate to={firstUnitId ? `/projects/${projectId}/workflow/${firstUnitId}/${stage || "video"}` : `/projects/${projectId}/workflow`} replace />;
|
||||
if (activeView === "workflow" && !unitId && detail.units.length) return <Navigate to={`/projects/${projectId}/workflow/${detail.units.slice().sort((left, right) => left.position - right.position)[0].id}/video`} replace />;
|
||||
return (
|
||||
<WorkspacePage className="project-workbench-page !overflow-hidden" fluid>
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
{detail.data.project.status === "archived" ? <Alert type="warning" showIcon banner message="项目已归档,恢复后才能创建画布和生成任务" className="!border-x-0 !border-t-0" /> : null}
|
||||
{detail.project.status === "archived" ? <Alert type="warning" showIcon banner message="项目已归档,恢复后才能创建画布和生成任务" className="!border-x-0 !border-t-0" /> : null}
|
||||
<main className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<div className={activeView === "chapters" || activeView === "workflow" ? "min-h-0 flex-1" : "thin-scrollbar min-h-0 flex-1 overflow-y-auto px-3 py-5 sm:px-5 lg:px-8 lg:py-7"}>
|
||||
<div className={activeView === "overview" ? "w-full" : activeView === "chapters" || activeView === "workflow" ? "h-full w-full" : "w-full"}>
|
||||
{activeView === "overview" ? <ProjectOverviewView detail={detail.data} refreshProject={refreshProject} onCreateCanvas={createCanvas} /> : null}
|
||||
{activeView === "chapters" ? <ProjectChaptersView detail={detail.data} refreshProject={refreshProject} onCreateCanvas={createCanvas} /> : null}
|
||||
{activeView === "workflow" ? <ProjectWorkflowView detail={detail.data} projectId={projectId} unitId={unitId || ""} stage={stage || "video"} /> : null}
|
||||
{activeView === "canvases" ? <ProjectCanvasesView detail={detail.data} refreshProject={refreshProject} onCreateCanvas={createCanvas} /> : null}
|
||||
{activeView === "assets" ? <ProjectAssetsView detail={detail.data} refreshProject={refreshProject} onCreateCanvas={createCanvas} /> : null}
|
||||
{activeView === "settings" ? <ProjectSettingsView detail={detail.data} refreshProject={refreshProject} onCreateCanvas={createCanvas} /> : null}
|
||||
{activeView === "overview" ? overviewQuery.isLoading ? <WorkspaceLoadingState label="正在统计制作进度" detail="只读取聚合数据,不加载全部镜头历史" /> : overviewQuery.data ? <ProjectOverviewView detail={detail} overview={overviewQuery.data} refreshProject={refreshProject} onCreateCanvas={createCanvas} /> : <WorkspaceErrorState title="制作概览读取失败" description="请稍后重试。" onRetry={() => void overviewQuery.refetch()} /> : null}
|
||||
{activeView === "chapters" ? workspaceQuery.isLoading ? <WorkspaceLoadingState label="正在读取当前章节" detail="正文与制作数据按章节加载" /> : workspaceQuery.isError ? <WorkspaceErrorState title="章节读取失败" description="当前章节可能已被删除,或服务暂时不可用。" onRetry={() => void workspaceQuery.refetch()} /> : <ProjectChaptersView detail={detail} refreshProject={refreshProject} onCreateCanvas={createCanvas} /> : null}
|
||||
{activeView === "workflow" ? workspaceQuery.isLoading ? <WorkspaceLoadingState label="正在读取当前章节分镜" detail="仅加载本章镜头、版本和产物" /> : workspaceQuery.isError ? <WorkspaceErrorState title="分镜工作区读取失败" description="当前章节制作数据暂时不可用。" onRetry={() => void workspaceQuery.refetch()} /> : <ProjectWorkflowView detail={detail} projectId={projectId} unitId={unitId || ""} stage={stage || "video"} /> : null}
|
||||
{activeView === "canvases" ? <ProjectCanvasesView detail={detail} refreshProject={refreshProject} onCreateCanvas={createCanvas} /> : null}
|
||||
{activeView === "assets" ? <ProjectAssetsView detail={detail} refreshProject={refreshProject} onCreateCanvas={createCanvas} /> : null}
|
||||
{activeView === "settings" ? <ProjectSettingsView detail={detail} refreshProject={refreshProject} onCreateCanvas={createCanvas} /> : null}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useMemo, useState, type ReactNode } from "react";
|
||||
import { useMutation, useMutationState, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
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 { WorkspaceState } from "@/components/layout/workspace-state";
|
||||
import { PaginationBar } from "@/components/layout/workspace-page";
|
||||
import { AssetMediaPreview } from "@/components/asset-media-preview";
|
||||
import { CachedResourceImage } from "@/components/cached-resource-image";
|
||||
import { AssetLibraryCard, AssetLibraryCardMedia } from "@/components/assets/asset-library-card";
|
||||
@@ -24,6 +25,9 @@ import {
|
||||
deleteProjectAssetFolder,
|
||||
getProjectCharacter,
|
||||
linkProjectAsset,
|
||||
listProjectAssetCandidates,
|
||||
listProjectAssetFolders,
|
||||
listProjectAssetsPage,
|
||||
moveProjectAsset,
|
||||
replaceProjectCharacterRepresentations,
|
||||
unbindProjectCharacterVoice,
|
||||
@@ -33,7 +37,6 @@ import {
|
||||
updateProjectCharacter,
|
||||
type ProjectAsset,
|
||||
type ProjectAssetFolder,
|
||||
type ProjectDetail,
|
||||
} from "@/services/api/projects";
|
||||
import { saveRemoteUserDataNow } from "@/services/user-data-sync";
|
||||
import { useAssetStore, type Asset, type AssetCategory, type AssetStatus, type EntityAsset, type ImageAsset } from "@/stores/use-asset-store";
|
||||
@@ -60,7 +63,6 @@ type CharacterForm = { name: string } & Record<(typeof characterFields)[number][
|
||||
|
||||
export default function ProjectAssetsView({ detail, refreshProject }: ProjectDetailViewProps) {
|
||||
const { message, modal } = App.useApp();
|
||||
const queryClient = useQueryClient();
|
||||
const personalAssets = useAssetStore((state) => state.assets);
|
||||
const addAsset = useAssetStore((state) => state.addAsset);
|
||||
const updatePersonalAsset = useAssetStore((state) => state.updateAsset);
|
||||
@@ -68,6 +70,10 @@ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDet
|
||||
const isAiConfigReady = useConfigStore((state) => state.isAiConfigReady);
|
||||
const [category, setCategory] = useState("all");
|
||||
const [folderId, setFolderId] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(40);
|
||||
const [candidatePage, setCandidatePage] = useState(1);
|
||||
const candidatePageSize = 24;
|
||||
const [folderEditor, setFolderEditor] = useState<{ folder?: ProjectAssetFolder; parentId: string } | null>(null);
|
||||
const [folderName, setFolderName] = useState("");
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
@@ -81,7 +87,53 @@ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDet
|
||||
const [voiceInstructions, setVoiceInstructions] = useState("");
|
||||
const [form] = Form.useForm<CharacterForm>();
|
||||
|
||||
const projectAssetIds = new Set(detail.assets.map((asset) => asset.id));
|
||||
const assetsQuery = useQuery({
|
||||
queryKey: ["project", detail.project.id, "assets", page, pageSize, category, folderId],
|
||||
queryFn: () => listProjectAssetsPage(detail.project.id, {
|
||||
page,
|
||||
pageSize,
|
||||
category: category === "all" ? undefined : category,
|
||||
folderId: folderId === ALL_FOLDERS ? undefined : folderId,
|
||||
}),
|
||||
});
|
||||
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" }),
|
||||
});
|
||||
const assets = assetsQuery.data?.assets || [];
|
||||
const assetFolders = foldersQuery.data?.folders || [];
|
||||
const pendingCandidates = candidatesQuery.data?.candidates || [];
|
||||
const categoryCountMap = assetsQuery.data?.categoryCounts || {};
|
||||
const folderCountMap = assetsQuery.data?.folderCounts || {};
|
||||
const totalAssetCount = Object.values(categoryCountMap).reduce((total, count) => total + count, 0);
|
||||
const characterAssetCount = categoryCountMap.character || 0;
|
||||
const mediaAssetCount = Math.max(0, totalAssetCount - characterAssetCount);
|
||||
|
||||
useEffect(() => {
|
||||
if (!assetsQuery.data) return;
|
||||
const lastPage = Math.max(1, Math.ceil(assetsQuery.data.total / pageSize));
|
||||
if (page > lastPage) setPage(lastPage);
|
||||
}, [assetsQuery.data, page, pageSize]);
|
||||
useEffect(() => {
|
||||
if (!candidatesQuery.data) return;
|
||||
const lastPage = Math.max(1, Math.ceil(candidatesQuery.data.total / candidatePageSize));
|
||||
if (candidatePage > lastPage) setCandidatePage(lastPage);
|
||||
}, [candidatePage, candidatesQuery.data]);
|
||||
|
||||
const selectFolder = (nextFolderId: string) => {
|
||||
setFolderId(nextFolderId);
|
||||
setCategory("all");
|
||||
setPage(1);
|
||||
};
|
||||
const selectCategory = (nextCategory: string) => {
|
||||
setCategory(nextCategory);
|
||||
setFolderId(ALL_FOLDERS);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const projectAssetIds = new Set(assets.map((asset) => asset.id));
|
||||
const availableAssets = personalAssets.filter((asset) => !projectAssetIds.has(asset.id));
|
||||
const imageAssets = personalAssets.filter((asset): asset is ImageAsset => asset.kind === "image");
|
||||
const availablePickerItems = useMemo<AssetLibraryPickerItem[]>(() => [
|
||||
@@ -107,16 +159,10 @@ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDet
|
||||
})),
|
||||
...externalAssetSources.items.filter((item) => item.external?.item.kind === "image"),
|
||||
], [externalAssetSources.items, imageAssets]);
|
||||
const pendingCandidates = detail.assetCandidates.filter((candidate) => candidate.category === "character" && candidate.status === "pending_confirmation");
|
||||
const characterAssets = detail.assets.filter((asset) => asset.category === "character" && asset.character);
|
||||
const mediaAssetCount = detail.assets.filter((asset) => asset.category !== "character").length;
|
||||
const assetFolders = detail.assetFolders || [];
|
||||
const characterAssets = assets.filter((asset) => asset.category === "character" && asset.character);
|
||||
const currentFolder = assetFolders.find((folder) => folder.id === folderId);
|
||||
const childFolders = folderId === ALL_FOLDERS ? [] : assetFolders.filter((folder) => (folder.parentId || "") === folderId);
|
||||
const visibleAssets = useMemo(() => detail.assets.filter((asset) => {
|
||||
if (category !== "all" && asset.category !== category) return false;
|
||||
return folderId === ALL_FOLDERS || (asset.folderId || "") === folderId;
|
||||
}), [category, detail.assets, folderId]);
|
||||
const visibleAssets = assets;
|
||||
const folderPath = useMemo(() => projectAssetFolderPath(assetFolders, folderId), [assetFolders, folderId]);
|
||||
const folderMoveItems = useMemo(() => [
|
||||
{ key: "", label: "素材库 / 根目录" },
|
||||
@@ -125,8 +171,8 @@ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDet
|
||||
const categoryCounts = categories.map((value) => ({
|
||||
value,
|
||||
count: value === "all"
|
||||
? detail.assets.length + pendingCandidates.length
|
||||
: detail.assets.filter((asset) => asset.category === value).length + (value === "character" ? pendingCandidates.length : 0),
|
||||
? totalAssetCount + (candidatesQuery.data?.total || 0)
|
||||
: (categoryCountMap[value] || 0) + (value === "character" ? candidatesQuery.data?.total || 0 : 0),
|
||||
}));
|
||||
const audioPickerItems = useMemo<AssetLibraryPickerItem[]>(() => {
|
||||
const localItems = personalAssets.flatMap((asset) => {
|
||||
@@ -135,18 +181,18 @@ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDet
|
||||
if (!resourceId) return [];
|
||||
return [{ id: asset.id, title: asset.title, category: "audio", kindLabel: `${characterVoiceFormatName(asset.data.mimeType)} 音频`, asset, description: asset.note || "素材库音频", searchText: (asset.tags || []).join(" ") }];
|
||||
});
|
||||
const projectItems = detail.assets.flatMap((asset) => {
|
||||
const projectItems = assets.flatMap((asset) => {
|
||||
if (asset.mediaType !== "audio") return [];
|
||||
const resourceId = resourceIdFromStorageKey(asset.storageKey);
|
||||
if (!resourceId || localItems.some((item) => item.id === asset.id)) return [];
|
||||
return [{ id: asset.id, title: asset.title, category: "audio", kindLabel: "音频素材", description: asset.previewText || "项目音频素材", searchText: asset.title, disabledReason: undefined, folderId: asset.folderId, imageUrl: undefined }];
|
||||
});
|
||||
return [...localItems, ...projectItems];
|
||||
}, [detail.assets, personalAssets]);
|
||||
}, [assets, personalAssets]);
|
||||
const audioResourceByItemId = useMemo(() => new Map([
|
||||
...personalAssets.flatMap((asset) => asset.kind === "audio" ? [[asset.id, resourceIdFromStorageKey(asset.data.storageKey)] as const] : []),
|
||||
...detail.assets.flatMap((asset) => asset.mediaType === "audio" ? [[asset.id, resourceIdFromStorageKey(asset.storageKey)] as const] : []),
|
||||
].filter((entry): entry is readonly [string, string] => Boolean(entry[1]))), [detail.assets, personalAssets]);
|
||||
...assets.flatMap((asset) => asset.mediaType === "audio" ? [[asset.id, resourceIdFromStorageKey(asset.storageKey)] as const] : []),
|
||||
].filter((entry): entry is readonly [string, string] => Boolean(entry[1]))), [assets, personalAssets]);
|
||||
const generatingAssets = useMutationState({
|
||||
filters: { mutationKey: ["project-character-turnaround", detail.project.id], status: "pending" },
|
||||
select: (mutation) => mutation.state.variables as ProjectAsset | undefined,
|
||||
@@ -227,12 +273,6 @@ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDet
|
||||
const confirmMutation = useMutation({
|
||||
mutationFn: ({ candidateId, targetAssetId }: { candidateId: string; targetAssetId?: string }) => confirmProjectAssetCandidate(detail.project.id, candidateId, targetAssetId),
|
||||
onSuccess: ({ asset }, variables) => {
|
||||
// 候选确认后先精确更新当前项,剩余队列不依赖整页异步刷新才能继续操作。
|
||||
queryClient.setQueryData<ProjectDetail>(["project", detail.project.id], (current) => current ? {
|
||||
...current,
|
||||
assetCandidates: current.assetCandidates.map((candidate) => candidate.id === variables.candidateId ? { ...candidate, status: "confirmed", resolvedAssetId: asset.id, updatedAt: asset.updatedAt } : candidate),
|
||||
assets: current.assets.some((item) => item.id === asset.id) ? current.assets.map((item) => item.id === asset.id ? asset : item) : [asset, ...current.assets],
|
||||
} : current);
|
||||
syncPersonalCharacterProjection(asset);
|
||||
done(variables.targetAssetId ? "候选信息已归并到角色新版本" : "角色卡已创建");
|
||||
},
|
||||
@@ -322,12 +362,12 @@ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDet
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2.5">
|
||||
<h2 className="text-[var(--fs-heading-lg)] font-semibold leading-6">角色与资产</h2>
|
||||
<span className="rounded bg-foreground/[.055] px-1.5 py-1 text-[var(--fs-tiny)] font-medium tabular-nums text-foreground/45">{detail.assets.length} 项已确认</span>
|
||||
<span className="rounded bg-foreground/[.055] px-1.5 py-1 text-[var(--fs-tiny)] font-medium tabular-nums text-foreground/45">{totalAssetCount} 项已确认</span>
|
||||
</div>
|
||||
<div className="mt-1.5 flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1 text-[var(--fs-label)] text-foreground/48">
|
||||
<span className="inline-flex items-center gap-1.5"><UserRound className="size-3.5" />{characterAssets.length} 个角色</span>
|
||||
<span className="inline-flex items-center gap-1.5"><UserRound className="size-3.5" />{characterAssetCount} 个角色</span>
|
||||
<span className="inline-flex items-center gap-1.5"><Box className="size-3.5" />{mediaAssetCount} 项媒体</span>
|
||||
{pendingCandidates.length ? <span className="inline-flex items-center gap-1.5 text-foreground/55"><Sparkles className="size-3.5" />{pendingCandidates.length} 个待确认</span> : null}
|
||||
{(candidatesQuery.data?.total || 0) ? <span className="inline-flex items-center gap-1.5 text-foreground/55"><Sparkles className="size-3.5" />{candidatesQuery.data?.total || 0} 个待确认</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -340,26 +380,26 @@ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDet
|
||||
<div className="project-assets-layout mt-3 grid gap-3">
|
||||
<nav className="space-y-0.5 pr-2" aria-label="素材目录与资产分类">
|
||||
<div className="mb-1 flex h-8 items-center justify-between px-2 text-[var(--fs-tiny)] font-medium text-foreground/42"><span>素材目录</span><button type="button" className="rounded p-1 hover:bg-surface-hover" aria-label="新建根目录文件夹" onClick={() => openFolderEditor(undefined, "")}><FolderPlus className="size-3.5" /></button></div>
|
||||
<button type="button" onClick={() => { setFolderId(""); setCategory("all"); }} className={`flex h-11 w-full items-center gap-2 rounded-md px-2 text-left text-xs ${folderId === "" ? "bg-surface-active font-medium" : "text-foreground/55 hover:bg-surface-hover"}`}><FolderOpen className="size-4 shrink-0" /><span className="min-w-0 flex-1 truncate">素材库</span><span className="text-[var(--fs-tiny)] tabular-nums text-foreground/42">{detail.assets.filter((asset) => !asset.folderId).length}</span></button>
|
||||
<ProjectAssetFolderTree folders={assetFolders} assets={detail.assets} selectedId={folderId} onSelect={(next) => { setFolderId(next); setCategory("all"); }} />
|
||||
<button type="button" onClick={() => selectFolder("")} className={`flex h-11 w-full items-center gap-2 rounded-md px-2 text-left text-xs ${folderId === "" ? "bg-surface-active font-medium" : "text-foreground/55 hover:bg-surface-hover"}`}><FolderOpen className="size-4 shrink-0" /><span className="min-w-0 flex-1 truncate">素材库</span><span className="text-[var(--fs-tiny)] tabular-nums text-foreground/42">{folderCountMap[""] || 0}</span></button>
|
||||
<ProjectAssetFolderTree folders={assetFolders} folderCounts={folderCountMap} selectedId={folderId} onSelect={selectFolder} />
|
||||
<div className="my-2 h-px bg-foreground/[.08]" />
|
||||
<div className="mb-1 h-8 px-2 text-[var(--fs-tiny)] font-medium leading-8 text-foreground/42">分类筛选</div>
|
||||
{categoryCounts.map((item) => <button key={item.value} type="button" onClick={() => { setCategory(item.value); setFolderId(ALL_FOLDERS); }} className={`flex h-11 w-full items-center justify-between rounded-md px-2 text-left text-xs ${folderId === ALL_FOLDERS && category === item.value ? "bg-surface-active font-medium" : "text-foreground/55 hover:bg-surface-hover"}`}><span>{item.value === "all" ? "全部资产" : categoryLabels[item.value]}</span><span className="min-w-5 rounded bg-foreground/[.05] px-1 text-center text-[var(--fs-tiny)] tabular-nums">{item.count}</span></button>)}
|
||||
{categoryCounts.map((item) => <button key={item.value} type="button" onClick={() => selectCategory(item.value)} className={`flex h-11 w-full items-center justify-between rounded-md px-2 text-left text-xs ${folderId === ALL_FOLDERS && category === item.value ? "bg-surface-active font-medium" : "text-foreground/55 hover:bg-surface-hover"}`}><span>{item.value === "all" ? "全部资产" : categoryLabels[item.value]}</span><span className="min-w-5 rounded bg-foreground/[.05] px-1 text-center text-[var(--fs-tiny)] tabular-nums">{item.count}</span></button>)}
|
||||
</nav>
|
||||
<div className="min-w-0">
|
||||
<div className="mb-3 flex min-h-9 flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex min-w-0 items-center gap-1 text-xs text-foreground/48">
|
||||
<button type="button" className="truncate rounded px-1.5 py-1 hover:bg-surface-hover" onClick={() => { setFolderId(""); setCategory("all"); }}>素材库</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={() => setFolderId(folder.id)}>{folder.name}</button></span>)}
|
||||
<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>
|
||||
{childFolders.length ? <div className="project-asset-folder-grid mb-5">{childFolders.map((folder) => <ProjectAssetFolderCard key={folder.id} folder={folder} folders={assetFolders} assets={detail.assets} personalAssets={personalAssets} onOpen={() => { setFolderId(folder.id); setCategory("all"); }} 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}
|
||||
{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 ? (
|
||||
<section className="mb-4" aria-label="待确认角色">
|
||||
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-1.5 text-xs font-medium"><Sparkles className="size-3.5 text-foreground/50" />剧情识别出的角色</div>
|
||||
<span className="text-[var(--fs-tiny)] tabular-nums text-foreground/42">剩余 {pendingCandidates.length} 个待确认</span>
|
||||
<span className="text-[var(--fs-tiny)] tabular-nums text-foreground/42">剩余 {candidatesQuery.data?.total || 0} 个待确认</span>
|
||||
</div>
|
||||
<div className="grid gap-2 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{pendingCandidates.map((candidate) => {
|
||||
@@ -379,10 +419,11 @@ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDet
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<PaginationBar current={candidatePage} pageSize={candidatePageSize} total={candidatesQuery.data?.total || 0} itemLabel="项" pageSizeOptions={[candidatePageSize]} onChange={(nextPage) => setCandidatePage(nextPage)} />
|
||||
</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>{visibleAssets.length} 项已确认</span></div>
|
||||
{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> : childFolders.length || (folderId === ALL_FOLDERS && (category === "all" || category === "character") && pendingCandidates.length) ? null : <WorkspaceState icon="assets" compact title="这个文件夹还没有内容" description="可以新建子文件夹、引用个人素材,或把画布产物归档到这里。" />}
|
||||
<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="可以新建子文件夹、引用个人素材,或把画布产物归档到这里。" />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -471,21 +512,22 @@ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDet
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectAssetFolderTree({ folders, assets, selectedId, onSelect }: { folders: ProjectAssetFolder[]; assets: ProjectAsset[]; selectedId: string; onSelect: (folderId: string) => void }) {
|
||||
function ProjectAssetFolderTree({ folders, folderCounts, selectedId, onSelect }: { folders: ProjectAssetFolder[]; folderCounts: Record<string, number>; selectedId: string; onSelect: (folderId: string) => void }) {
|
||||
const renderLevel = (parentId: string, depth: number, visited: ReadonlySet<string>): ReactNode => depth >= 8 ? null : folders
|
||||
.filter((folder) => (folder.parentId || "") === parentId)
|
||||
.map((folder) => {
|
||||
if (visited.has(folder.id)) return null;
|
||||
const count = assets.filter((asset) => asset.folderId === folder.id).length;
|
||||
const count = folderCounts[folder.id] || 0;
|
||||
const nextVisited = new Set(visited).add(folder.id);
|
||||
return <div key={folder.id}><button type="button" onClick={() => onSelect(folder.id)} className={`flex h-9 w-full items-center gap-1.5 rounded-md pr-2 text-left text-[var(--fs-label)] ${selectedId === folder.id ? "bg-surface-active font-medium" : "text-foreground/52 hover:bg-surface-hover"}`} style={{ paddingLeft: `calc(var(--space-2) + ${depth} * var(--space-3))` }}><FolderOpen className="size-3.5 shrink-0" /><span className="min-w-0 flex-1 truncate">{folder.name}</span><span className="text-[var(--fs-micro)] tabular-nums text-foreground/36">{count}</span></button>{renderLevel(folder.id, depth + 1, nextVisited)}</div>;
|
||||
});
|
||||
return <div>{renderLevel("", 0, new Set())}</div>;
|
||||
}
|
||||
|
||||
function ProjectAssetFolderCard({ folder, folders, assets, personalAssets, onOpen, onRename, onMove, onStyle, onTheme, onDelete, deleting }: { folder: ProjectAssetFolder; folders: ProjectAssetFolder[]; assets: ProjectAsset[]; personalAssets: Asset[]; onOpen: () => void; onRename: () => void; onMove: (parentId: string) => void; onStyle: (style: CanvasFolderStyle) => void; onTheme: (theme: CanvasFolderTheme) => void; onDelete: () => void; deleting: boolean }) {
|
||||
function ProjectAssetFolderCard({ folder, folders, assets, folderCounts, personalAssets, onOpen, onRename, onMove, onStyle, onTheme, onDelete, deleting }: { folder: ProjectAssetFolder; folders: ProjectAssetFolder[]; assets: ProjectAsset[]; folderCounts: Record<string, number>; personalAssets: Asset[]; onOpen: () => void; onRename: () => void; onMove: (parentId: string) => void; onStyle: (style: CanvasFolderStyle) => void; onTheme: (theme: CanvasFolderTheme) => void; onDelete: () => void; deleting: boolean }) {
|
||||
const directAssets = assets.filter((asset) => (asset.folderId || "") === folder.id);
|
||||
const totalCount = projectAssetFolderDescendantAssetCount(folders, assets, folder.id);
|
||||
const totalCount = projectAssetFolderDescendantAssetCount(folders, folderCounts, folder.id);
|
||||
const directCount = folderCounts[folder.id] || 0;
|
||||
const childFolderCount = folders.filter((item) => item.parentId === folder.id).length;
|
||||
const moveItems = projectAssetFolderMoveItems(folders, folder);
|
||||
const data: CanvasNodeData = {
|
||||
@@ -504,7 +546,7 @@ function ProjectAssetFolderCard({ folder, folders, assets, personalAssets, onOpe
|
||||
return <article className="project-asset-folder-card" aria-label={`${folder.name} 文件夹,共 ${totalCount} 项`}>
|
||||
<button type="button" className="project-asset-folder-open" aria-label={`打开文件夹 ${folder.name}`} onClick={onOpen} />
|
||||
<div className="project-asset-folder-visual"><CanvasFolderPreview data={data} childNodes={childNodes} active={false} isDropTarget={false} readOnly onToggleCollapsed={onOpen} onTitleChange={() => undefined} onStyleChange={() => undefined} onThemeChange={() => undefined} /></div>
|
||||
<div className="project-asset-folder-card-footer"><span>{directAssets.length} 项内容{childFolderCount ? ` · ${childFolderCount} 个子文件夹` : ""}</span><Dropdown trigger={["click"]} menu={{ selectedKeys: [`style:${folder.style}`, `theme:${resolveCanvasFolderTheme(folder.theme)}`], items: [{ key: "rename", label: "重命名", icon: <Pencil className="size-3.5" /> }, { key: "move", label: "移动到", icon: <MoveRight className="size-3.5" />, children: moveItems }, { key: "style", label: "切换样式", children: [{ key: "style:glass", label: "流光玻璃" }, { key: "style:stacked", label: "内容陈列" }, { key: "style:midnight", label: "午夜封面" }, { key: "style:paper", label: "纸感收藏" }, { key: "style:cinema", label: "电影胶片" }, { key: "style:compact", label: "紧凑资料" }] }, { key: "theme", label: "切换主题", children: CANVAS_FOLDER_THEME_OPTIONS.map((item) => ({ key: `theme:${item.key}`, label: item.label })) }, { key: "delete", label: "删除空文件夹", icon: <Trash2 className="size-3.5" />, danger: true }], onClick: ({ key, domEvent }) => { domEvent.stopPropagation(); if (key === "rename") onRename(); else if (key.startsWith("move:")) onMove(key === "move:root" ? "" : key.slice(5)); else if (key.startsWith("style:")) onStyle(key.slice(6) as CanvasFolderStyle); else if (key.startsWith("theme:")) onTheme(key.slice(6) as CanvasFolderTheme); else if (key === "delete") onDelete(); } }}><button type="button" className="project-asset-folder-menu" disabled={deleting} aria-label={`${folder.name} 文件夹操作`} onClick={(event) => event.stopPropagation()}><MoreHorizontal className="size-4" /></button></Dropdown></div>
|
||||
<div className="project-asset-folder-card-footer"><span>{directCount} 项内容{childFolderCount ? ` · ${childFolderCount} 个子文件夹` : ""}</span><Dropdown trigger={["click"]} menu={{ selectedKeys: [`style:${folder.style}`, `theme:${resolveCanvasFolderTheme(folder.theme)}`], items: [{ key: "rename", label: "重命名", icon: <Pencil className="size-3.5" /> }, { key: "move", label: "移动到", icon: <MoveRight className="size-3.5" />, children: moveItems }, { key: "style", label: "切换样式", children: [{ key: "style:glass", label: "流光玻璃" }, { key: "style:stacked", label: "内容陈列" }, { key: "style:midnight", label: "午夜封面" }, { key: "style:paper", label: "纸感收藏" }, { key: "style:cinema", label: "电影胶片" }, { key: "style:compact", label: "紧凑资料" }] }, { key: "theme", label: "切换主题", children: CANVAS_FOLDER_THEME_OPTIONS.map((item) => ({ key: `theme:${item.key}`, label: item.label })) }, { key: "delete", label: "删除空文件夹", icon: <Trash2 className="size-3.5" />, danger: true }], onClick: ({ key, domEvent }) => { domEvent.stopPropagation(); if (key === "rename") onRename(); else if (key.startsWith("move:")) onMove(key === "move:root" ? "" : key.slice(5)); else if (key.startsWith("style:")) onStyle(key.slice(6) as CanvasFolderStyle); else if (key.startsWith("theme:")) onTheme(key.slice(6) as CanvasFolderTheme); else if (key === "delete") onDelete(); } }}><button type="button" className="project-asset-folder-menu" disabled={deleting} aria-label={`${folder.name} 文件夹操作`} onClick={(event) => event.stopPropagation()}><MoreHorizontal className="size-4" /></button></Dropdown></div>
|
||||
</article>;
|
||||
}
|
||||
|
||||
@@ -580,7 +622,7 @@ function projectAssetFolderMoveItems(folders: ProjectAssetFolder[], movingFolder
|
||||
];
|
||||
}
|
||||
|
||||
function projectAssetFolderDescendantAssetCount(folders: ProjectAssetFolder[], assets: ProjectAsset[], folderId: string) {
|
||||
function projectAssetFolderDescendantAssetCount(folders: ProjectAssetFolder[], folderCounts: Record<string, number>, folderId: string) {
|
||||
const descendantIds = new Set([folderId]);
|
||||
let added = true;
|
||||
while (added) {
|
||||
@@ -591,7 +633,7 @@ function projectAssetFolderDescendantAssetCount(folders: ProjectAssetFolder[], a
|
||||
added = true;
|
||||
}
|
||||
}
|
||||
return assets.filter((asset) => asset.folderId && descendantIds.has(asset.folderId)).length;
|
||||
return Array.from(descendantIds).reduce((total, id) => total + (folderCounts[id] || 0), 0);
|
||||
}
|
||||
|
||||
function CharacterEditorModal({ open, editing, form, loading, onClose, onSave }: { open: boolean; editing: boolean; form: FormInstance<CharacterForm>; loading: boolean; onClose: () => void; onSave: () => void }) {
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { App, Button, Popconfirm, Select, Tooltip } from "antd";
|
||||
import { Link2, Unlink, X } from "lucide-react";
|
||||
|
||||
import { CanvasProjectCard } from "@/components/canvas/canvas-project-card";
|
||||
import { PaginationBar } from "@/components/layout/workspace-page";
|
||||
import { WorkspaceState } from "@/components/layout/workspace-state";
|
||||
import { linkCanvasUnit, unlinkCanvasProject, unlinkCanvasUnit } from "@/services/api/projects";
|
||||
import { linkCanvasUnit, listProjectCanvases, unlinkCanvasProject, unlinkCanvasUnit } from "@/services/api/projects";
|
||||
import { useCanvasStore, type CanvasProject } from "@/stores/canvas/use-canvas-store";
|
||||
|
||||
import { type ProjectDetailViewProps } from "./shared";
|
||||
@@ -13,7 +14,18 @@ import { type ProjectDetailViewProps } from "./shared";
|
||||
export default function ProjectCanvasesView({ detail, refreshProject }: ProjectDetailViewProps) {
|
||||
const { message } = App.useApp();
|
||||
const [linkingCanvasId, setLinkingCanvasId] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(40);
|
||||
const localCanvases = useCanvasStore((state) => state.projects);
|
||||
const canvasesQuery = useQuery({
|
||||
queryKey: ["project", detail.project.id, "canvases", page, pageSize],
|
||||
queryFn: () => listProjectCanvases(detail.project.id, page, pageSize),
|
||||
});
|
||||
useEffect(() => {
|
||||
if (!canvasesQuery.data) return;
|
||||
const lastPage = Math.max(1, Math.ceil(canvasesQuery.data.total / pageSize));
|
||||
if (page > lastPage) setPage(lastPage);
|
||||
}, [canvasesQuery.data, page, pageSize]);
|
||||
const linkMutation = useMutation({
|
||||
mutationFn: ({ canvasId, unitId }: { canvasId: string; unitId: string }) => linkCanvasUnit(detail.project.id, { canvasId, unitId, role: "storyboard" }),
|
||||
onSuccess: () => { setLinkingCanvasId(""); refreshProject(); message.success("画布已关联章节"); },
|
||||
@@ -34,16 +46,18 @@ export default function ProjectCanvasesView({ detail, refreshProject }: ProjectD
|
||||
},
|
||||
onError: (error) => message.error(error instanceof Error ? error.message : "解除项目关系失败"),
|
||||
});
|
||||
const linksByCanvas = useMemo(() => detail.canvasUnitLinks.reduce<Record<string, typeof detail.canvasUnitLinks>>((result, link) => { (result[link.canvasId] ||= []).push(link); return result; }, {}), [detail.canvasUnitLinks]);
|
||||
const canvases = useMemo(() => detail.canvases.map((canvas) => {
|
||||
const canvasUnitLinks = canvasesQuery.data?.canvasUnitLinks || [];
|
||||
const linksByCanvas = useMemo(() => canvasUnitLinks.reduce<Record<string, typeof canvasUnitLinks>>((result, link) => { (result[link.canvasId] ||= []).push(link); return result; }, {}), [canvasUnitLinks]);
|
||||
const canvases = useMemo(() => (canvasesQuery.data?.canvases || []).map((canvas) => {
|
||||
const local = localCanvases.find((item) => item.id === canvas.id && item.projectId === detail.project.id);
|
||||
if (!local || Date.parse(local.updatedAt) < Date.parse(canvas.updatedAt)) return canvas;
|
||||
return { ...canvas, title: local.title, updatedAt: local.updatedAt };
|
||||
}).sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)), [detail.canvases, detail.project.id, localCanvases]);
|
||||
}).sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)), [canvasesQuery.data?.canvases, detail.project.id, localCanvases]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{canvases.length ? (
|
||||
{canvasesQuery.isLoading ? <WorkspaceState icon="canvas" title="正在读取项目画布" description="按页加载画布摘要与章节关联。" /> : canvases.length ? (
|
||||
<>
|
||||
<div className="project-library-grid library-grid">
|
||||
{canvases.map((canvas) => {
|
||||
const links = linksByCanvas[canvas.id] || [];
|
||||
@@ -76,6 +90,8 @@ export default function ProjectCanvasesView({ detail, refreshProject }: ProjectD
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<PaginationBar current={page} pageSize={pageSize} total={canvasesQuery.data?.total || 0} itemLabel="张" pageSizeOptions={[20, 40, 80]} onChange={(nextPage, nextPageSize) => { setPage(nextPageSize !== pageSize ? 1 : nextPage); setPageSize(nextPageSize); }} />
|
||||
</>
|
||||
) : <WorkspaceState icon="canvas" title="还没有项目画布" description="使用右上角的新建画布开始创作。" />}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -49,7 +49,7 @@ import { useNavigate, useParams } from "react-router";
|
||||
|
||||
import { SkillRuntimePicker, useSkillRuntimeCatalog } from "@/components/skills/skill-runtime-picker";
|
||||
import { ModelPicker } from "@/components/model-picker";
|
||||
import { WorkspaceErrorState, WorkspaceState } from "@/components/layout/workspace-state";
|
||||
import { WorkspaceState } from "@/components/layout/workspace-state";
|
||||
import { resolveProjectCanvasStyle } from "@/components/canvas/canvas-style-picker-modal";
|
||||
import { normalizeCharacterName } from "@/lib/canvas/canvas-character-reference";
|
||||
import { decodeNovelText, splitTextIntoChapters } from "@/lib/canvas/canvas-document";
|
||||
@@ -58,7 +58,6 @@ import {
|
||||
createProjectAssetCandidates,
|
||||
createProjectUnit,
|
||||
deleteProjectUnit,
|
||||
getProjectUnit,
|
||||
importProjectUnits,
|
||||
replaceProjectUnitShots,
|
||||
reorderProjectUnits,
|
||||
@@ -78,6 +77,11 @@ const MAX_NOVEL_IMPORT_CHAPTERS = 2500;
|
||||
type ChapterOperationKind = "characters" | "storyboard";
|
||||
type ChapterOperation = { startedAt: number; taskId?: string };
|
||||
|
||||
function formatChapterListCount(value: number) {
|
||||
if (value < 10_000) return formatCount(value);
|
||||
return new Intl.NumberFormat("zh-CN", { notation: "compact", maximumFractionDigits: 1 }).format(value);
|
||||
}
|
||||
|
||||
export default function ProjectChaptersView({ detail, refreshProject }: ProjectDetailViewProps) {
|
||||
const { message, modal } = App.useApp();
|
||||
const navigate = useNavigate();
|
||||
@@ -115,11 +119,6 @@ export default function ProjectChaptersView({ detail, refreshProject }: ProjectD
|
||||
return orderedIds.map((id) => byId.get(id)).filter((unit): unit is ProjectUnit => Boolean(unit));
|
||||
}, [detail.units, orderedIds]);
|
||||
const selectedUnitSummary = detail.units.find((unit) => unit.id === selectedId) || orderedUnits[0];
|
||||
const selectedUnitQuery = useQuery({
|
||||
queryKey: ["project-unit", detail.project.id, selectedId],
|
||||
queryFn: () => getProjectUnit(detail.project.id, selectedId),
|
||||
enabled: Boolean(selectedId),
|
||||
});
|
||||
const chapterTasksQuery = useQuery({
|
||||
queryKey: ["project-chapter-generation-tasks", detail.project.id],
|
||||
queryFn: () => listGenerationTasks(100, { projectId: detail.project.id }),
|
||||
@@ -129,9 +128,9 @@ export default function ProjectChaptersView({ detail, refreshProject }: ProjectD
|
||||
}) ? 2_000 : false,
|
||||
refetchOnWindowFocus: true,
|
||||
});
|
||||
const selectedUnit = selectedUnitQuery.data?.unit || selectedUnitSummary;
|
||||
const selectedUnit = selectedUnitSummary;
|
||||
const chapterNumberById = useMemo(() => new Map(orderedUnits.map((unit, index) => [unit.id, index + 1])), [orderedUnits]);
|
||||
const canvasCountByUnitId = useMemo(() => detail.canvasUnitLinks.reduce<Map<string, number>>((result, link) => result.set(link.unitId, (result.get(link.unitId) || 0) + 1), new Map()), [detail.canvasUnitLinks]);
|
||||
const canvasCountByUnitId = useMemo(() => new Map(Object.entries(detail.unitCanvasCounts || {}).map(([unitId, count]) => [unitId, Number(count)])), [detail.unitCanvasCounts]);
|
||||
const storyboardImpact = useMemo(() => chapterStoryboardReplaceImpact(detail, selectedUnit?.id || ""), [detail, selectedUnit?.id]);
|
||||
const serverChapterOperations = useMemo(() => {
|
||||
const operations = new Map<string, ChapterOperation>();
|
||||
@@ -204,14 +203,29 @@ export default function ProjectChaptersView({ detail, refreshProject }: ProjectD
|
||||
}, [runningOperationCount]);
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: () => selectedUnitQuery.data?.unit
|
||||
? updateProjectUnit(detail.project.id, selectedUnitQuery.data.unit.id, {
|
||||
mutationFn: () => selectedUnit
|
||||
? updateProjectUnit(detail.project.id, selectedUnit.id, {
|
||||
title: draftTitle.trim(),
|
||||
sourceText: draftHtml,
|
||||
status: stripHtml(draftHtml) ? "ready" : "draft",
|
||||
})
|
||||
: Promise.reject(new Error("请选择章节")),
|
||||
onSuccess: ({ unit }) => { queryClient.setQueryData(["project-unit", detail.project.id, unit.id], { unit }); setDirty(false); refreshProject(); message.success("章节已保存"); },
|
||||
onSuccess: ({ unit }) => {
|
||||
queryClient.setQueryData(["project-unit", detail.project.id, unit.id], { unit });
|
||||
queryClient.setQueryData<{ units: ProjectDetail["units"]; canvasCounts: Record<string, number> }>(["project", detail.project.id, "units"], (current) => current ? {
|
||||
...current,
|
||||
units: current.units.map((item) => item.id === unit.id ? {
|
||||
...item,
|
||||
title: unit.title,
|
||||
wordCount: unit.wordCount,
|
||||
status: unit.status,
|
||||
updatedAt: unit.updatedAt,
|
||||
} : item),
|
||||
} : current);
|
||||
setDirty(false);
|
||||
refreshProject();
|
||||
message.success("章节已保存");
|
||||
},
|
||||
onError: (error) => message.error(error instanceof Error ? error.message : "章节保存失败"),
|
||||
});
|
||||
const createMutation = useMutation({
|
||||
@@ -257,7 +271,7 @@ export default function ProjectChaptersView({ detail, refreshProject }: ProjectD
|
||||
CharacterCount,
|
||||
Placeholder.configure({ placeholder: "从这一章开始写下故事……" }),
|
||||
],
|
||||
content: selectedUnitQuery.data?.unit.sourceText || "",
|
||||
content: selectedUnit?.sourceText || "",
|
||||
editorProps: { attributes: { class: "project-chapter-editor focus:outline-none" } },
|
||||
onUpdate: ({ editor: nextEditor }) => { setDraftHtml(nextEditor.getHTML()); setDirty(true); },
|
||||
});
|
||||
@@ -270,19 +284,19 @@ export default function ProjectChaptersView({ detail, refreshProject }: ProjectD
|
||||
}, [selectedUnitSummary?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
const loadedUnit = selectedUnitQuery.data?.unit;
|
||||
const loadedUnit = selectedUnit;
|
||||
if (!loadedUnit || !editor) return;
|
||||
setDraftTitle(loadedUnit.title);
|
||||
setDraftHtml(loadedUnit.sourceText || "");
|
||||
setDirty(false);
|
||||
editor.commands.setContent(loadedUnit.sourceText || "", { emitUpdate: false });
|
||||
// 只在切换章节时装载服务端内容,避免项目刷新覆盖当前未保存正文。
|
||||
}, [editor, selectedUnitQuery.data?.unit]);
|
||||
}, [editor, selectedUnit?.id]);
|
||||
|
||||
const wordCount = useMemo(() => editor?.storage.characterCount?.characters?.() || stripHtml(draftHtml).length || 0, [draftHtml, editor]);
|
||||
const chapterCanvasCount = (unitId: string) => canvasCountByUnitId.get(unitId) || 0;
|
||||
const chapterAnalysisInput = (textModel: string) => {
|
||||
const unit = selectedUnitQuery.data?.unit;
|
||||
const unit = selectedUnit;
|
||||
if (!unit) throw new Error("章节正文尚未加载完成");
|
||||
if (dirty) throw new Error("请先保存当前章节,再运行 AI 分析");
|
||||
const sourceText = editor?.getText().trim() || stripHtml(unit.sourceText);
|
||||
@@ -378,7 +392,7 @@ export default function ProjectChaptersView({ detail, refreshProject }: ProjectD
|
||||
}
|
||||
};
|
||||
const createStoryboard = async () => {
|
||||
const unit = selectedUnitQuery.data?.unit;
|
||||
const unit = selectedUnit;
|
||||
if (!unit) return message.warning("章节正文尚未加载完成");
|
||||
if (dirty) return message.warning("请先保存当前章节,再生成分镜");
|
||||
if (detail.project.status === "archived") return message.warning("项目已归档,请先在项目设置中恢复");
|
||||
@@ -522,7 +536,7 @@ export default function ProjectChaptersView({ detail, refreshProject }: ProjectD
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid h-full min-h-0 min-w-0 w-full grid-rows-[minmax(180px,34vh)_minmax(0,1fr)] overflow-hidden lg:grid-cols-[232px_minmax(0,1fr)] lg:grid-rows-1">
|
||||
<div className="grid h-full min-h-0 min-w-0 w-full grid-rows-[minmax(180px,34vh)_minmax(0,1fr)] overflow-hidden lg:grid-cols-[280px_minmax(0,1fr)] lg:grid-rows-1">
|
||||
<aside className="flex min-h-0 min-w-0 w-full flex-col border-b border-border/70 bg-background/28 lg:border-b-0 lg:border-r">
|
||||
<div className="flex h-11 shrink-0 items-center justify-between border-b border-border/70 px-2.5">
|
||||
<div className="text-xs font-medium text-foreground/55">章节 <span className="ml-1 tabular-nums text-foreground/35">{detail.units.length.toLocaleString("zh-CN")}</span></div>
|
||||
@@ -547,19 +561,21 @@ export default function ProjectChaptersView({ detail, refreshProject }: ProjectD
|
||||
const unit = visibleUnits[virtualItem.index];
|
||||
if (!unit) return null;
|
||||
const chapterNumber = chapterNumberById.get(unit.id) || virtualItem.index + 1;
|
||||
const displayedWordCount = dirty && selectedUnit?.id === unit.id ? wordCount : unit.wordCount || 0;
|
||||
const chapterMeta = `${statusLabel(unit.status)} · ${formatCount(displayedWordCount)} 字 · ${chapterCanvasCount(unit.id)} 画布`;
|
||||
return (
|
||||
<div key={unit.id} className="absolute left-0 top-0 w-full" style={{ height: virtualItem.size, transform: `translateY(${virtualItem.start}px)` }}>
|
||||
<div draggable={!dirty && !reorderMutation.isPending && !deferredSearchQuery} onDragStart={(event) => { setDraggedId(unit.id); event.dataTransfer.effectAllowed = "move"; }} onDragOver={(event) => { if (deferredSearchQuery) return; event.preventDefault(); event.dataTransfer.dropEffect = "move"; }} onDrop={() => moveChapter(unit.id)} onDragEnd={() => setDraggedId("")} className={`group flex h-[58px] items-start rounded-lg border px-0.5 transition-colors duration-100 ${unit.id === selectedUnit?.id ? "border-border/90 bg-surface-active" : "border-transparent hover:border-border/60 hover:bg-surface-hover"} ${draggedId === unit.id ? "opacity-45" : ""}`}>
|
||||
<div draggable={!dirty && !reorderMutation.isPending && !deferredSearchQuery} onDragStart={(event) => { setDraggedId(unit.id); event.dataTransfer.effectAllowed = "move"; }} onDragOver={(event) => { if (deferredSearchQuery) return; event.preventDefault(); event.dataTransfer.dropEffect = "move"; }} onDrop={() => moveChapter(unit.id)} onDragEnd={() => setDraggedId("")} className={`group relative flex h-[58px] items-start rounded-lg border px-0.5 transition-colors duration-100 ${unit.id === selectedUnit?.id ? "border-border/90 bg-surface-active" : "border-transparent hover:border-border/60 hover:bg-surface-hover"} ${draggedId === unit.id ? "opacity-45" : ""}`}>
|
||||
<button type="button" disabled={Boolean(deferredSearchQuery)} className="mt-3.5 grid size-6 shrink-0 cursor-grab place-items-center text-foreground/22 active:cursor-grabbing disabled:cursor-default disabled:opacity-35" aria-label={`拖动第 ${chapterNumber} 章排序`}><GripVertical className="size-3.5" /></button>
|
||||
<button type="button" onClick={() => selectChapter(unit.id)} className="flex min-w-0 flex-1 items-center gap-2.5 py-2 pr-1 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--workspace-accent)]">
|
||||
<button type="button" onClick={() => selectChapter(unit.id)} className="flex min-w-0 flex-1 items-center gap-2.5 py-2 pr-14 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--workspace-accent)]">
|
||||
<span className={`grid h-8 min-w-8 shrink-0 place-items-center rounded-md px-1 text-[var(--fs-tiny)] font-semibold tabular-nums ${unit.id === selectedUnit?.id ? "bg-[var(--workspace-accent-soft)] text-[var(--workspace-accent)]" : "bg-foreground/[.035] text-foreground/35"}`}>{String(chapterNumber).padStart(Math.max(2, String(orderedUnits.length).length), "0")}</span>
|
||||
<span className="min-w-0 flex-1"><span className={`block truncate text-[var(--fs-body)] ${unit.id === selectedUnit?.id ? "font-semibold text-foreground" : "font-medium text-foreground/68"}`}>{unit.title}</span><span className="mt-1 flex items-center gap-1.5 text-[var(--fs-tiny)] text-foreground/38"><span>{statusLabel(unit.status)}</span><span>·</span><span className="tabular-nums">{formatCount(unit.wordCount || 0)} 字</span><span>·</span><span className="tabular-nums">{chapterCanvasCount(unit.id)} 画布</span></span></span>
|
||||
<span className="min-w-0 flex-1 overflow-hidden"><span className={`block truncate text-[var(--fs-body)] ${unit.id === selectedUnit?.id ? "font-semibold text-foreground" : "font-medium text-foreground/68"}`}>{unit.title}</span><span title={chapterMeta} className="mt-1 block truncate whitespace-nowrap text-[var(--fs-tiny)] tabular-nums text-foreground/38">{statusLabel(unit.status)} · {formatChapterListCount(displayedWordCount)} 字 · {chapterCanvasCount(unit.id)} 画布</span></span>
|
||||
</button>
|
||||
<Dropdown trigger={["click"]} placement="bottomRight" menu={{ items: [{ key: "move", icon: <MoveVertical className="size-3.5" />, label: "移动到…" }], onClick: () => { setMoveTargetId(unit.id); setMovePosition(chapterNumber); } }}>
|
||||
<button type="button" className="mt-3.5 grid size-6 shrink-0 place-items-center rounded text-foreground/28 opacity-0 hover:bg-surface-hover hover:text-foreground group-hover:opacity-100 focus-visible:opacity-100" aria-label={`${unit.title}更多操作`}><MoreHorizontal className="size-3.5" /></button>
|
||||
<button type="button" className="absolute right-7 top-1/2 z-[1] grid size-6 -translate-y-1/2 place-items-center rounded text-foreground/28 opacity-0 hover:bg-surface-hover hover:text-foreground group-hover:opacity-100 focus-visible:opacity-100" aria-label={`${unit.title}更多操作`}><MoreHorizontal className="size-3.5" /></button>
|
||||
</Dropdown>
|
||||
<Popconfirm title="删除此章节?" description="章节内容及关联制作记录将被删除,已关联的画布不会删除。" okText="删除" cancelText="取消" okButtonProps={{ danger: true, loading: deleteMutation.isPending }} onConfirm={() => deleteMutation.mutate(unit.id)}>
|
||||
<button type="button" className="mr-1 mt-3.5 grid size-6 shrink-0 place-items-center rounded text-foreground/28 opacity-0 hover:bg-red-500/10 hover:text-red-500 group-hover:opacity-100 focus-visible:opacity-100" aria-label={`删除${unit.title}`}><Trash2 className="size-3.5" /></button>
|
||||
<button type="button" className="absolute right-1 top-1/2 z-[1] grid size-6 -translate-y-1/2 place-items-center rounded text-foreground/28 opacity-0 hover:bg-red-500/10 hover:text-red-500 group-hover:opacity-100 focus-visible:opacity-100" aria-label={`删除${unit.title}`}><Trash2 className="size-3.5" /></button>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
</div>
|
||||
@@ -576,18 +592,18 @@ export default function ProjectChaptersView({ detail, refreshProject }: ProjectD
|
||||
<header className="flex shrink-0 flex-wrap items-start gap-3 border-b border-border/70 px-4 py-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="mb-1 text-[var(--fs-tiny)] font-medium tabular-nums text-foreground/38">第 {String(orderedUnits.findIndex((unit) => unit.id === selectedUnit.id) + 1).padStart(2, "0")} 章</div>
|
||||
<Input variant="borderless" value={draftTitle} disabled={!selectedUnitQuery.data?.unit} onChange={(event) => { setDraftTitle(event.target.value); setDirty(true); }} className="!h-auto !px-0 !py-0 !text-xl !font-semibold !leading-tight disabled:!cursor-wait disabled:!text-foreground" placeholder="章节标题" />
|
||||
<Input variant="borderless" value={draftTitle} disabled={!selectedUnit} onChange={(event) => { setDraftTitle(event.target.value); setDirty(true); }} className="!h-auto !px-0 !py-0 !text-xl !font-semibold !leading-tight disabled:!cursor-wait disabled:!text-foreground" placeholder="章节标题" />
|
||||
<div className="mt-1.5 flex flex-wrap items-center gap-1.5 text-[var(--fs-tiny)] text-foreground/38"><span>{dirty ? "有未保存修改" : `保存于 ${formatTime(selectedUnit.updatedAt)}`}</span><span>·</span><span>{formatCount(wordCount)} 字</span><span>·</span><span>{chapterCanvasCount(selectedUnit.id)} 个画布</span></div>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-wrap items-center justify-end gap-1.5">
|
||||
<Button size="small" icon={<UsersRound className="size-3.5" />} disabled={!selectedUnitQuery.data?.unit || dirty || Boolean(characterOperation)} loading={Boolean(characterOperation)} onClick={() => { setSelectedTextModel(effectiveConfig.textModel || effectiveConfig.model || effectiveConfig.textModels[0] || ""); setCharacterExtractOpen(true); }} aria-label={characterOperation ? `提取角色,已运行 ${formatOperationElapsed(characterOperation.startedAt, operationNow)}` : "提取角色"}>{characterOperation ? `提取角色(已运行${formatOperationElapsed(characterOperation.startedAt, operationNow)})` : charactersGenerated ? "提取角色(已生成)" : "提取角色"}</Button>
|
||||
<Button size="small" type="primary" icon={<Clapperboard className="size-3.5" />} disabled={!selectedUnitQuery.data?.unit || dirty || Boolean(storyboardOperation)} loading={Boolean(storyboardOperation)} onClick={() => { setSelectedTextModel(effectiveConfig.textModel || effectiveConfig.model || effectiveConfig.textModels[0] || ""); setSelectedSkillIds([]); setStoryboardOpen(true); }} aria-label={storyboardOperation ? `生成到分镜制作,已运行 ${formatOperationElapsed(storyboardOperation.startedAt, operationNow)}` : "生成到分镜制作"}>{storyboardOperation ? `生成到分镜制作(已运行${formatOperationElapsed(storyboardOperation.startedAt, operationNow)})` : storyboardGenerated ? "生成到分镜制作(已生成)" : "生成到分镜制作"}</Button>
|
||||
<Button size="small" type={dirty ? "primary" : "default"} icon={dirty ? <Save className="size-3.5" /> : <Check className="size-3.5" />} disabled={!selectedUnitQuery.data?.unit || !dirty || !draftTitle.trim() || saveMutation.isPending} loading={saveMutation.isPending} onClick={() => saveMutation.mutate()}>{dirty ? "保存" : "已保存"}</Button>
|
||||
<Button size="small" icon={<UsersRound className="size-3.5" />} disabled={!selectedUnit || dirty || Boolean(characterOperation)} loading={Boolean(characterOperation)} onClick={() => { setSelectedTextModel(effectiveConfig.textModel || effectiveConfig.model || effectiveConfig.textModels[0] || ""); setCharacterExtractOpen(true); }} aria-label={characterOperation ? `提取角色,已运行 ${formatOperationElapsed(characterOperation.startedAt, operationNow)}` : "提取角色"}>{characterOperation ? `提取角色(已运行${formatOperationElapsed(characterOperation.startedAt, operationNow)})` : charactersGenerated ? "提取角色(已生成)" : "提取角色"}</Button>
|
||||
<Button size="small" type="primary" icon={<Clapperboard className="size-3.5" />} disabled={!selectedUnit || dirty || Boolean(storyboardOperation)} loading={Boolean(storyboardOperation)} onClick={() => { setSelectedTextModel(effectiveConfig.textModel || effectiveConfig.model || effectiveConfig.textModels[0] || ""); setSelectedSkillIds([]); setStoryboardOpen(true); }} aria-label={storyboardOperation ? `生成到分镜制作,已运行 ${formatOperationElapsed(storyboardOperation.startedAt, operationNow)}` : "生成到分镜制作"}>{storyboardOperation ? `生成到分镜制作(已运行${formatOperationElapsed(storyboardOperation.startedAt, operationNow)})` : storyboardGenerated ? "生成到分镜制作(已生成)" : "生成到分镜制作"}</Button>
|
||||
<Button size="small" type={dirty ? "primary" : "default"} icon={dirty ? <Save className="size-3.5" /> : <Check className="size-3.5" />} disabled={!selectedUnit || !dirty || !draftTitle.trim() || saveMutation.isPending} loading={saveMutation.isPending} onClick={() => saveMutation.mutate()}>{dirty ? "保存" : "已保存"}</Button>
|
||||
</div>
|
||||
</header>
|
||||
<EditorToolbar editor={editor} />
|
||||
<div className="project-chapter-editor-scroll thin-scrollbar min-h-0 flex-1 overflow-y-auto bg-foreground/[.012]">
|
||||
{selectedUnitQuery.isLoading ? <WorkspaceState icon="loading" compact className="h-full" title="正在读取章节正文" description="正文准备完成后会自动显示。" /> : selectedUnitQuery.isError ? <WorkspaceErrorState compact title="章节正文读取失败" description={selectedUnitQuery.error instanceof Error ? selectedUnitQuery.error.message : "请检查网络连接后重试。"} onRetry={() => void selectedUnitQuery.refetch()} /> : <div className="project-chapter-editor-wrap min-h-full"><EditorContent editor={editor} /></div>}
|
||||
<div className="project-chapter-editor-wrap min-h-full"><EditorContent editor={editor} /></div>
|
||||
</div>
|
||||
</div>
|
||||
) : <WorkspaceState icon="projects" compact className="h-full" title="请选择章节" description="从左侧章节列表选择一章开始编辑。" />}
|
||||
|
||||
@@ -2,39 +2,42 @@ import { ArrowRight, BookOpenText, CheckCircle2, CircleAlert, Clapperboard, Cloc
|
||||
import { Link } from "react-router";
|
||||
|
||||
import { WorkspaceState } from "@/components/layout/workspace-state";
|
||||
import { projectAttentionCount, projectContinueTarget, projectDetailStage, projectNextActions, projectUnitStages, type ProjectStageCell, type ProjectWorkbenchAction } from "@/lib/project-workbench";
|
||||
import type { ProjectStageCell, ProjectWorkbenchAction } from "@/lib/project-workbench";
|
||||
import type { ProjectOverview, ProjectOverviewMetrics } from "@/services/api/projects";
|
||||
|
||||
import { formatTime, type ProjectDetailViewProps } from "./shared";
|
||||
|
||||
export default function ProjectOverviewView({ detail }: ProjectDetailViewProps) {
|
||||
const { project, units, canvases, shots } = detail;
|
||||
const completedUnits = units.filter((unit) => unit.status === "completed").length;
|
||||
const attentionCount = projectAttentionCount(detail);
|
||||
const completion = units.length ? Math.round((completedUnits / units.length) * 100) : 0;
|
||||
const stage = projectDetailStage(detail);
|
||||
const actions = projectNextActions(detail, 3);
|
||||
export default function ProjectOverviewView({ detail, overview }: ProjectDetailViewProps & { overview: ProjectOverview }) {
|
||||
const { project } = detail;
|
||||
const metrics = overview.metrics;
|
||||
const completedUnits = metrics.completedUnitCount;
|
||||
const attentionCount = metrics.pendingCandidateCount + metrics.staleArtifactCount;
|
||||
const completion = metrics.unitCount ? Math.round((completedUnits / metrics.unitCount) * 100) : 0;
|
||||
const firstUnitId = detail.units.slice().sort((left, right) => left.position - right.position)[0]?.id || overview.units[0]?.unit.id;
|
||||
const workflowHref = (targetStage: string) => firstUnitId ? `/projects/${project.id}/workflow/${firstUnitId}/${targetStage}` : `/projects/${project.id}/chapters`;
|
||||
const stage = overviewStage(metrics);
|
||||
const actions = overviewActions(project.id, metrics, firstUnitId).slice(0, 3);
|
||||
const primaryAction = actions[0];
|
||||
const secondaryActions = actions.slice(1);
|
||||
const continueTarget = projectContinueTarget(detail);
|
||||
const unitStages = projectUnitStages(detail);
|
||||
const firstUnitId = units.slice().sort((left, right) => left.position - right.position)[0]?.id;
|
||||
const workflowHref = (targetStage: string) => firstUnitId ? `/projects/${project.id}/workflow/${firstUnitId}/${targetStage}` : `/projects/${project.id}/chapters`;
|
||||
const readyArtifacts = detail.shotArtifacts.filter((artifact) => artifact.status === "ready");
|
||||
const readyStoryboardCount = new Set(readyArtifacts.filter((artifact) => artifact.type === "storyboard").map((artifact) => artifact.shotId)).size;
|
||||
const readyPrevizCount = new Set(readyArtifacts.filter((artifact) => artifact.type === "action_board").map((artifact) => artifact.shotId)).size;
|
||||
const readyVideoCount = new Set(readyArtifacts.filter((artifact) => artifact.type === "video").map((artifact) => artifact.shotId)).size;
|
||||
const unitStages = overview.units.map((item) => ({
|
||||
unit: item.unit,
|
||||
content: stageCell(item.unit.wordCount > 0, item.unit.wordCount > 0 ? `${formatCompactCount(item.unit.wordCount)} 字` : "待补充"),
|
||||
assets: stageCell(item.candidateCount === 0 && item.shotCount > 0, item.candidateCount ? `${item.candidateCount} 待确认` : item.shotCount ? "已确认" : "待拆分", item.candidateCount > 0),
|
||||
storyboard: stageCell(item.shotCount > 0, item.shotCount ? `${item.shotCount} 镜` : "待生成"),
|
||||
canvas: stageCell(item.canvasCount > 0, item.canvasCount ? `${item.canvasCount} 张` : "未关联"),
|
||||
}));
|
||||
const productionSteps = [
|
||||
{ id: "story", icon: BookOpenText, label: "剧情章节", description: "导入或编写正文,确认每章叙事目标", metric: `${units.length} 章 · ${formatCompactCount(units.reduce((total, unit) => total + (unit.wordCount || 0), 0))} 字`, href: `/projects/${project.id}/chapters`, complete: units.length > 0 },
|
||||
{ id: "assets", icon: UsersRound, label: "角色与资产", description: "确认角色、场景、道具和项目画风", metric: `${detail.assets.length} 项资产`, href: `/projects/${project.id}/assets`, complete: detail.assets.length > 0 },
|
||||
{ id: "storyboard", icon: Clapperboard, label: "分镜脚本与画面", description: "拆分镜头并确认构图、对白和时长", metric: `${shots.length} 镜 · ${readyStoryboardCount} 张图`, href: workflowHref("storyboard"), complete: shots.length > 0 },
|
||||
{ id: "previz", icon: PlaySquare, label: "动作预演", description: "检查表演节拍、运镜和连续性", metric: `${readyPrevizCount}/${shots.length || 0} 镜`, href: workflowHref("previz"), complete: shots.length > 0 && readyPrevizCount === shots.length },
|
||||
{ id: "video", icon: Film, label: "镜头视频", description: "逐镜生成、筛选版本并锁定成片", metric: `${readyVideoCount}/${shots.length || 0} 镜`, href: workflowHref("video"), complete: shots.length > 0 && readyVideoCount === shots.length },
|
||||
{ id: "delivery", icon: PackageCheck, label: "交付与打包", description: "检查缺失镜头并整理最终产物", metric: readyVideoCount && readyVideoCount === shots.length ? "可以交付" : `还差 ${Math.max(0, shots.length - readyVideoCount)} 镜`, href: workflowHref("delivery"), complete: shots.length > 0 && readyVideoCount === shots.length },
|
||||
{ id: "story", icon: BookOpenText, label: "剧情章节", description: "导入或编写正文,确认每章叙事目标", metric: `${metrics.unitCount} 章 · ${formatCompactCount(metrics.totalWordCount)} 字`, href: `/projects/${project.id}/chapters`, complete: metrics.unitCount > 0 },
|
||||
{ id: "assets", icon: UsersRound, label: "角色与资产", description: "确认角色、场景、道具和项目画风", metric: `${metrics.assetCount} 项资产`, href: `/projects/${project.id}/assets`, complete: metrics.assetCount > 0 },
|
||||
{ id: "storyboard", icon: Clapperboard, label: "分镜脚本与画面", description: "拆分镜头并确认构图、对白和时长", metric: `${metrics.shotCount} 镜 · ${metrics.readyStoryboardCount} 张图`, href: workflowHref("storyboard"), complete: metrics.shotCount > 0 },
|
||||
{ id: "previz", icon: PlaySquare, label: "动作预演", description: "检查表演节拍、运镜和连续性", metric: `${metrics.readyPrevizCount}/${metrics.shotCount || 0} 镜`, href: workflowHref("previz"), complete: metrics.shotCount > 0 && metrics.readyPrevizCount === metrics.shotCount },
|
||||
{ id: "video", icon: Film, label: "镜头视频", description: "逐镜生成、筛选版本并锁定成片", metric: `${metrics.readyVideoCount}/${metrics.shotCount || 0} 镜`, href: workflowHref("video"), complete: metrics.shotCount > 0 && metrics.readyVideoCount === metrics.shotCount },
|
||||
{ id: "delivery", icon: PackageCheck, label: "交付与打包", description: "检查缺失镜头并整理最终产物", metric: metrics.readyVideoCount && metrics.readyVideoCount === metrics.shotCount ? "可以交付" : `还差 ${Math.max(0, metrics.shotCount - metrics.readyVideoCount)} 镜`, href: workflowHref("delivery"), complete: metrics.shotCount > 0 && metrics.readyVideoCount === metrics.shotCount },
|
||||
];
|
||||
const gaps = [
|
||||
units.length === 0 ? "还没有剧情章节" : units.some((unit) => !unit.wordCount) ? `${units.filter((unit) => !unit.wordCount).length} 章还没有正文` : "章节正文已就绪",
|
||||
detail.assetCandidates.some((candidate) => candidate.status === "pending_confirmation") ? `${detail.assetCandidates.filter((candidate) => candidate.status === "pending_confirmation").length} 项资产等待确认` : detail.assets.length ? "项目资产已建立" : "还没有角色与资产",
|
||||
shots.length ? readyVideoCount === shots.length ? "所有镜头视频已生成" : `${shots.length - readyVideoCount} 个镜头尚未生成视频` : "还没有分镜镜头",
|
||||
metrics.unitCount === 0 ? "还没有剧情章节" : metrics.unitsWithoutText ? `${metrics.unitsWithoutText} 章还没有正文` : "章节正文已就绪",
|
||||
metrics.pendingCandidateCount ? `${metrics.pendingCandidateCount} 项资产等待确认` : metrics.assetCount ? "项目资产已建立" : "还没有角色与资产",
|
||||
metrics.shotCount ? metrics.readyVideoCount === metrics.shotCount ? "所有镜头视频已生成" : `${metrics.shotCount - metrics.readyVideoCount} 个镜头尚未生成视频` : "还没有分镜镜头",
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -56,7 +59,7 @@ export default function ProjectOverviewView({ detail }: ProjectDetailViewProps)
|
||||
<Link to={primaryAction.href} className="project-overview-cta-primary">
|
||||
<span className="truncate">{primaryAction.actionLabel}</span><ArrowRight className="size-4 shrink-0" />
|
||||
</Link>
|
||||
{continueTarget.href !== primaryAction.href ? <Link to={continueTarget.href} className="project-overview-cta-secondary">继续最近工作<ArrowRight className="size-3.5" /></Link> : null}
|
||||
{secondaryActions[0] ? <Link to={secondaryActions[0].href} className="project-overview-cta-secondary">继续下一步<ArrowRight className="size-3.5" /></Link> : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -66,13 +69,13 @@ export default function ProjectOverviewView({ detail }: ProjectDetailViewProps)
|
||||
<span className="project-overview-status-label">章节进度</span>
|
||||
<span className="project-overview-progress-percent">{completion}%</span>
|
||||
</div>
|
||||
<div className="project-overview-progress-count">{completedUnits}<span>/ {units.length}</span></div>
|
||||
<div className="project-overview-progress-count">{completedUnits}<span>/ {metrics.unitCount}</span></div>
|
||||
<div className="project-overview-progress-track" aria-label={`章节完成度 ${completion}%`}><div style={{ width: `${completion}%` }} /></div>
|
||||
</div>
|
||||
<dl className="project-overview-facts">
|
||||
<ProjectFact label="当前阶段" value={stage.label} />
|
||||
<ProjectFact label="分镜镜头" value={`${shots.length} 个`} />
|
||||
<ProjectFact label="项目画布" value={`${canvases.length} 张`} />
|
||||
<ProjectFact label="分镜镜头" value={`${metrics.shotCount} 个`} />
|
||||
<ProjectFact label="项目画布" value={`${metrics.canvasCount} 张`} />
|
||||
<ProjectFact label="需要处理" value={`${attentionCount} 项`} attention={attentionCount > 0} />
|
||||
</dl>
|
||||
{secondaryActions.length ? (
|
||||
@@ -90,7 +93,7 @@ export default function ProjectOverviewView({ detail }: ProjectDetailViewProps)
|
||||
<div className="project-standard-flow-track">
|
||||
{productionSteps.map((step, index) => { const Icon = step.icon; return <Link key={step.id} to={step.href} className={step.complete ? "is-complete" : ""}><span className="project-standard-flow-index">{step.complete ? <CheckCircle2 /> : index + 1}</span><span className="project-standard-flow-icon"><Icon /></span><strong>{step.label}</strong><p>{step.description}</p><em>{step.metric}</em><ArrowRight className="project-standard-flow-arrow" /></Link>; })}
|
||||
</div>
|
||||
<div className="project-standard-flow-footer"><div><strong>当前制作检查</strong>{gaps.map((gap, index) => <span key={gap}><i className={index === 2 && readyVideoCount !== shots.length ? "is-attention" : ""} />{gap}</span>)}</div><div><strong>快速入口</strong><Link to={`/projects/${project.id}/chapters`}>整理章节</Link><Link to={`/projects/${project.id}/assets`}>确认资产</Link><Link to={workflowHref("video")}>继续镜头制作</Link></div></div>
|
||||
<div className="project-standard-flow-footer"><div><strong>当前制作检查</strong>{gaps.map((gap, index) => <span key={gap}><i className={index === 2 && metrics.readyVideoCount !== metrics.shotCount ? "is-attention" : ""} />{gap}</span>)}</div><div><strong>快速入口</strong><Link to={`/projects/${project.id}/chapters`}>整理章节</Link><Link to={`/projects/${project.id}/assets`}>确认资产</Link><Link to={workflowHref("video")}>继续镜头制作</Link></div></div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
@@ -152,3 +155,43 @@ function StageStep({ label, cell }: { label: string; cell: ProjectStageCell }) {
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function overviewStage(metrics: ProjectOverviewMetrics) {
|
||||
if (!metrics.unitCount) return { label: "准备故事", detail: "添加或导入剧情章节" };
|
||||
if (metrics.pendingCandidateCount) return { label: "资产确认", detail: `${metrics.pendingCandidateCount} 项待确认` };
|
||||
if (!metrics.shotCount || metrics.unitsWithoutShots) return { label: "分镜准备", detail: `${metrics.unitsWithoutShots || metrics.unitCount} 章待生成分镜` };
|
||||
if (metrics.readyVideoCount < metrics.shotCount) return { label: "镜头制作", detail: `${metrics.readyVideoCount}/${metrics.shotCount} 镜视频就绪` };
|
||||
return { label: "检查交付", detail: "镜头视频已准备完成" };
|
||||
}
|
||||
|
||||
function overviewActions(projectId: string, metrics: ProjectOverviewMetrics, firstUnitId?: string): ProjectWorkbenchAction[] {
|
||||
const projectRoot = `/projects/${projectId}`;
|
||||
const workflowHref = firstUnitId ? `${projectRoot}/workflow/${firstUnitId}/video` : `${projectRoot}/chapters`;
|
||||
if (!metrics.unitCount) {
|
||||
return [{ id: "add-story", title: "添加第一个剧情章节", description: "导入小说、粘贴文本,或从空白章节开始。", href: `${projectRoot}/chapters`, actionLabel: "添加章节", tone: "default" }];
|
||||
}
|
||||
const actions: ProjectWorkbenchAction[] = [];
|
||||
if (metrics.unitsWithoutText) {
|
||||
actions.push({ id: "complete-story", title: `补充 ${metrics.unitsWithoutText} 章正文`, description: "先完善章节内容,后续角色识别与分镜拆分才能获得稳定输入。", href: `${projectRoot}/chapters`, actionLabel: "整理章节", tone: "attention" });
|
||||
}
|
||||
if (metrics.pendingCandidateCount) {
|
||||
actions.push({ id: "confirm-assets", title: `确认 ${metrics.pendingCandidateCount} 个资产候选`, description: "确认角色、场景与道具后,镜头可以稳定引用项目资产。", href: `${projectRoot}/assets`, actionLabel: "去确认", tone: "attention" });
|
||||
}
|
||||
if (!metrics.shotCount || metrics.unitsWithoutShots) {
|
||||
actions.push({ id: "create-storyboards", title: `为 ${metrics.unitsWithoutShots || metrics.unitCount} 章建立分镜`, description: "按章节生成镜头草稿,再逐镜调整画面、对白和时长。", href: firstUnitId ? `${projectRoot}/chapters/${firstUnitId}` : `${projectRoot}/chapters`, actionLabel: "建立分镜", tone: "default" });
|
||||
}
|
||||
if (metrics.shotCount && metrics.readyVideoCount < metrics.shotCount) {
|
||||
actions.push({ id: "continue-video", title: `继续制作 ${metrics.shotCount - metrics.readyVideoCount} 个镜头视频`, description: "检查镜头提示词与参考资产,逐镜生成并选择最终版本。", href: workflowHref, actionLabel: "继续制作", tone: metrics.staleArtifactCount ? "attention" : "default" });
|
||||
}
|
||||
if (!metrics.canvasCount) {
|
||||
actions.push({ id: "create-canvas", title: "建立第一张项目画布", description: "把章节、分镜和参考资产放进同一个制作空间。", href: `${projectRoot}/canvases`, actionLabel: "查看画布", tone: "default" });
|
||||
}
|
||||
if (!actions.length) {
|
||||
actions.push({ id: "review-delivery", title: "检查镜头并准备交付", description: "所有镜头视频已就绪,可检查版本、连续性和缺失项。", href: workflowHref, actionLabel: "检查交付", tone: "default" });
|
||||
}
|
||||
return actions;
|
||||
}
|
||||
|
||||
function stageCell(complete: boolean, label: string, attention = false): ProjectStageCell {
|
||||
return { label, state: attention ? "attention" : complete ? "completed" : "idle" };
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { App, Button, Input, Modal, Select } from "antd";
|
||||
import { Archive, Check, Eye, FolderOpen, Image as ImageIcon, Palette, Pencil, Save, ShieldAlert, Trash2 } from "lucide-react";
|
||||
|
||||
@@ -7,7 +7,7 @@ import { AssetLibraryPickerModal, type AssetLibraryPickerItem } from "@/componen
|
||||
import { CanvasStyleDetailModal, CanvasStylePickerModal, resolveProjectCanvasStyle, type CanvasStylePreset } from "@/components/canvas/canvas-style-picker-modal";
|
||||
import { createStyleProfileSnapshot, parseStyleProfile, resolveStyleExecutionPlan, serializeStyleProfile } from "@/lib/canvas/style-profile";
|
||||
import { resourceFileUrl, resourceIdFromStorageKey } from "@/services/api/resources";
|
||||
import { updateProject } from "@/services/api/projects";
|
||||
import { listProjectAssetsPage, updateProject } from "@/services/api/projects";
|
||||
import { uploadImage } from "@/services/image-storage";
|
||||
import { useAssetStore } from "@/stores/use-asset-store";
|
||||
import { resolveModelRequestConfig, useEffectiveConfig } from "@/stores/use-config-store";
|
||||
@@ -31,6 +31,14 @@ export default function ProjectSettingsView({ detail, refreshProject }: ProjectD
|
||||
const [styleEditorRequested, setStyleEditorRequested] = useState(false);
|
||||
const [archiveOpen, setArchiveOpen] = useState(false);
|
||||
const [coverPickerOpen, setCoverPickerOpen] = useState(false);
|
||||
const [coverPage, setCoverPage] = useState(1);
|
||||
const [coverPageSize, setCoverPageSize] = useState(40);
|
||||
const coverAssetsQuery = useQuery({
|
||||
queryKey: ["project", project.id, "assets", "cover-picker", coverPage, coverPageSize],
|
||||
queryFn: () => listProjectAssetsPage(project.id, { page: coverPage, pageSize: coverPageSize, mediaType: "image" }),
|
||||
enabled: coverPickerOpen,
|
||||
});
|
||||
const projectCoverAssets = coverAssetsQuery.data?.assets || [];
|
||||
useEffect(() => { setName(project.name); setDescription(project.description || ""); setAspectRatio(project.aspectRatio); setSourceType(project.sourceType); setStylePresetId(project.stylePresetId || ""); setStyleProfileJson(project.styleProfileJson || ""); }, [project]);
|
||||
const dirty = useMemo(() => name.trim() !== project.name || description !== (project.description || "") || aspectRatio !== project.aspectRatio || sourceType !== project.sourceType || stylePresetId !== (project.stylePresetId || "") || styleProfileJson !== (project.styleProfileJson || ""), [aspectRatio, description, name, project, sourceType, stylePresetId, styleProfileJson]);
|
||||
const selectedStyle = useMemo(() => resolveProjectCanvasStyle(stylePresetId, styleProfileJson), [stylePresetId, styleProfileJson]);
|
||||
@@ -55,7 +63,7 @@ export default function ProjectSettingsView({ detail, refreshProject }: ProjectD
|
||||
seenResourceIds.add(resourceId);
|
||||
result.push({ id: asset.id, title: asset.title, category: "image", kindLabel: "图片", asset, description: asset.note || "个人素材库", searchText: (asset.tags || []).join(" ") });
|
||||
}
|
||||
for (const asset of detail.assets) {
|
||||
for (const asset of projectCoverAssets) {
|
||||
if (asset.mediaType !== "image") continue;
|
||||
const resourceId = resourceIdFromStorageKey(asset.storageKey);
|
||||
if (!resourceId || seenResourceIds.has(resourceId)) continue;
|
||||
@@ -66,18 +74,22 @@ export default function ProjectSettingsView({ detail, refreshProject }: ProjectD
|
||||
result.unshift({ id: `current:${project.coverResourceId}`, title: "当前项目主图", category: "image", kindLabel: "当前主图", imageUrl: resourceFileUrl(project.coverResourceId) });
|
||||
}
|
||||
return result;
|
||||
}, [detail.assets, personalAssets, project.coverResourceId]);
|
||||
}, [personalAssets, project.coverResourceId, projectCoverAssets]);
|
||||
const coverResourceByItemId = useMemo(() => new Map(coverPickerItems.flatMap((item) => {
|
||||
if (item.id.startsWith("current:")) return [[item.id, item.id.slice("current:".length)] as const];
|
||||
if (item.id.startsWith("project:")) {
|
||||
const asset = detail.assets.find((entry) => `project:${entry.id}` === item.id);
|
||||
const asset = projectCoverAssets.find((entry) => `project:${entry.id}` === item.id);
|
||||
const resourceId = resourceIdFromStorageKey(asset?.storageKey);
|
||||
return resourceId ? [[item.id, resourceId] as const] : [];
|
||||
}
|
||||
const asset = personalAssets.find((entry) => entry.id === item.id);
|
||||
const resourceId = asset?.kind === "image" ? resourceIdFromStorageKey(asset.data.storageKey) : "";
|
||||
return resourceId ? [[item.id, resourceId] as const] : [];
|
||||
})), [coverPickerItems, detail.assets, personalAssets]);
|
||||
})), [coverPickerItems, personalAssets, projectCoverAssets]);
|
||||
const coverResourceByItemIdRef = useRef(new Map<string, string>());
|
||||
useEffect(() => {
|
||||
for (const [itemId, resourceId] of coverResourceByItemId) coverResourceByItemIdRef.current.set(itemId, resourceId);
|
||||
}, [coverResourceByItemId]);
|
||||
const currentCoverItemId = coverPickerItems.find((item) => coverResourceByItemId.get(item.id) === project.coverResourceId)?.id;
|
||||
const saveMutation = useMutation({ mutationFn: () => updateProject(project.id, { name: name.trim(), description, aspectRatio, sourceType, stylePresetId, styleProfileJson }), onSuccess: () => { refreshProject(); message.success("项目设置已保存"); }, onError: (error) => message.error(error instanceof Error ? error.message : "项目设置保存失败") });
|
||||
const archiveMutation = useMutation({ mutationFn: () => updateProject(project.id, { status: project.status === "archived" ? "active" : "archived" }), onSuccess: () => { setArchiveOpen(false); refreshProject(); message.success(project.status === "archived" ? "项目已恢复" : "项目已归档"); }, onError: (error) => message.error(error instanceof Error ? error.message : "项目状态更新失败") });
|
||||
@@ -102,7 +114,7 @@ export default function ProjectSettingsView({ detail, refreshProject }: ProjectD
|
||||
<div className="flex flex-col gap-3 rounded-lg border border-border/70 bg-surface-active p-3 sm:flex-row sm:items-center">
|
||||
{project.coverResourceId ? <img src={resourceFileUrl(project.coverResourceId)} alt={`${project.name}项目主图`} className="aspect-video w-full shrink-0 rounded-md bg-foreground/5 object-cover sm:w-52" /> : <span className="grid aspect-video w-full shrink-0 place-items-center rounded-md bg-foreground/5 text-foreground/30 sm:w-52"><ImageIcon className="size-6" /></span>}
|
||||
<div className="min-w-0 flex-1"><div className="text-sm font-medium">{project.coverResourceId ? "已设置项目主图" : "尚未设置项目主图"}</div><p className="mt-1 text-xs leading-5 text-foreground/48">从个人素材库或项目素材库选择,也可以在选择窗口中上传一张新图片。</p></div>
|
||||
<div className="flex shrink-0 gap-2"><Button icon={<FolderOpen className="size-3.5" />} onClick={() => setCoverPickerOpen(true)}>{project.coverResourceId ? "替换主图" : "设置主图"}</Button>{project.coverResourceId ? <Button danger type="text" icon={<Trash2 className="size-3.5" />} loading={coverMutation.isPending} onClick={() => coverMutation.mutate("")}>移除</Button> : null}</div>
|
||||
<div className="flex shrink-0 gap-2"><Button icon={<FolderOpen className="size-3.5" />} onClick={() => { setCoverPage(1); setCoverPickerOpen(true); }}>{project.coverResourceId ? "替换主图" : "设置主图"}</Button>{project.coverResourceId ? <Button danger type="text" icon={<Trash2 className="size-3.5" />} loading={coverMutation.isPending} onClick={() => coverMutation.mutate("")}>移除</Button> : null}</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -141,6 +153,8 @@ export default function ProjectSettingsView({ detail, refreshProject }: ProjectD
|
||||
confirmLabel={() => "设为项目主图"}
|
||||
emptyTitle="素材库还没有图片"
|
||||
emptyDescription="可以从底部上传一张新图片,上传后会自动选中。"
|
||||
loading={coverAssetsQuery.isLoading}
|
||||
pagination={{ current: coverPage, pageSize: coverPageSize, total: coverAssetsQuery.data?.total || 0, onChange: (nextPage, nextPageSize) => { setCoverPage(nextPageSize !== coverPageSize ? 1 : nextPage); setCoverPageSize(nextPageSize); } }}
|
||||
upload={{ accept: "image/png,image/jpeg,image/webp,image/avif", description: "支持 PNG、JPG、WebP、AVIF", onUpload: async (files) => {
|
||||
const file = Array.from(files)[0];
|
||||
if (!file?.type.startsWith("image/")) throw new Error("请选择图片文件");
|
||||
@@ -153,7 +167,7 @@ export default function ProjectSettingsView({ detail, refreshProject }: ProjectD
|
||||
onClose={() => setCoverPickerOpen(false)}
|
||||
onConfirm={async (ids) => {
|
||||
const id = ids[0];
|
||||
let resourceId = coverResourceByItemId.get(id);
|
||||
let resourceId = coverResourceByItemIdRef.current.get(id);
|
||||
if (!resourceId) {
|
||||
const asset = useAssetStore.getState().assets.find((entry) => entry.id === id);
|
||||
resourceId = asset?.kind === "image" ? resourceIdFromStorageKey(asset.data.storageKey) : "";
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useDeferredValue, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { useDeferredValue, useEffect, useMemo, useState } from "react";
|
||||
import { Input, Modal } from "antd";
|
||||
import { BookOpenText, ChevronDown, ChevronLeft, ChevronRight, Search } from "lucide-react";
|
||||
import { useNavigate } from "react-router";
|
||||
|
||||
import { PaginationBar } from "@/components/layout/workspace-page";
|
||||
import type { ProjectUnit } from "@/services/api/projects";
|
||||
|
||||
type Props = {
|
||||
@@ -17,29 +17,30 @@ export function WorkflowChapterNavigator({ projectId, units, unitId, stage }: Pr
|
||||
const navigate = useNavigate();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(100);
|
||||
const deferredQuery = useDeferredValue(query.trim().toLocaleLowerCase("zh-CN"));
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const orderedUnits = useMemo(() => units.slice().sort((left, right) => left.position - right.position), [units]);
|
||||
const currentIndex = Math.max(0, orderedUnits.findIndex((unit) => unit.id === unitId));
|
||||
const current = orderedUnits[currentIndex];
|
||||
const visibleUnits = useMemo(() => {
|
||||
const filteredUnits = useMemo(() => {
|
||||
if (!deferredQuery) return orderedUnits;
|
||||
const numericQuery = /^\d+$/.test(deferredQuery) ? deferredQuery.replace(/^0+/, "") || "0" : "";
|
||||
return orderedUnits.filter((unit, index) => (numericQuery && String(index + 1).startsWith(numericQuery)) || unit.title.toLocaleLowerCase("zh-CN").includes(deferredQuery));
|
||||
}, [deferredQuery, orderedUnits]);
|
||||
const virtualizer = useVirtualizer({
|
||||
count: visibleUnits.length,
|
||||
getScrollElement: () => listRef.current,
|
||||
estimateSize: () => 48,
|
||||
getItemKey: (index) => visibleUnits[index]?.id || index,
|
||||
overscan: 10,
|
||||
});
|
||||
const pageCount = Math.max(1, Math.ceil(filteredUnits.length / pageSize));
|
||||
const safePage = Math.min(page, pageCount);
|
||||
const pagedUnits = filteredUnits.slice((safePage - 1) * pageSize, safePage * pageSize);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || deferredQuery) return;
|
||||
const frame = window.requestAnimationFrame(() => virtualizer.scrollToIndex(currentIndex, { align: "center" }));
|
||||
return () => window.cancelAnimationFrame(frame);
|
||||
}, [currentIndex, deferredQuery, open, virtualizer]);
|
||||
if (page > pageCount) setPage(pageCount);
|
||||
}, [page, pageCount]);
|
||||
|
||||
const openPicker = () => {
|
||||
setQuery("");
|
||||
setPage(Math.floor(currentIndex / pageSize) + 1);
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const goTo = (target?: ProjectUnit) => {
|
||||
if (!target) return;
|
||||
@@ -52,20 +53,29 @@ export function WorkflowChapterNavigator({ projectId, units, unitId, stage }: Pr
|
||||
<>
|
||||
<div className="workflow-chapter-navigator">
|
||||
<button type="button" disabled={currentIndex === 0} onClick={() => goTo(orderedUnits[currentIndex - 1])} aria-label="上一章" title="上一章"><ChevronLeft /></button>
|
||||
<button type="button" className="workflow-chapter-current" onClick={() => { setQuery(""); setOpen(true); }} aria-haspopup="dialog" aria-label={`定位章节,当前第 ${currentIndex + 1} 章`}>
|
||||
<button type="button" className="workflow-chapter-current" onClick={openPicker} aria-haspopup="dialog" aria-label={`定位章节,当前第 ${currentIndex + 1} 章`}>
|
||||
<span>第 {currentIndex + 1} 章</span><strong>{current.title}</strong><em>{currentIndex + 1}/{orderedUnits.length}</em><ChevronDown />
|
||||
</button>
|
||||
<button type="button" disabled={currentIndex >= orderedUnits.length - 1} onClick={() => goTo(orderedUnits[currentIndex + 1])} aria-label="下一章" title="下一章"><ChevronRight /></button>
|
||||
</div>
|
||||
<Modal open={open} footer={null} title={null} width={560} destroyOnHidden className="workspace-modal workflow-chapter-modal" onCancel={() => setOpen(false)} styles={{ body: { padding: 0 } }}>
|
||||
<div className="workflow-chapter-modal-head"><span><BookOpenText /><strong>定位章节</strong><em>共 {orderedUnits.length.toLocaleString("zh-CN")} 章</em></span><Input autoFocus allowClear prefix={<Search className="size-3.5" />} value={query} onChange={(event) => setQuery(event.target.value)} placeholder="输入章节序号或标题" /></div>
|
||||
<div ref={listRef} className="workflow-chapter-list thin-scrollbar">
|
||||
{visibleUnits.length ? <div className="relative" style={{ height: virtualizer.getTotalSize() }}>{virtualizer.getVirtualItems().map((virtualItem) => {
|
||||
const unit = visibleUnits[virtualItem.index];
|
||||
<Modal open={open} footer={null} title={null} destroyOnHidden className="workspace-modal workspace-modal-wide workflow-chapter-modal" onCancel={() => setOpen(false)} styles={{ body: { padding: 0 } }}>
|
||||
<div className="workflow-chapter-modal-shell">
|
||||
<header className="workflow-chapter-modal-head">
|
||||
<div className="workflow-chapter-modal-title"><span><BookOpenText /></span><div><strong>定位章节</strong><p>快速搜索并跳转到需要制作的剧情章节</p></div></div>
|
||||
<div className="workflow-chapter-modal-current"><span>当前</span><strong>第 {currentIndex + 1} 章</strong></div>
|
||||
</header>
|
||||
<div className="workflow-chapter-modal-search">
|
||||
<Input autoFocus allowClear size="large" prefix={<Search className="size-4" />} value={query} onChange={(event) => { setQuery(event.target.value); setPage(1); }} placeholder="输入章节序号或标题" />
|
||||
<span>{deferredQuery ? `找到 ${filteredUnits.length.toLocaleString("zh-CN")} 章` : `共 ${orderedUnits.length.toLocaleString("zh-CN")} 章`}</span>
|
||||
</div>
|
||||
<div className="workflow-chapter-list thin-scrollbar">
|
||||
{pagedUnits.length ? <div className="workflow-chapter-grid">{pagedUnits.map((unit) => {
|
||||
const index = orderedUnits.findIndex((item) => item.id === unit.id);
|
||||
const selected = unit.id === current.id;
|
||||
return <button key={unit.id} type="button" className={selected ? "is-active" : ""} style={{ height: virtualItem.size, transform: `translateY(${virtualItem.start}px)` }} onClick={() => goTo(unit)}><span>{String(index + 1).padStart(Math.max(2, String(orderedUnits.length).length), "0")}</span><strong>{unit.title}</strong><em>{(unit.wordCount || 0).toLocaleString("zh-CN")} 字</em>{selected ? <small>当前</small> : null}</button>;
|
||||
})}</div> : <div className="workflow-chapter-empty">没有匹配的章节</div>}
|
||||
return <button key={unit.id} type="button" className={`workflow-chapter-item${selected ? " is-active" : ""}`} onClick={() => goTo(unit)}><span className="workflow-chapter-item-index">{String(index + 1).padStart(Math.max(2, String(orderedUnits.length).length), "0")}</span><span className="workflow-chapter-item-copy"><strong title={unit.title}>{unit.title}</strong><small>第 {index + 1} 章 · {(unit.wordCount || 0).toLocaleString("zh-CN")} 字</small></span>{selected ? <em>当前</em> : <ChevronRight />}</button>;
|
||||
})}</div> : <div className="workflow-chapter-empty"><Search /><strong>没有匹配的章节</strong><span>换一个章节序号或标题试试</span></div>}
|
||||
</div>
|
||||
<footer className="workflow-chapter-modal-pagination"><PaginationBar alwaysShow current={safePage} pageSize={pageSize} total={filteredUnits.length} pageSizeOptions={[100, 200, 500]} itemLabel="章" onChange={(nextPage, nextPageSize) => { setPage(nextPage); setPageSize(nextPageSize); }} /></footer>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
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 { Link, useNavigate } from "react-router";
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
createUnitWorkflow,
|
||||
deleteProjectShot,
|
||||
linkShotAsset,
|
||||
listProjectAssetsPage,
|
||||
registerProjectTaskOutput,
|
||||
saveProjectShot,
|
||||
unlinkShotAsset,
|
||||
@@ -433,13 +434,22 @@ 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 [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 }),
|
||||
});
|
||||
const assetsPage = assetsQuery.data?.assets || [];
|
||||
const groups = useMemo(() => {
|
||||
const map = new Map<string, ProjectAsset[]>();
|
||||
detail.assets.forEach((asset) => map.set(asset.category || "other", [...(map.get(asset.category || "other") || []), asset]));
|
||||
assetsPage.forEach((asset) => map.set(asset.category || "other", [...(map.get(asset.category || "other") || []), asset]));
|
||||
return Array.from(map.entries());
|
||||
}, [detail.assets]);
|
||||
if (!groups.length) return <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="项目还没有资产" />;
|
||||
return <div className="workflow-asset-groups">{groups.map(([category, assets]) => <section key={category}><h3>{assetCategoryLabel(category)} <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>)}</div>;
|
||||
}, [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>;
|
||||
}
|
||||
|
||||
function ShotAssetMentionTextarea({ value = "", onChange = () => undefined, references, variant = "motion" }: { value?: string; onChange?: (value: string) => void; references: ReturnType<typeof buildShotAssetReferenceContext>["mentionReferences"]; variant?: "scene" | "motion" }) {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Button } from "antd";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Clock3, Film, Layers3, PackageCheck } from "lucide-react";
|
||||
import { Link } from "react-router";
|
||||
|
||||
import type { ProjectDetail } from "@/services/api/projects";
|
||||
import { listProjectAssetsPage, type ProjectDetail } from "@/services/api/projects";
|
||||
|
||||
import { assetCategoryLabel, formatDuration, MetricCard, StageHeading } from "./workflow-shared";
|
||||
|
||||
@@ -13,8 +14,10 @@ export function StoryStage({ detail, projectId, unitId }: { detail: ProjectDetai
|
||||
|
||||
export function AssetsStage({ detail, projectId, unitId }: { detail: ProjectDetail; projectId: string; unitId: string }) {
|
||||
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 = detail.assets.filter((item) => item.category === category).length; 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">{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">{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 }) {
|
||||
|
||||
Reference in New Issue
Block a user