feat(assets): 素材库与画布 - 新增可交互通用文件夹
This commit is contained in:
@@ -1,33 +1,40 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMemo, useState, type ReactNode } from "react";
|
||||
import { useMutation, useMutationState, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { App, Button, Dropdown, Form, Input, Modal, Popconfirm, Select, Tabs, type FormInstance } from "antd";
|
||||
import { Box, Check, ChevronDown, Download, FileText, Image as ImageIcon, Link2, Music2, Plus, RefreshCw, Sparkles, Trash2, UserRound, Video, VolumeX } from "lucide-react";
|
||||
import { Box, Check, ChevronDown, Download, FileText, FolderOpen, FolderPlus, Image as ImageIcon, Link2, MoreHorizontal, MoveRight, Music2, Pencil, Plus, RefreshCw, Sparkles, Trash2, UserRound, Video, VolumeX } from "lucide-react";
|
||||
|
||||
import { WorkspaceState } from "@/components/layout/workspace-state";
|
||||
import { AssetMediaPreview } from "@/components/asset-media-preview";
|
||||
import { AssetLibraryCard, AssetLibraryCardMedia } from "@/components/assets/asset-library-card";
|
||||
import { AssetLibraryPickerModal, type AssetLibraryPickerItem } from "@/components/assets/asset-library-picker-modal";
|
||||
import { CanvasFolderPreview } from "@/components/canvas/canvas-folder-preview";
|
||||
import { resolveProjectCanvasStyle } from "@/components/canvas/canvas-style-picker-modal";
|
||||
import { resourceFileUrl, resourceIdFromStorageKey } from "@/services/api/resources";
|
||||
import {
|
||||
bindProjectCharacterVoice,
|
||||
confirmProjectAssetCandidate,
|
||||
createProjectAssetFolder,
|
||||
createProjectAssetVersion,
|
||||
createProjectCharacter,
|
||||
deleteProjectAssetFolder,
|
||||
getProjectCharacter,
|
||||
linkProjectAsset,
|
||||
listVoiceProfiles,
|
||||
moveProjectAsset,
|
||||
replaceProjectCharacterRepresentations,
|
||||
unbindProjectCharacterVoice,
|
||||
unlinkProjectAsset,
|
||||
updateProjectAssetCategory,
|
||||
updateProjectAssetFolder,
|
||||
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";
|
||||
import { useConfigStore, useEffectiveConfig } from "@/stores/use-config-store";
|
||||
import { CanvasNodeType, type CanvasFolderStyle, type CanvasNodeData } from "@/types/canvas";
|
||||
import { saveAs } from "file-saver";
|
||||
|
||||
import { ProjectCharacterCard } from "./project-character-card";
|
||||
@@ -35,6 +42,7 @@ import { generateCharacterTurnaround } from "./project-character-media";
|
||||
import { categoryLabels, categoryLabel, mediaLabel, StatusPill, formatTime, textValue, type ProjectDetailViewProps } from "./shared";
|
||||
|
||||
const categories = ["all", "character", "environment", "wardrobe", "prop", "weapon", "style", "other"];
|
||||
const ALL_FOLDERS = "__all_folders__";
|
||||
const pickerCategoryLabels = { all: "全部素材", ...categoryLabels };
|
||||
const characterFields = [
|
||||
["role", "剧情定位与人物关系"], ["aliases", "别名"], ["appearance", "稳定外貌"], ["physique", "身高、体型与体态"],
|
||||
@@ -46,13 +54,16 @@ const characterFields = [
|
||||
type CharacterForm = { name: string } & Record<(typeof characterFields)[number][0], string>;
|
||||
|
||||
export default function ProjectAssetsView({ detail, refreshProject }: ProjectDetailViewProps) {
|
||||
const { message } = App.useApp();
|
||||
const { message, modal } = App.useApp();
|
||||
const queryClient = useQueryClient();
|
||||
const personalAssets = useAssetStore((state) => state.assets);
|
||||
const updatePersonalAsset = useAssetStore((state) => state.updateAsset);
|
||||
const effectiveConfig = useEffectiveConfig();
|
||||
const isAiConfigReady = useConfigStore((state) => state.isAiConfigReady);
|
||||
const [category, setCategory] = useState("all");
|
||||
const [folderId, setFolderId] = useState("");
|
||||
const [folderEditor, setFolderEditor] = useState<{ folder?: ProjectAssetFolder; parentId: string } | null>(null);
|
||||
const [folderName, setFolderName] = useState("");
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
const [editorAsset, setEditorAsset] = useState<ProjectAsset | "new" | null>(null);
|
||||
const [imageAsset, setImageAsset] = useState<ProjectAsset | null>(null);
|
||||
@@ -85,7 +96,18 @@ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDet
|
||||
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 visibleAssets = useMemo(() => category === "all" ? detail.assets : detail.assets.filter((asset) => asset.category === category), [category, detail.assets]);
|
||||
const assetFolders = detail.assetFolders || [];
|
||||
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 folderPath = useMemo(() => projectAssetFolderPath(assetFolders, folderId), [assetFolders, folderId]);
|
||||
const folderMoveItems = useMemo(() => [
|
||||
{ key: "", label: "素材库 / 根目录" },
|
||||
...assetFolders.map((folder) => ({ key: folder.id, label: projectAssetFolderLabel(assetFolders, folder) })),
|
||||
], [assetFolders]);
|
||||
const categoryCounts = categories.map((value) => ({
|
||||
value,
|
||||
count: value === "all"
|
||||
@@ -101,10 +123,36 @@ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDet
|
||||
|
||||
const done = (content: string) => { refreshProject(); message.success(content); };
|
||||
const failed = (fallback: string) => (error: unknown) => message.error(error instanceof Error ? error.message : fallback);
|
||||
const addMutation = useMutation({ mutationFn: ({ assetId, category: nextCategory }: { assetId: string; category: string }) => linkProjectAsset(detail.project.id, { assetId, category: nextCategory }), onSuccess: ({ asset }) => { updatePersonalAsset(asset.id, { category: asset.category as AssetCategory, status: asset.status as AssetStatus, primaryVersionId: asset.primaryVersionId }); setAddOpen(false); done("资产已加入项目"); }, onError: failed("资产加入失败") });
|
||||
const addMutation = useMutation({ mutationFn: ({ assetId, category: nextCategory, nextFolderId }: { assetId: string; category: string; nextFolderId?: string }) => linkProjectAsset(detail.project.id, { assetId, category: nextCategory, folderId: nextFolderId }), onSuccess: ({ asset }) => { updatePersonalAsset(asset.id, { category: asset.category as AssetCategory, status: asset.status as AssetStatus, primaryVersionId: asset.primaryVersionId }); setAddOpen(false); done("资产已加入项目"); }, onError: failed("资产加入失败") });
|
||||
const versionMutation = useMutation({ mutationFn: (id: string) => createProjectAssetVersion(detail.project.id, id, {}), onSuccess: () => done("已创建新版本"), onError: failed("版本创建失败") });
|
||||
const unlinkMutation = useMutation({ mutationFn: (id: string) => unlinkProjectAsset(detail.project.id, id), onSuccess: () => done("资产已移出项目"), onError: failed("资产移除失败") });
|
||||
const categoryMutation = useMutation({ mutationFn: ({ id, next }: { id: string; next: string }) => updateProjectAssetCategory(detail.project.id, id, next), onSuccess: ({ asset }) => { updatePersonalAsset(asset.id, { category: asset.category as AssetCategory }); done("资产分类已更新"); }, onError: failed("资产分类更新失败") });
|
||||
const moveMutation = useMutation({ mutationFn: ({ id, nextFolderId }: { id: string; nextFolderId: string }) => moveProjectAsset(detail.project.id, id, nextFolderId), onSuccess: () => done("资产已移动"), onError: failed("资产移动失败") });
|
||||
const createFolderMutation = useMutation({
|
||||
mutationFn: ({ name, parentId }: { name: string; parentId: string }) => createProjectAssetFolder(detail.project.id, { name, parentId: parentId || undefined }),
|
||||
onSuccess: ({ folder }) => { setFolderEditor(null); setFolderName(""); setFolderId(folder.parentId || ""); done("文件夹已创建"); },
|
||||
onError: failed("文件夹创建失败"),
|
||||
});
|
||||
const renameFolderMutation = useMutation({
|
||||
mutationFn: ({ id, name }: { id: string; name: string }) => updateProjectAssetFolder(detail.project.id, id, { name }),
|
||||
onSuccess: () => { setFolderEditor(null); setFolderName(""); done("文件夹已重命名"); },
|
||||
onError: failed("文件夹重命名失败"),
|
||||
});
|
||||
const styleFolderMutation = useMutation({
|
||||
mutationFn: ({ id, style }: { id: string; style: CanvasFolderStyle }) => updateProjectAssetFolder(detail.project.id, id, { style }),
|
||||
onSuccess: () => done("文件夹样式已更新"),
|
||||
onError: failed("文件夹样式更新失败"),
|
||||
});
|
||||
const moveFolderMutation = useMutation({
|
||||
mutationFn: ({ id, parentId }: { id: string; parentId: string }) => updateProjectAssetFolder(detail.project.id, id, { parentId }),
|
||||
onSuccess: () => done("文件夹已移动"),
|
||||
onError: failed("文件夹移动失败"),
|
||||
});
|
||||
const deleteFolderMutation = useMutation({
|
||||
mutationFn: (id: string) => deleteProjectAssetFolder(detail.project.id, id),
|
||||
onSuccess: (_, deletedId) => { if (folderId === deletedId) setFolderId(""); done("文件夹已删除"); },
|
||||
onError: failed("文件夹删除失败"),
|
||||
});
|
||||
const confirmMutation = useMutation({
|
||||
mutationFn: ({ candidateId, targetAssetId }: { candidateId: string; targetAssetId?: string }) => confirmProjectAssetCandidate(detail.project.id, candidateId, targetAssetId),
|
||||
onSuccess: ({ asset }, variables) => {
|
||||
@@ -166,6 +214,19 @@ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDet
|
||||
};
|
||||
const openImages = (asset: ProjectAsset) => setImageAsset(asset);
|
||||
const openVoice = (asset: ProjectAsset) => { setVoiceAsset(asset); setVoiceProfileId(asset.character?.voice?.profile.id || ""); setVoiceInstructions(asset.character?.voice?.instructions || ""); };
|
||||
const openFolderEditor = (folder?: ProjectAssetFolder, parentId = folderId === ALL_FOLDERS ? "" : folderId) => {
|
||||
setFolderEditor({ folder, parentId: folder?.parentId || parentId });
|
||||
setFolderName(folder?.name || "");
|
||||
};
|
||||
const saveFolder = () => {
|
||||
const name = folderName.trim();
|
||||
if (!name) {
|
||||
message.warning("请输入文件夹名称");
|
||||
return;
|
||||
}
|
||||
if (folderEditor?.folder) renameFolderMutation.mutate({ id: folderEditor.folder.id, name });
|
||||
else if (folderEditor) createFolderMutation.mutate({ name, parentId: folderEditor.parentId });
|
||||
};
|
||||
const downloadPreviewAsset = (asset: ProjectAsset) => {
|
||||
const personal = personalAssets.find((item) => item.id === asset.id);
|
||||
if (personal && (personal.kind === "image" || personal.kind === "video" || personal.kind === "audio" || personal.kind === "model")) {
|
||||
@@ -176,7 +237,11 @@ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDet
|
||||
}
|
||||
const cover = asset.character?.representations.find((item) => item.role === "turnaround_sheet") || asset.character?.representations.find((item) => item.role === "primary") || asset.character?.representations[0];
|
||||
if (cover) saveAs(resourceFileUrl(cover.resourceId), `${asset.title || "character"}.png`);
|
||||
else message.warning("当前资产没有可下载的媒体文件");
|
||||
else {
|
||||
const remoteUrl = projectAssetRemoteUrl(asset);
|
||||
if (remoteUrl) saveAs(remoteUrl, `${asset.title || "asset"}.${projectAssetFileExtension(asset.mediaType)}`);
|
||||
else message.warning("当前资产没有可下载的媒体文件");
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div>
|
||||
@@ -195,14 +260,30 @@ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDet
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1.5 sm:pl-4">
|
||||
<Button type="text" className="!h-9 !px-3" icon={<FolderPlus className="size-3.5" />} onClick={() => openFolderEditor()}>新建文件夹</Button>
|
||||
<Button type="text" className="!h-9 !px-3" icon={<Link2 className="size-3.5" />} onClick={() => setAddOpen(true)}>引用素材</Button>
|
||||
<Button type="primary" className="!h-9 !px-3.5" icon={<Plus className="size-3.5" />} onClick={() => openCharacterEditor("new")}>新建角色</Button>
|
||||
</div>
|
||||
</header>
|
||||
<div className="mt-3 grid gap-3 lg:grid-cols-[156px_minmax(0,1fr)]">
|
||||
<nav className="space-y-0.5 pr-2" aria-label="资产分类">{categoryCounts.map((item) => <button key={item.value} type="button" onClick={() => setCategory(item.value)} className={`flex h-11 w-full items-center justify-between rounded-md px-2 text-left text-xs ${category === item.value ? "bg-surface-active font-medium" : "text-foreground/55 hover:bg-surface-hover"}`}><span>{item.value === "all" ? "全部资产" : categoryLabels[item.value]}</span><span className="min-w-5 rounded bg-foreground/[.05] px-1 text-center text-[var(--fs-tiny)] tabular-nums">{item.count}</span></button>)}</nav>
|
||||
<div className="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"); }} />
|
||||
<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>)}
|
||||
</nav>
|
||||
<div className="min-w-0">
|
||||
{(category === "all" || category === "character") && pendingCandidates.length ? (
|
||||
<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>)}
|
||||
</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 })} 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)} />)}</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>
|
||||
@@ -228,8 +309,8 @@ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDet
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
<div className="mb-2 flex items-center justify-between text-xs text-foreground/45"><span>{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} generating={generatingAssetIds.has(asset.id)} removing={unlinkMutation.isPending && unlinkMutation.variables === asset.id} onOpen={() => setPreviewAsset(asset)} onEdit={() => openCharacterEditor(asset)} onGenerate={() => generateMutation.mutate(asset)} onBindImages={() => openImages(asset)} onBindVoice={() => openVoice(asset)} onRemove={() => unlinkMutation.mutate(asset.id)} /> : <MediaAssetCard key={asset.id} asset={asset} personalAsset={personalAssets.find((item) => item.id === asset.id)} onOpen={() => setPreviewAsset(asset)} onCategoryChange={(next) => categoryMutation.mutate({ id: asset.id, next })} onVersion={() => versionMutation.mutate(asset.id)} onRemove={() => unlinkMutation.mutate(asset.id)} loading={(categoryMutation.isPending && categoryMutation.variables?.id === asset.id) || (versionMutation.isPending && versionMutation.variables === asset.id) || (unlinkMutation.isPending && unlinkMutation.variables === asset.id)} />)}</div> : (category === "all" || category === "character") && pendingCandidates.length ? null : <WorkspaceState icon="assets" compact title="这个分类还没有资产" description={category === "character" ? "新建角色,或先从剧情章节提取角色信息。" : "可从个人素材库引用,或切换到其他分类查看。"} />}
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@@ -246,11 +327,14 @@ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDet
|
||||
onConfirm={async (ids) => {
|
||||
const selected = personalAssets.find((asset) => asset.id === ids[0]);
|
||||
if (!selected) throw new Error("所选素材已不存在,请重新选择");
|
||||
await addMutation.mutateAsync({ assetId: selected.id, category: selected.kind === "entity" ? "character" : selected.category || "other" });
|
||||
await addMutation.mutateAsync({ assetId: selected.id, category: selected.kind === "entity" ? "character" : selected.category || "other", nextFolderId: folderId === ALL_FOLDERS ? undefined : folderId });
|
||||
}}
|
||||
/>
|
||||
<ProjectAssetPreviewModal asset={previewAsset} personalAsset={previewAsset ? personalAssets.find((item) => item.id === previewAsset.id) : undefined} onClose={() => setPreviewAsset(null)} onDownload={() => previewAsset && downloadPreviewAsset(previewAsset)} onReplaceImage={() => { if (!previewAsset || previewAsset.category !== "character") return; setPreviewAsset(null); openImages(previewAsset); }} />
|
||||
<CharacterEditorModal open={Boolean(editorAsset)} editing={editorAsset !== "new"} form={form} loading={saveCharacter.isPending} onClose={() => setEditorAsset(null)} onSave={() => form.validateFields().then((values) => saveCharacter.mutate(values))} />
|
||||
<Modal className="workspace-modal workspace-modal-compact" title={folderEditor?.folder ? "重命名文件夹" : "新建文件夹"} open={Boolean(folderEditor)} okText={folderEditor?.folder ? "保存" : "创建"} cancelText="取消" okButtonProps={{ loading: createFolderMutation.isPending || renameFolderMutation.isPending }} onCancel={() => { setFolderEditor(null); setFolderName(""); }} onOk={saveFolder}>
|
||||
<div className="grid gap-2"><span className="text-[var(--fs-label)] text-foreground/48">位置:{folderEditor ? projectAssetFolderParentLabel(assetFolders, folderEditor.parentId) : ""}</span><Input autoFocus maxLength={60} value={folderName} placeholder="输入文件夹名称" onChange={(event) => setFolderName(event.target.value)} onPressEnter={saveFolder} /></div>
|
||||
</Modal>
|
||||
<AssetLibraryPickerModal
|
||||
open={Boolean(imageAsset)}
|
||||
items={imagePickerItems}
|
||||
@@ -272,6 +356,129 @@ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDet
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectAssetFolderTree({ folders, assets, selectedId, onSelect }: { folders: ProjectAssetFolder[]; assets: ProjectAsset[]; 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 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, onDelete, deleting }: { folder: ProjectAssetFolder; folders: ProjectAssetFolder[]; assets: ProjectAsset[]; personalAssets: Asset[]; onOpen: () => void; onRename: () => void; onMove: (parentId: string) => void; onStyle: (style: CanvasFolderStyle) => void; onDelete: () => void; deleting: boolean }) {
|
||||
const directAssets = assets.filter((asset) => (asset.folderId || "") === folder.id);
|
||||
const totalCount = projectAssetFolderDescendantAssetCount(folders, assets, folder.id);
|
||||
const childFolderCount = folders.filter((item) => item.parentId === folder.id).length;
|
||||
const moveItems = projectAssetFolderMoveItems(folders, folder);
|
||||
const data: CanvasNodeData = {
|
||||
id: folder.id,
|
||||
type: CanvasNodeType.Frame,
|
||||
title: folder.name,
|
||||
position: { x: 0, y: 0 },
|
||||
width: 360,
|
||||
height: 280,
|
||||
metadata: {
|
||||
frame: { collapsed: true, expandedWidth: 760, expandedHeight: 520 },
|
||||
folder: { style: projectAssetFolderStyle(folder.style), createdAt: folder.createdAt },
|
||||
},
|
||||
};
|
||||
const childNodes = directAssets.slice(0, 3).map((asset, index) => projectAssetCanvasPreviewNode(asset, personalAssets.find((item) => item.id === asset.id), index));
|
||||
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} /></div>
|
||||
<div className="project-asset-folder-card-footer"><span>{directAssets.length} 项内容{childFolderCount ? ` · ${childFolderCount} 个子文件夹` : ""}</span><Dropdown trigger={["click"]} menu={{ selectedKeys: [`style:${folder.style}`], 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: "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 === "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>;
|
||||
}
|
||||
|
||||
function projectAssetCanvasPreviewNode(asset: ProjectAsset, personalAsset: Asset | undefined, index: number): CanvasNodeData {
|
||||
const type = asset.mediaType === "image" || asset.category === "character" ? CanvasNodeType.Image : asset.mediaType === "video" ? CanvasNodeType.Video : asset.mediaType === "audio" ? CanvasNodeType.Audio : CanvasNodeType.Text;
|
||||
const characterCover = asset.character?.representations.find((item) => item.role === "turnaround_sheet") || asset.character?.representations.find((item) => item.role === "primary") || asset.character?.representations[0];
|
||||
const content = characterCover
|
||||
? resourceFileUrl(characterCover.resourceId)
|
||||
: personalAsset?.kind === "image"
|
||||
? personalAsset.data.dataUrl || personalAsset.coverUrl
|
||||
: personalAsset?.kind === "video" || personalAsset?.kind === "audio"
|
||||
? personalAsset.data.url
|
||||
: personalAsset?.kind === "text"
|
||||
? personalAsset.data.content
|
||||
: projectAssetRemoteUrl(asset) || asset.previewText || "";
|
||||
return { id: asset.id, type, title: asset.title, position: { x: index * 24, y: index * 18 }, width: 240, height: 160, metadata: { content, assetId: asset.id } };
|
||||
}
|
||||
|
||||
function projectAssetFolderStyle(style?: string): CanvasFolderStyle {
|
||||
return style === "glass" || style === "stacked" || style === "midnight" || style === "paper" || style === "cinema" || style === "compact" ? style : "glass";
|
||||
}
|
||||
|
||||
function projectAssetRemoteUrl(asset: ProjectAsset) {
|
||||
const resourceId = resourceIdFromStorageKey(asset.storageKey);
|
||||
return resourceId ? resourceFileUrl(resourceId) : "";
|
||||
}
|
||||
|
||||
function projectAssetFileExtension(mediaType: string) {
|
||||
if (mediaType === "image") return "png";
|
||||
if (mediaType === "video") return "mp4";
|
||||
if (mediaType === "audio") return "mp3";
|
||||
if (mediaType === "model") return "glb";
|
||||
return "txt";
|
||||
}
|
||||
|
||||
function projectAssetFolderPath(folders: ProjectAssetFolder[], folderId: string) {
|
||||
if (!folderId || folderId === ALL_FOLDERS) return [];
|
||||
const byId = new Map(folders.map((folder) => [folder.id, folder]));
|
||||
const result: ProjectAssetFolder[] = [];
|
||||
const seen = new Set<string>();
|
||||
let current = byId.get(folderId);
|
||||
while (current && !seen.has(current.id)) {
|
||||
seen.add(current.id);
|
||||
result.unshift(current);
|
||||
current = current.parentId ? byId.get(current.parentId) : undefined;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function projectAssetFolderLabel(folders: ProjectAssetFolder[], folder: ProjectAssetFolder) {
|
||||
return projectAssetFolderPath(folders, folder.id).map((item) => item.name).join(" / ");
|
||||
}
|
||||
|
||||
function projectAssetFolderParentLabel(folders: ProjectAssetFolder[], parentId: string) {
|
||||
const path = parentId ? projectAssetFolderPath(folders, parentId).map((item) => item.name) : [];
|
||||
return ["素材库", ...path].join(" / ");
|
||||
}
|
||||
|
||||
function projectAssetFolderMoveItems(folders: ProjectAssetFolder[], movingFolder: ProjectAssetFolder) {
|
||||
const blocked = new Set([movingFolder.id]);
|
||||
let changed = true;
|
||||
while (changed) {
|
||||
changed = false;
|
||||
for (const folder of folders) {
|
||||
if (!folder.parentId || blocked.has(folder.id) || !blocked.has(folder.parentId)) continue;
|
||||
blocked.add(folder.id);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
return [
|
||||
{ key: "move:root", label: "素材库 / 根目录", disabled: !movingFolder.parentId },
|
||||
...folders.filter((folder) => !blocked.has(folder.id)).map((folder) => ({ key: `move:${folder.id}`, label: projectAssetFolderLabel(folders, folder), disabled: movingFolder.parentId === folder.id })),
|
||||
];
|
||||
}
|
||||
|
||||
function projectAssetFolderDescendantAssetCount(folders: ProjectAssetFolder[], assets: ProjectAsset[], folderId: string) {
|
||||
const descendantIds = new Set([folderId]);
|
||||
let added = true;
|
||||
while (added) {
|
||||
added = false;
|
||||
for (const folder of folders) {
|
||||
if (!folder.parentId || descendantIds.has(folder.id) || !descendantIds.has(folder.parentId)) continue;
|
||||
descendantIds.add(folder.id);
|
||||
added = true;
|
||||
}
|
||||
}
|
||||
return assets.filter((asset) => asset.folderId && descendantIds.has(asset.folderId)).length;
|
||||
}
|
||||
|
||||
function CharacterEditorModal({ open, editing, form, loading, onClose, onSave }: { open: boolean; editing: boolean; form: FormInstance<CharacterForm>; loading: boolean; onClose: () => void; onSave: () => void }) {
|
||||
const field = (key: CharacterFormKey) => characterFields.find(([name]) => name === key)!;
|
||||
const textArea = (key: CharacterFormKey, rows = 3) => <Form.Item name={key} label={field(key)[1]}><Input.TextArea rows={rows} placeholder={key === "appearance" ? "先描述用户能看到的稳定特征…" : undefined} /></Form.Item>;
|
||||
@@ -284,19 +491,30 @@ function CharacterEditorModal({ open, editing, form, loading, onClose, onSave }:
|
||||
|
||||
type CharacterFormKey = (typeof characterFields)[number][0];
|
||||
|
||||
function MediaAssetCard({ asset, personalAsset, onOpen, onCategoryChange, onVersion, onRemove, loading }: { asset: ProjectAsset; personalAsset?: Asset; onOpen: () => void; onCategoryChange: (category: string) => void; onVersion: () => void; onRemove: () => void; loading: boolean }) {
|
||||
return <AssetLibraryCard className="project-asset-library-card"><AssetLibraryCardMedia className="relative aspect-[4/3] overflow-hidden bg-foreground/[.05]"><button type="button" className="project-asset-media-button" onClick={onOpen} aria-label={`查看资产:${asset.title}`}><AssetMediaPreview asset={personalAsset} alt={asset.title} className="h-full w-full bg-black object-cover" fallback={<div className="grid h-full place-items-center text-foreground/25"><MediaIcon kind={asset.mediaType} /></div>} /><div className="absolute inset-x-2 top-2 flex items-center justify-between"><StatusPill status={asset.status} /><span className="rounded bg-black/50 px-1.5 py-0.5 text-[var(--fs-micro)] text-white">{mediaLabel(asset.mediaType)}</span></div></button></AssetLibraryCardMedia><div className="p-2.5"><button type="button" className="project-asset-title-button" onClick={onOpen}><span className="min-w-0 truncate text-xs font-medium">{asset.title}</span><span className="shrink-0 text-[var(--fs-micro)] text-foreground/38">{formatTime(asset.updatedAt)}</span></button><div className="mt-1 flex items-center gap-1.5 text-[var(--fs-tiny)] text-foreground/42"><Dropdown trigger={["click"]} menu={{ selectedKeys: [asset.category], items: Object.entries(categoryLabels).filter(([value]) => value !== "character").map(([value, label]) => ({ key: value, label })), onClick: ({ key }) => onCategoryChange(key) }}><button type="button" disabled={loading} className="inline-flex h-6 items-center gap-1 rounded px-1.5 text-[var(--fs-tiny)] text-foreground/50 hover:bg-surface-hover"><span>{categoryLabel(asset.category)}</span><ChevronDown className="size-3" /></button></Dropdown><span>·</span><span>v{Math.max(1, asset.versionCount)}</span><Link2 className="ml-auto size-3.5 shrink-0 text-foreground/42" /></div><div className="mt-2 flex items-center justify-between gap-2 pt-1"><span className="text-[var(--fs-micro)] text-foreground/38">{mediaLabel(asset.mediaType)}</span><div className="flex items-center"><Button type="text" size="small" icon={<RefreshCw className="size-3.5" />} loading={loading} onClick={onVersion} /><Popconfirm title="移出项目资产?" okText="移出" cancelText="取消" onConfirm={onRemove}><Button type="text" danger size="small" icon={<Trash2 className="size-3.5" />} loading={loading} /></Popconfirm></div></div></div></AssetLibraryCard>;
|
||||
function MediaAssetCard({ asset, personalAsset, folderItems, onOpen, onMove, onCategoryChange, onVersion, onRemove, loading }: { asset: ProjectAsset; personalAsset?: Asset; folderItems: Array<{ key: string; label: string }>; onOpen: () => void; onMove: (folderId: string) => void; onCategoryChange: (category: string) => void; onVersion: () => void; onRemove: () => void; loading: boolean }) {
|
||||
return <AssetLibraryCard className="project-asset-library-card"><AssetLibraryCardMedia className="relative aspect-[4/3] overflow-hidden bg-foreground/[.05]"><button type="button" className="project-asset-media-button" onClick={onOpen} aria-label={`查看资产:${asset.title}`}><ProjectAssetMedia asset={asset} personalAsset={personalAsset} /><div className="absolute inset-x-2 top-2 flex items-center justify-between"><StatusPill status={asset.status} /><span className="rounded bg-black/50 px-1.5 py-0.5 text-[var(--fs-micro)] text-white">{mediaLabel(asset.mediaType)}</span></div></button></AssetLibraryCardMedia><div className="p-2.5"><button type="button" className="project-asset-title-button" onClick={onOpen}><span className="min-w-0 truncate text-xs font-medium">{asset.title}</span><span className="shrink-0 text-[var(--fs-micro)] text-foreground/38">{formatTime(asset.updatedAt)}</span></button><div className="mt-1 flex items-center gap-1.5 text-[var(--fs-tiny)] text-foreground/42"><Dropdown trigger={["click"]} menu={{ selectedKeys: [asset.category], items: Object.entries(categoryLabels).filter(([value]) => value !== "character").map(([value, label]) => ({ key: value, label })), onClick: ({ key }) => onCategoryChange(key) }}><button type="button" disabled={loading} className="inline-flex h-6 items-center gap-1 rounded px-1.5 text-[var(--fs-tiny)] text-foreground/50 hover:bg-surface-hover"><span>{categoryLabel(asset.category)}</span><ChevronDown className="size-3" /></button></Dropdown><span>·</span><span>v{Math.max(1, asset.versionCount)}</span><Link2 className="ml-auto size-3.5 shrink-0 text-foreground/42" /></div><div className="mt-2 flex items-center justify-between gap-2 pt-1"><span className="text-[var(--fs-micro)] text-foreground/38">{mediaLabel(asset.mediaType)}</span><div className="flex items-center"><Dropdown trigger={["click"]} menu={{ selectedKeys: [asset.folderId || ""], items: folderItems, onClick: ({ key }) => onMove(key) }}><Button type="text" size="small" icon={<MoveRight className="size-3.5" />} loading={loading} aria-label={`移动 ${asset.title}`} /></Dropdown><Button type="text" size="small" icon={<RefreshCw className="size-3.5" />} loading={loading} onClick={onVersion} aria-label={`为 ${asset.title} 创建版本`} /><Popconfirm title="移出项目资产?" okText="移出" cancelText="取消" onConfirm={onRemove}><Button type="text" danger size="small" icon={<Trash2 className="size-3.5" />} loading={loading} aria-label={`移出 ${asset.title}`} /></Popconfirm></div></div></div></AssetLibraryCard>;
|
||||
}
|
||||
|
||||
function ProjectAssetMedia({ asset, personalAsset }: { asset: ProjectAsset; personalAsset?: Asset }) {
|
||||
if (personalAsset) return <AssetMediaPreview asset={personalAsset} alt={asset.title} className="h-full w-full bg-black object-cover" fallback={<div className="grid h-full place-items-center text-foreground/25"><MediaIcon kind={asset.mediaType} /></div>} />;
|
||||
const remoteUrl = projectAssetRemoteUrl(asset);
|
||||
if (asset.mediaType === "image" && remoteUrl) return <img src={remoteUrl} alt={asset.title} className="h-full w-full bg-black object-cover" />;
|
||||
if (asset.mediaType === "video" && remoteUrl) return <video src={remoteUrl} muted preload="metadata" className="h-full w-full bg-black object-cover" />;
|
||||
if (asset.mediaType === "text" && asset.previewText) return <p className="line-clamp-6 h-full overflow-hidden p-4 text-left text-xs leading-5 text-foreground/62">{asset.previewText}</p>;
|
||||
return <div className="grid h-full place-items-center text-foreground/25"><MediaIcon kind={asset.mediaType} /></div>;
|
||||
}
|
||||
|
||||
function ProjectAssetPreviewModal({ asset, personalAsset, onClose, onDownload, onReplaceImage }: { asset: ProjectAsset | null; personalAsset?: Asset; onClose: () => void; onDownload: () => void; onReplaceImage: () => void }) {
|
||||
const characterCover = asset?.character?.representations.find((item) => item.role === "turnaround_sheet") || asset?.character?.representations.find((item) => item.role === "primary") || asset?.character?.representations[0];
|
||||
const canDownload = Boolean(personalAsset && ["image", "video", "audio", "model"].includes(personalAsset.kind)) || Boolean(characterCover);
|
||||
const previewClass = asset?.category === "character" ? "is-character" : personalAsset?.kind === "video" ? "is-video" : personalAsset?.kind === "audio" ? "is-audio" : personalAsset?.kind === "text" ? "is-text" : "is-image";
|
||||
const remoteUrl = asset ? projectAssetRemoteUrl(asset) : "";
|
||||
const canDownload = Boolean(personalAsset && ["image", "video", "audio", "model"].includes(personalAsset.kind)) || Boolean(characterCover) || Boolean(remoteUrl);
|
||||
const previewKind = personalAsset?.kind || asset?.mediaType;
|
||||
const previewClass = asset?.category === "character" ? "is-character" : previewKind === "video" ? "is-video" : previewKind === "audio" ? "is-audio" : previewKind === "text" ? "is-text" : "is-image";
|
||||
return (
|
||||
<Modal className="workspace-modal workspace-modal-wide library-modal project-asset-preview-modal" title={asset?.category === "character" ? "角色卡预览" : "资产预览"} open={Boolean(asset)} onCancel={onClose} footer={<div className="flex justify-end gap-2"><Button onClick={onClose}>关闭</Button>{asset?.category === "character" ? <Button onClick={onReplaceImage}>替换图片</Button> : null}{canDownload ? <Button type="primary" icon={<Download className="size-3.5" />} onClick={onDownload}>下载</Button> : null}</div>}>
|
||||
{asset ? <div className="project-asset-preview-layout">
|
||||
<div className={`project-asset-preview-stage ${previewClass}`}>
|
||||
{asset.category === "character" ? characterCover ? <img src={resourceFileUrl(characterCover.resourceId)} alt={asset.title} className="project-asset-preview-media" /> : <div className="grid min-h-48 place-items-center text-foreground/35"><UserRound className="size-12" /></div> : personalAsset?.kind === "video" ? <video src={personalAsset.data.url} controls className="project-asset-preview-media" /> : personalAsset?.kind === "audio" ? <audio src={personalAsset.data.url} controls className="project-asset-preview-audio" /> : personalAsset?.kind === "image" ? <img src={personalAsset.data.dataUrl || personalAsset.coverUrl} alt={asset.title} className="project-asset-preview-media" /> : personalAsset?.kind === "text" ? <p className="project-asset-preview-text">{personalAsset.data.content}</p> : <div className="grid min-h-48 place-items-center text-foreground/35"><MediaIcon kind={asset.mediaType} /></div>}
|
||||
{asset.category === "character" ? characterCover ? <img src={resourceFileUrl(characterCover.resourceId)} alt={asset.title} className="project-asset-preview-media" /> : <div className="grid min-h-48 place-items-center text-foreground/35"><UserRound className="size-12" /></div> : personalAsset?.kind === "video" ? <video src={personalAsset.data.url} controls className="project-asset-preview-media" /> : personalAsset?.kind === "audio" ? <audio src={personalAsset.data.url} controls className="project-asset-preview-audio" /> : personalAsset?.kind === "image" ? <img src={personalAsset.data.dataUrl || personalAsset.coverUrl} alt={asset.title} className="project-asset-preview-media" /> : personalAsset?.kind === "text" ? <p className="project-asset-preview-text">{personalAsset.data.content}</p> : asset.mediaType === "video" && remoteUrl ? <video src={remoteUrl} controls className="project-asset-preview-media" /> : asset.mediaType === "audio" && remoteUrl ? <audio src={remoteUrl} controls className="project-asset-preview-audio" /> : asset.mediaType === "image" && remoteUrl ? <img src={remoteUrl} alt={asset.title} className="project-asset-preview-media" /> : asset.mediaType === "text" && asset.previewText ? <p className="project-asset-preview-text">{asset.previewText}</p> : <div className="grid min-h-48 place-items-center text-foreground/35"><MediaIcon kind={asset.mediaType} /></div>}
|
||||
</div>
|
||||
<aside className="project-asset-preview-details">
|
||||
<div className="project-asset-preview-eyebrow">{asset.category === "character" ? "角色卡" : mediaLabel(asset.mediaType)}</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Button, Popconfirm } from "antd";
|
||||
import { Image as ImageIcon, Pencil, Sparkles, Trash2, UserRound, Volume2 } from "lucide-react";
|
||||
import { Button, Dropdown, Popconfirm } from "antd";
|
||||
import { Image as ImageIcon, MoveRight, Pencil, Sparkles, Trash2, UserRound, Volume2 } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { resourceFileUrl } from "@/services/api/resources";
|
||||
@@ -8,8 +8,9 @@ import { AssetLibraryCard, AssetLibraryCardMedia } from "@/components/assets/ass
|
||||
|
||||
import { textValue } from "./shared";
|
||||
|
||||
export function ProjectCharacterCard({ asset, generating, removing, onOpen, onEdit, onGenerate, onBindImages, onBindVoice, onRemove }: {
|
||||
export function ProjectCharacterCard({ asset, folderItems, generating, removing, onOpen, onEdit, onGenerate, onBindImages, onBindVoice, onMove, onRemove }: {
|
||||
asset: ProjectAsset;
|
||||
folderItems: Array<{ key: string; label: string }>;
|
||||
generating: boolean;
|
||||
removing: boolean;
|
||||
onOpen: () => void;
|
||||
@@ -17,6 +18,7 @@ export function ProjectCharacterCard({ asset, generating, removing, onOpen, onEd
|
||||
onGenerate: () => void;
|
||||
onBindImages: () => void;
|
||||
onBindVoice: () => void;
|
||||
onMove: (folderId: string) => void;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
const character = asset.character;
|
||||
@@ -45,7 +47,7 @@ export function ProjectCharacterCard({ asset, generating, removing, onOpen, onEd
|
||||
<StatusLine icon={<Volume2 className="size-3.5" />} ready={character?.voiceStatus === "ready"} label={voiceStatus} action={character?.voiceStatus === "ready" ? "调整" : "选择"} onClick={onBindVoice} />
|
||||
</div>
|
||||
<div className="mt-3 flex min-w-0 gap-2 border-t border-border/60 pt-2">
|
||||
<Button size="small" className="min-w-0 flex-1" icon={<Sparkles className="size-3.5" />} loading={generating} disabled={removing} onClick={onGenerate}>{character?.visualStatus === "missing" ? "初始化三视图" : "重新生成三视图"}</Button><Popconfirm title="移出项目角色?" description="已有画布或镜头引用时将无法移出。" okText="移出" cancelText="取消" onConfirm={onRemove}><Button type="text" danger size="small" loading={removing} disabled={generating} icon={<Trash2 className="size-3.5" />} aria-label={`移出 ${asset.title}`} /></Popconfirm>
|
||||
<Button size="small" className="min-w-0 flex-1" icon={<Sparkles className="size-3.5" />} loading={generating} disabled={removing} onClick={onGenerate}>{character?.visualStatus === "missing" ? "初始化三视图" : "重新生成三视图"}</Button><Dropdown trigger={["click"]} menu={{ selectedKeys: [asset.folderId || ""], items: folderItems, onClick: ({ key }) => onMove(key) }}><Button type="text" size="small" disabled={generating || removing} icon={<MoveRight className="size-3.5" />} aria-label={`移动 ${asset.title}`} /></Dropdown><Popconfirm title="移出项目角色?" description="已有画布或镜头引用时将无法移出。" okText="移出" cancelText="取消" onConfirm={onRemove}><Button type="text" danger size="small" loading={removing} disabled={generating} icon={<Trash2 className="size-3.5" />} aria-label={`移出 ${asset.title}`} /></Popconfirm>
|
||||
</div>
|
||||
</div>
|
||||
</AssetLibraryCard>
|
||||
|
||||
Reference in New Issue
Block a user