diff --git a/web/src/components/assets/asset-library-card.tsx b/web/src/components/assets/asset-library-card.tsx new file mode 100644 index 000000000..3afc64850 --- /dev/null +++ b/web/src/components/assets/asset-library-card.tsx @@ -0,0 +1,12 @@ +import type { ReactNode } from "react"; + +import { cn } from "@/lib/utils"; + +/** 资产库卡片的共同外壳;页面只负责业务动作和卡片内的信息层级。 */ +export function AssetLibraryCard({ children, className, selected = false }: { children: ReactNode; className?: string; selected?: boolean }) { + return
{children}
; +} + +export function AssetLibraryCardMedia({ children, className }: { children: ReactNode; className?: string }) { + return
{children}
; +} diff --git a/web/src/components/canvas/canvas-project-card.tsx b/web/src/components/canvas/canvas-project-card.tsx index bc547cf22..8213af55c 100644 --- a/web/src/components/canvas/canvas-project-card.tsx +++ b/web/src/components/canvas/canvas-project-card.tsx @@ -1,4 +1,5 @@ import { Check, Clapperboard, Download, FileText, Frame, Image as ImageIcon, MoreHorizontal, Music2, Pencil, Plus, Settings2, Sparkles, Trash2, Video, X } from "lucide-react"; +import type { ReactNode } from "react"; import { useNavigate, useSearchParams } from "react-router"; import { Dropdown, Input } from "antd"; @@ -18,7 +19,7 @@ export function CanvasCreateCard({ disabled, onClick }: { disabled?: boolean; on ; } -export function CanvasProjectCard({ project, projectName, variant = "library" }: { project: CanvasProject; projectName?: string; variant?: "library" | "recent" }) { +export function CanvasProjectCard({ project, projectName, variant = "library", readOnly = false, footer }: { project: CanvasProject; projectName?: string; variant?: "library" | "recent"; readOnly?: boolean; footer?: ReactNode }) { const navigate = useNavigate(); const [searchParams] = useSearchParams(); const renameProject = useCanvasStore((state) => state.renameProject); @@ -40,7 +41,7 @@ export function CanvasProjectCard({ project, projectName, variant = "library" }: const compact = variant === "recent"; return ( -
!editing && open()}> +
(!editing || readOnly) && open()}>
- {!compact ? event.stopPropagation()}> toggleSelected(project.id, event.target.checked)} className="app-canvas-project-checkbox" aria-label={`选择 ${project.title}`} /> : null} + {!compact && !readOnly ? event.stopPropagation()}> toggleSelected(project.id, event.target.checked)} className="app-canvas-project-checkbox" aria-label={`选择 ${project.title}`} /> : null}
- {editing ? ( + {editing && !readOnly ? ( event.stopPropagation()} onChange={(event) => setEditingTitle(event.target.value)} onKeyDown={(event) => event.key === "Enter" && saveTitle()} autoFocus /> ) : ( )} - {editing ? ( + {editing && !readOnly ? (
event.stopPropagation()}>
- ) : ( + ) : !readOnly ? (
event.stopPropagation()}>
- )} + ) : null}
{projectName || "自由画布"}
+ {footer ?
event.stopPropagation()}>{footer}
: null}
); diff --git a/web/src/pages/assets/index.tsx b/web/src/pages/assets/index.tsx index 90e0998b0..5835b1092 100644 --- a/web/src/pages/assets/index.tsx +++ b/web/src/pages/assets/index.tsx @@ -5,6 +5,7 @@ import { App, Button, Drawer, Dropdown, Form, Input, Modal, Select, Space, Tag, import { CollectionGrid, ListToolbar, PageHeader, PaginationBar, WorkspacePage } from "@/components/layout/workspace-page"; import { WorkspaceState } from "@/components/layout/workspace-state"; import { AssetMediaPreview } from "@/components/asset-media-preview"; +import { AssetLibraryCard, AssetLibraryCardMedia } from "@/components/assets/asset-library-card"; import { saveAs } from "file-saver"; import { useCopyText } from "@/hooks/use-copy-text"; @@ -432,8 +433,8 @@ export default function AssetsPage() { function AssetCard({ asset, selected, onSelect, onOpen, onEdit, onCopy, onDownload, onDelete }: { asset: LibraryAsset; selected: boolean; onSelect: (selected: boolean) => void; onOpen: () => void; onEdit: () => void; onCopy: (asset: LibraryAsset) => void; onDownload: (asset: LibraryAsset) => void; onDelete: () => void }) { const summary = assetSummary(asset); return ( -
-
+ +
} /> @@ -456,7 +457,7 @@ function AssetCard({ asset, selected, onSelect, onOpen, onEdit, onCopy, onDownlo {assetKindLabel(asset.kind)} {assetCategoryLabel(asset.category)} - + -
+ ); } diff --git a/web/src/pages/projects/detail/assets.tsx b/web/src/pages/projects/detail/assets.tsx index b6aa8b9e5..b56026fa9 100644 --- a/web/src/pages/projects/detail/assets.tsx +++ b/web/src/pages/projects/detail/assets.tsx @@ -1,10 +1,11 @@ import { useMemo, useState } 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, FileText, Image as ImageIcon, Link2, Music2, Plus, RefreshCw, Sparkles, Trash2, UserRound, Video, VolumeX } from "lucide-react"; +import { Box, Check, ChevronDown, Download, FileText, Image as ImageIcon, Link2, Music2, 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 { WorkspaceSignalIcon } from "@/components/ui/aceternity/workspace-signal-icon"; import { resolveCanvasStylePreset } from "@/components/canvas/canvas-style-picker-modal"; import { resourceFileUrl, resourceIdFromStorageKey } from "@/services/api/resources"; @@ -27,10 +28,11 @@ import { 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 { saveAs } from "file-saver"; import { ProjectCharacterCard } from "./project-character-card"; import { generateCharacterTurnaround } from "./project-character-media"; -import { categoryLabels, categoryLabel, mediaLabel, StatusPill, formatTime, type ProjectDetailViewProps } from "./shared"; +import { categoryLabels, categoryLabel, mediaLabel, StatusPill, formatTime, textValue, type ProjectDetailViewProps } from "./shared"; const categories = ["all", "character", "environment", "wardrobe", "prop", "weapon", "style", "other"]; const characterFields = [ @@ -56,6 +58,7 @@ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDet const [editorAsset, setEditorAsset] = useState(null); const [imageAsset, setImageAsset] = useState(null); const [voiceAsset, setVoiceAsset] = useState(null); + const [previewAsset, setPreviewAsset] = useState(null); const [imageSelection, setImageSelection] = useState(""); const [voiceProfileId, setVoiceProfileId] = useState(""); const [voiceInstructions, setVoiceInstructions] = useState(""); @@ -149,6 +152,18 @@ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDet }; const openImages = (asset: ProjectAsset) => { setImageAsset(asset); setImageSelection(""); }; const openVoice = (asset: ProjectAsset) => { setVoiceAsset(asset); setVoiceProfileId(asset.character?.voice?.profile.id || ""); setVoiceInstructions(asset.character?.voice?.instructions || ""); }; + 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")) { + const url = personal.kind === "image" ? personal.data.dataUrl : personal.data.url; + const extension = personal.kind === "model" ? personal.data.fileName.split(".").pop() || "glb" : personal.data.mimeType.split("/")[1] || "bin"; + saveAs(url, `${asset.title || "asset"}.${extension}`); + return; + } + 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("当前资产没有可下载的媒体文件"); + }; return (
@@ -201,11 +216,12 @@ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDet ) : null}
{category === "all" ? "全部资产" : categoryLabel(category)}{visibleAssets.length} 项已确认
- {visibleAssets.length ?
{visibleAssets.map((asset) => asset.category === "character" ? openCharacterEditor(asset)} onGenerate={() => generateMutation.mutate(asset)} onBindImages={() => openImages(asset)} onBindVoice={() => openVoice(asset)} onRemove={() => unlinkMutation.mutate(asset.id)} /> : item.id === asset.id)} 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)} />)}
: (category === "all" || category === "character") && pendingCandidates.length ? null : } + {visibleAssets.length ?
{visibleAssets.map((asset) => asset.category === "character" ? setPreviewAsset(asset)} onEdit={() => openCharacterEditor(asset)} onGenerate={() => generateMutation.mutate(asset)} onBindImages={() => openImages(asset)} onBindVoice={() => openVoice(asset)} onRemove={() => unlinkMutation.mutate(asset.id)} /> : 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)} />)}
: (category === "all" || category === "character") && pendingCandidates.length ? null : }
setAddOpen(false)} onOk={() => addMutation.mutate()} width={480}>
value !== "character").map(([value, label]) => ({ value, label }))} onChange={setAssetCategory} />
+ item.id === previewAsset.id) : undefined} onClose={() => setPreviewAsset(null)} onDownload={() => previewAsset && downloadPreviewAsset(previewAsset)} onReplaceImage={() => { if (!previewAsset || previewAsset.category !== "character") return; setPreviewAsset(null); openImages(previewAsset); }} /> setEditorAsset(null)} onSave={() => form.validateFields().then((values) => saveCharacter.mutate(values))} /> setImageAsset(null)} onOk={() => bindImagesMutation.mutate()}>{imageAssets.length ?
{imageAssets.map((asset) => )}
: }
setVoiceAsset(null)} onOk={() => bindVoiceMutation.mutate()}>
({ label: `${String(unit.position + 1).padStart(2, "0")} · ${unit.title}`, value: unit.id }))} onChange={(unitId) => { setLinkingCanvasId(canvas.id); linkMutation.mutate({ canvasId: canvas.id, unitId }); }} loading={linkMutation.isPending && linkingCanvasId === canvas.id} suffixIcon={} /> + unlinkProjectMutation.mutate(canvas.id)}> +
-
-