feat: 完成短剧项目空间与结构化资产创作闭环 (#38)
* feat(canvas): 短剧项目空间 - 建立项目章节与画布关联 MVP * feat(canvas): 短剧项目空间 - 完善项目资产、镜头与流程闭环 Refs #30 * fix(deploy): 本地部署 - 修复前端构建并忽略本地 PostgreSQL 配置 * feat(*): 短剧项目空间 - 完成项目、章节、资产、角色与画布创作闭环 Refs #30 --------- Co-authored-by: ddCat <ddcat666@88.com>
This commit is contained in:
@@ -26,7 +26,7 @@ export async function exportAssets(assets: Asset[]) {
|
||||
|
||||
await Promise.all(
|
||||
assets.map(async (asset) => {
|
||||
if (asset.kind !== "image" && asset.kind !== "video" && asset.kind !== "model") return;
|
||||
if (asset.kind !== "image" && asset.kind !== "video" && asset.kind !== "audio" && asset.kind !== "model") return;
|
||||
const storageKey = asset.data.storageKey;
|
||||
if (!storageKey) return;
|
||||
const blob = asset.kind === "image" ? await getImageBlob(storageKey) : await getMediaBlob(storageKey);
|
||||
@@ -69,6 +69,8 @@ function fileExtension(mimeType: string, kind: Asset["kind"]) {
|
||||
if (mimeType.includes("gif")) return "gif";
|
||||
if (mimeType.includes("mp4")) return "mp4";
|
||||
if (mimeType.includes("webm")) return "webm";
|
||||
if (mimeType.includes("mpeg")) return "mp3";
|
||||
if (mimeType.includes("wav")) return "wav";
|
||||
if (mimeType.includes("gltf-binary")) return "glb";
|
||||
if (mimeType.includes("gltf+json") || mimeType.includes("json")) return "gltf";
|
||||
return kind === "image" ? "png" : "bin";
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Copy, Download, FileUp, MoreHorizontal, PencilLine, Plus, Search, Trash2, Upload } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { App, Button, Card, Drawer, Dropdown, Empty, Form, Image, Input, Modal, Select, Space, Tag, Typography } from "antd";
|
||||
import { App, Button, Drawer, Dropdown, Form, Image, Input, Modal, Select, Space, Tag, Typography } from "antd";
|
||||
|
||||
import { CollectionGrid, ListToolbar, PageHeader, PaginationBar, WorkspacePage } from "@/components/layout/workspace-page";
|
||||
import { WorkspaceState } from "@/components/layout/workspace-state";
|
||||
import { saveAs } from "file-saver";
|
||||
|
||||
import { useCopyText } from "@/hooks/use-copy-text";
|
||||
@@ -10,11 +11,13 @@ import { resourceStorageLabel, resourceStorageLocation, resourceStorageTitle } f
|
||||
import { formatBytes, readFileAsDataUrl } from "@/lib/image-utils";
|
||||
import { uploadImage } from "@/services/image-storage";
|
||||
import { uploadMediaFile } from "@/services/file-storage";
|
||||
import { useAssetStore, type Asset, type AssetKind, type ImageAsset } from "@/stores/use-asset-store";
|
||||
import { useAssetStore, type Asset, type AssetCategory, type AssetKind, type ImageAsset } from "@/stores/use-asset-store";
|
||||
import { exportAssets, readAssetPackage } from "./asset-transfer";
|
||||
import { deleteAssetWithRemoteSync } from "@/services/user-data-sync";
|
||||
|
||||
type AssetFormValues = {
|
||||
kind: AssetKind;
|
||||
category: AssetCategory;
|
||||
title: string;
|
||||
coverUrl: string;
|
||||
tags: string[];
|
||||
@@ -30,9 +33,21 @@ const kindOptions = [
|
||||
{ label: "文本", value: "text" },
|
||||
{ label: "图片", value: "image" },
|
||||
{ label: "视频", value: "video" },
|
||||
{ label: "音频", value: "audio" },
|
||||
{ label: "3D 模型", value: "model" },
|
||||
];
|
||||
|
||||
const categoryOptions = [
|
||||
{ label: "全部分类", value: "all" },
|
||||
{ label: "角色", value: "character" },
|
||||
{ label: "场景", value: "environment" },
|
||||
{ label: "服饰", value: "wardrobe" },
|
||||
{ label: "道具", value: "prop" },
|
||||
{ label: "武器", value: "weapon" },
|
||||
{ label: "画风", value: "style" },
|
||||
{ label: "其他", value: "other" },
|
||||
];
|
||||
|
||||
export default function AssetsPage() {
|
||||
const { message } = App.useApp();
|
||||
const copyText = useCopyText();
|
||||
@@ -44,31 +59,37 @@ export default function AssetsPage() {
|
||||
const assets = useAssetStore((state) => state.assets);
|
||||
const addAsset = useAssetStore((state) => state.addAsset);
|
||||
const updateAsset = useAssetStore((state) => state.updateAsset);
|
||||
const removeAsset = useAssetStore((state) => state.removeAsset);
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [kindFilter, setKindFilter] = useState<AssetKind | "all">("all");
|
||||
const [categoryFilter, setCategoryFilter] = useState<AssetCategory | "all">("all");
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(20);
|
||||
const [editingAsset, setEditingAsset] = useState<Asset | null>(null);
|
||||
const [isAssetOpen, setIsAssetOpen] = useState(false);
|
||||
const [previewAsset, setPreviewAsset] = useState<Asset | null>(null);
|
||||
const [deletingAsset, setDeletingAsset] = useState<Asset | null>(null);
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||
const [batchDeleteOpen, setBatchDeleteOpen] = useState(false);
|
||||
const [formKind, setFormKind] = useState<AssetKind>("text");
|
||||
const [imageDraft, setImageDraft] = useState<ImageDraft>(null);
|
||||
const coverUrl = Form.useWatch("coverUrl", form) || "";
|
||||
const title = Form.useWatch("title", form) || "";
|
||||
const tags = Form.useWatch("tags", form) || [];
|
||||
const content = Form.useWatch("content", form) || "";
|
||||
const validAssets = useMemo(() => assets.filter((asset) => asset.kind === "text" || asset.kind === "image" || asset.kind === "video" || asset.kind === "model"), [assets]);
|
||||
const validAssets = useMemo(() => assets.filter((asset) => asset.kind === "text" || asset.kind === "image" || asset.kind === "video" || asset.kind === "audio" || asset.kind === "model"), [assets]);
|
||||
const selectedAssets = useMemo(() => validAssets.filter((asset) => selectedIds.includes(asset.id)), [selectedIds, validAssets]);
|
||||
const kindCounts = useMemo(() => new Map(kindOptions.map((option) => [option.value, option.value === "all" ? validAssets.length : validAssets.filter((asset) => asset.kind === option.value).length])), [validAssets]);
|
||||
const categoryCounts = useMemo(() => new Map(categoryOptions.map((option) => [option.value, option.value === "all" ? validAssets.length : validAssets.filter((asset) => (asset.category || "other") === option.value).length])), [validAssets]);
|
||||
|
||||
const filteredAssets = useMemo(() => {
|
||||
const query = keyword.trim().toLowerCase();
|
||||
return validAssets.filter((asset) => {
|
||||
if (kindFilter !== "all" && asset.kind !== kindFilter) return false;
|
||||
if (categoryFilter !== "all" && (asset.category || "other") !== categoryFilter) return false;
|
||||
if (!query) return true;
|
||||
return assetSearchText(asset).includes(query);
|
||||
});
|
||||
}, [validAssets, keyword, kindFilter]);
|
||||
}, [validAssets, keyword, kindFilter, categoryFilter]);
|
||||
|
||||
const visibleAssets = useMemo(() => {
|
||||
const start = (page - 1) * pageSize;
|
||||
@@ -80,11 +101,16 @@ export default function AssetsPage() {
|
||||
setPage((value) => Math.min(value, maxPage));
|
||||
}, [filteredAssets.length, pageSize]);
|
||||
|
||||
useEffect(() => {
|
||||
const existingIds = new Set(validAssets.map((asset) => asset.id));
|
||||
setSelectedIds((current) => current.filter((id) => existingIds.has(id)));
|
||||
}, [validAssets]);
|
||||
|
||||
const openCreate = () => {
|
||||
setEditingAsset(null);
|
||||
setImageDraft(null);
|
||||
setFormKind("text");
|
||||
form.setFieldsValue({ kind: "text", title: "", coverUrl: "", tags: [], source: "手动添加", note: "", content: "" });
|
||||
form.setFieldsValue({ kind: "text", category: "other", title: "", coverUrl: "", tags: [], source: "手动添加", note: "", content: "" });
|
||||
setIsAssetOpen(true);
|
||||
};
|
||||
|
||||
@@ -94,6 +120,7 @@ export default function AssetsPage() {
|
||||
setImageDraft(asset.kind === "image" ? asset.data : null);
|
||||
form.setFieldsValue({
|
||||
kind: asset.kind,
|
||||
category: asset.category || "other",
|
||||
title: asset.title,
|
||||
coverUrl: asset.coverUrl,
|
||||
tags: asset.tags || [],
|
||||
@@ -108,6 +135,9 @@ export default function AssetsPage() {
|
||||
const values = await form.validateFields();
|
||||
const base = {
|
||||
title: values.title.trim(),
|
||||
category: values.category,
|
||||
status: editingAsset?.status || "confirmed" as const,
|
||||
primaryVersionId: editingAsset?.primaryVersionId,
|
||||
coverUrl: values.coverUrl?.trim() || (values.kind === "image" && imageDraft ? imageDraft.dataUrl : ""),
|
||||
tags: values.tags || [],
|
||||
source: values.source?.trim(),
|
||||
@@ -159,7 +189,7 @@ export default function AssetsPage() {
|
||||
};
|
||||
|
||||
const downloadImage = (asset: Asset) => {
|
||||
if (asset.kind !== "image" && asset.kind !== "video" && asset.kind !== "model") return;
|
||||
if (asset.kind !== "image" && asset.kind !== "video" && asset.kind !== "audio" && asset.kind !== "model") return;
|
||||
const url = asset.kind === "image" ? asset.data.dataUrl : asset.data.url;
|
||||
const extension = asset.kind === "model" ? asset.data.fileName.split(".").pop() || "glb" : asset.data.mimeType.split("/")[1] || "png";
|
||||
saveAs(url, `${asset.title || "asset"}.${extension}`);
|
||||
@@ -192,50 +222,84 @@ export default function AssetsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const confirmDelete = () => {
|
||||
const confirmDelete = async () => {
|
||||
if (!deletingAsset) return;
|
||||
removeAsset(deletingAsset.id);
|
||||
message.success("素材已删除");
|
||||
setDeletingAsset(null);
|
||||
try {
|
||||
await deleteAssetWithRemoteSync(deletingAsset.id);
|
||||
message.success("素材已删除");
|
||||
setDeletingAsset(null);
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "素材删除失败");
|
||||
}
|
||||
};
|
||||
|
||||
const exportSelectedAssets = async () => {
|
||||
if (!selectedAssets.length) return;
|
||||
await exportAssets(selectedAssets);
|
||||
};
|
||||
|
||||
const confirmBatchDelete = async () => {
|
||||
if (!selectedAssets.length) return;
|
||||
try {
|
||||
for (const asset of selectedAssets) await deleteAssetWithRemoteSync(asset.id);
|
||||
message.success(`已删除 ${selectedAssets.length} 个素材`);
|
||||
setSelectedIds([]);
|
||||
setBatchDeleteOpen(false);
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "批量删除失败");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<WorkspacePage grid>
|
||||
<PageHeader
|
||||
title="我的素材"
|
||||
description="管理文本、图片、视频和 3D 模型素材。"
|
||||
icon="assets"
|
||||
title="素材库"
|
||||
description="管理文本、图片、视频、音频和 3D 模型素材。"
|
||||
meta={<span className="text-xs text-foreground/45">{filteredAssets.length} 个素材</span>}
|
||||
actions={(
|
||||
<>
|
||||
<Button icon={<Download className="size-4" />} onClick={() => void exportAllAssets()}>导出</Button>
|
||||
<Button icon={<FileUp className="size-4" />} onClick={() => assetInputRef.current?.click()}>导入</Button>
|
||||
<Button icon={<Upload className="size-4" />} onClick={() => modelInputRef.current?.click()}>上传 3D</Button>
|
||||
<Button icon={<Download className="size-4" />} onClick={() => void exportAllAssets()}>导出全部</Button>
|
||||
<Dropdown trigger={["click"]} menu={{ items: [{ key: "package", icon: <FileUp className="size-4" />, label: "导入素材包", onClick: () => assetInputRef.current?.click() }, { key: "model", icon: <Upload className="size-4" />, label: "上传 3D 模型", onClick: () => modelInputRef.current?.click() }] }}><Button icon={<FileUp className="size-4" />}>导入</Button></Dropdown>
|
||||
<Button type="primary" icon={<Plus className="size-4" />} onClick={openCreate}>新增素材</Button>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
<ListToolbar active={Boolean(keyword || kindFilter !== "all")} onReset={() => { setKeyword(""); setKindFilter("all"); setPage(1); }}>
|
||||
<ListToolbar active={Boolean(keyword || kindFilter !== "all" || categoryFilter !== "all")} onReset={() => { setKeyword(""); setKindFilter("all"); setCategoryFilter("all"); setPage(1); }}>
|
||||
<Input allowClear className="w-full sm:w-80" prefix={<Search className="size-4 text-foreground/40" />} value={keyword} placeholder="搜索标题、内容、标签或来源" onChange={(event) => { setPage(1); setKeyword(event.target.value); }} />
|
||||
<Select className="w-36" value={kindFilter} options={kindOptions} onChange={(value) => { setPage(1); setKindFilter(value); }} />
|
||||
</ListToolbar>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<CollectionGrid>
|
||||
{visibleAssets.map((asset) => (
|
||||
<AssetCard key={asset.id} asset={asset} onOpen={() => setPreviewAsset(asset)} onEdit={() => openEdit(asset)} onCopy={copyAssetText} onDownload={downloadImage} onDelete={() => setDeletingAsset(asset)} />
|
||||
))}
|
||||
</CollectionGrid>
|
||||
<div className="grid min-h-0 gap-4 lg:grid-cols-[176px_minmax(0,1fr)]">
|
||||
<aside className="thin-scrollbar flex gap-2 overflow-x-auto border-b border-border/70 py-3 lg:sticky lg:top-0 lg:block lg:max-h-[calc(100vh-150px)] lg:overflow-y-auto lg:border-b-0 lg:border-r lg:pr-3">
|
||||
<AssetFilterGroup title="素材类型" options={kindOptions} value={kindFilter} counts={kindCounts} onChange={(value) => { setKindFilter(value as AssetKind | "all"); setPage(1); }} />
|
||||
<AssetFilterGroup title="业务分类" options={categoryOptions} value={categoryFilter} counts={categoryCounts} onChange={(value) => { setCategoryFilter(value as AssetCategory | "all"); setPage(1); }} className="lg:mt-5" />
|
||||
</aside>
|
||||
<section className="min-w-0">
|
||||
{selectedAssets.length ? (
|
||||
<div className="mt-3 flex min-h-10 flex-wrap items-center gap-2 border-y border-border/75 py-2 text-xs">
|
||||
<strong className="mr-auto font-medium">已选择 {selectedAssets.length} 个素材</strong>
|
||||
<Button size="small" onClick={() => setSelectedIds([])}>取消选择</Button>
|
||||
<Button size="small" icon={<Download className="size-3.5" />} onClick={() => void exportSelectedAssets()}>导出</Button>
|
||||
<Button size="small" danger icon={<Trash2 className="size-3.5" />} onClick={() => setBatchDeleteOpen(true)}>删除</Button>
|
||||
</div>
|
||||
) : null}
|
||||
<CollectionGrid className="sm:grid-cols-[repeat(auto-fill,minmax(250px,1fr))] xl:grid-cols-[repeat(auto-fill,minmax(270px,1fr))]">
|
||||
{visibleAssets.map((asset) => (
|
||||
<AssetCard key={asset.id} asset={asset} selected={selectedIds.includes(asset.id)} onSelect={(selected) => setSelectedIds((current) => selected ? [...new Set([...current, asset.id])] : current.filter((id) => id !== asset.id))} onOpen={() => setPreviewAsset(asset)} onEdit={() => openEdit(asset)} onCopy={copyAssetText} onDownload={downloadImage} onDelete={() => setDeletingAsset(asset)} />
|
||||
))}
|
||||
</CollectionGrid>
|
||||
|
||||
{!visibleAssets.length ? <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="没有找到素材" className="py-20" /> : null}
|
||||
{!visibleAssets.length ? <WorkspaceState icon="assets" compact title={validAssets.length ? "没有匹配的素材" : "素材库还是空的"} description={validAssets.length ? "调整关键词或左侧分类后再试。" : "从画布保存结果,或导入已有素材包开始整理。"} action={!validAssets.length ? <Button type="primary" icon={<Plus className="size-4" />} onClick={openCreate}>新增素材</Button> : undefined} /> : null}
|
||||
|
||||
<PaginationBar current={page} pageSize={pageSize} total={filteredAssets.length} pageSizeOptions={[20, 40, 80]} onChange={(nextPage, nextPageSize) => { setPage(nextPageSize !== pageSize ? 1 : nextPage); setPageSize(nextPageSize); }} />
|
||||
<PaginationBar current={page} pageSize={pageSize} total={filteredAssets.length} pageSizeOptions={[20, 40, 80]} onChange={(nextPage, nextPageSize) => { setPage(nextPageSize !== pageSize ? 1 : nextPage); setPageSize(nextPageSize); }} />
|
||||
</section>
|
||||
</div>
|
||||
</WorkspacePage>
|
||||
|
||||
<Modal title={editingAsset ? "编辑素材" : "新增素材"} open={isAssetOpen} width={980} onCancel={() => setIsAssetOpen(false)} onOk={() => void saveAsset()} okText="保存" cancelText="取消" destroyOnHidden>
|
||||
<div className="grid gap-6 pt-1 lg:grid-cols-[minmax(0,1fr)_320px]">
|
||||
<Form form={form} layout="vertical" requiredMark={false} initialValues={{ kind: "text", tags: [] }}>
|
||||
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_280px]">
|
||||
<Form form={form} layout="vertical" requiredMark={false} initialValues={{ kind: "text", category: "other", tags: [] }}>
|
||||
<Form.Item name="kind" label="类型">
|
||||
<Select
|
||||
options={[
|
||||
@@ -245,8 +309,11 @@ export default function AssetsPage() {
|
||||
onChange={(value) => setFormKind(value)}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="category" label="业务分类">
|
||||
<Select options={categoryOptions.slice(1)} />
|
||||
</Form.Item>
|
||||
<Form.Item name="title" label="标题" rules={[{ required: true, message: "请输入标题" }]}>
|
||||
<Input size="large" placeholder="给素材起一个容易检索的名字" />
|
||||
<Input placeholder="给素材起一个容易检索的名字" />
|
||||
</Form.Item>
|
||||
<Form.Item name="coverUrl" label="封面 URL">
|
||||
<Space.Compact className="w-full">
|
||||
@@ -290,15 +357,15 @@ export default function AssetsPage() {
|
||||
</Form.Item>
|
||||
)}
|
||||
</Form>
|
||||
<div className="rounded-xl border border-stone-200 bg-stone-50 p-4 dark:border-stone-800 dark:bg-stone-950">
|
||||
<Typography.Text strong>预览</Typography.Text>
|
||||
<div className="mt-3 overflow-hidden rounded-lg border border-stone-200 bg-background dark:border-stone-800">
|
||||
<div className="lg:border-l lg:border-border lg:pl-4">
|
||||
<Typography.Text strong className="text-xs">预览</Typography.Text>
|
||||
<div className="mt-2 overflow-hidden rounded-md bg-stone-100 dark:bg-stone-900">
|
||||
{coverUrl || imageDraft?.dataUrl ? (
|
||||
<img src={coverUrl || imageDraft?.dataUrl} alt="" loading="lazy" decoding="async" className="aspect-[4/3] w-full object-cover" />
|
||||
) : (
|
||||
<div className="flex aspect-[4/3] items-center justify-center bg-stone-100 p-5 text-center text-sm text-stone-500 dark:bg-stone-900">{content || "暂无封面"}</div>
|
||||
)}
|
||||
<div className="p-4">
|
||||
<div className="border-x border-b border-border bg-background p-3">
|
||||
<Typography.Text strong ellipsis className="block">
|
||||
{title || "未命名素材"}
|
||||
</Typography.Text>
|
||||
@@ -344,31 +411,27 @@ export default function AssetsPage() {
|
||||
<input ref={assetInputRef} type="file" accept="application/zip,.zip" className="hidden" onChange={(event) => void importAssetZip(event.target.files?.[0])} />
|
||||
<input ref={modelInputRef} type="file" accept=".glb,.gltf,model/gltf-binary,model/gltf+json" className="hidden" onChange={(event) => { void readModelFile(event.target.files?.[0]); event.currentTarget.value = ""; }} />
|
||||
|
||||
<Modal title="删除素材" open={Boolean(deletingAsset)} onCancel={() => setDeletingAsset(null)} onOk={confirmDelete} okText="删除" okButtonProps={{ danger: true }} cancelText="取消">
|
||||
<Modal title="删除素材" open={Boolean(deletingAsset)} onCancel={() => setDeletingAsset(null)} onOk={() => void confirmDelete()} okText="删除" okButtonProps={{ danger: true }} cancelText="取消">
|
||||
确定删除「{deletingAsset?.title}」吗?删除后会从我的素材中移除。
|
||||
</Modal>
|
||||
<Modal title="批量删除素材" open={batchDeleteOpen} onCancel={() => setBatchDeleteOpen(false)} onOk={() => void confirmBatchDelete()} okText="删除" okButtonProps={{ danger: true }} cancelText="取消">
|
||||
确定删除已选择的 {selectedAssets.length} 个素材吗?此操作会同步移除远端记录。
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function AssetCard({ asset, onOpen, onEdit, onCopy, onDownload, onDelete }: { asset: Asset; onOpen: () => void; onEdit: () => void; onCopy: (asset: Asset) => void; onDownload: (asset: Asset) => void; onDelete: () => void }) {
|
||||
function AssetCard({ asset, selected, onSelect, onOpen, onEdit, onCopy, onDownload, onDelete }: { asset: Asset; selected: boolean; onSelect: (selected: boolean) => void; onOpen: () => void; onEdit: () => void; onCopy: (asset: Asset) => void; onDownload: (asset: Asset) => void; onDelete: () => void }) {
|
||||
const cover = asset.coverUrl || (asset.kind === "image" ? asset.data.dataUrl : "");
|
||||
const summary = assetSummary(asset);
|
||||
return (
|
||||
<Card
|
||||
hoverable
|
||||
className="app-collection-card h-full overflow-hidden"
|
||||
styles={{ body: { padding: 0 } }}
|
||||
cover={
|
||||
<article className={`app-collection-card group h-full overflow-hidden ${selected ? "border-foreground/45" : ""}`}>
|
||||
<div className="relative">
|
||||
<button type="button" className="block w-full text-left" onClick={onOpen}>
|
||||
{cover ? (
|
||||
<img src={cover} alt={asset.title} loading="lazy" decoding="async" className="aspect-[16/10] w-full object-cover" />
|
||||
) : (
|
||||
<div className="flex aspect-[16/10] items-center justify-center bg-stone-100 p-4 text-center text-xs leading-5 text-stone-600 dark:bg-stone-900 dark:text-stone-300">{asset.kind === "text" ? asset.data.content : "暂无封面"}</div>
|
||||
)}
|
||||
{cover ? <img src={cover} alt={asset.title} loading="lazy" decoding="async" className="aspect-[16/10] w-full object-cover" /> : <div className="flex aspect-[16/10] items-center justify-center bg-stone-100 p-4 text-center text-xs leading-5 text-stone-600 dark:bg-stone-900 dark:text-stone-300">{asset.kind === "text" ? asset.data.content : "暂无封面"}</div>}
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<input type="checkbox" checked={selected} onClick={(event) => event.stopPropagation()} onChange={(event) => onSelect(event.target.checked)} className={`absolute left-2 top-2 size-4 accent-[var(--workspace-accent)] ${selected ? "opacity-100" : "opacity-100 sm:opacity-0 sm:group-hover:opacity-100 sm:group-focus-within:opacity-100"}`} aria-label={`选择 ${asset.title}`} />
|
||||
</div>
|
||||
<button type="button" className="block w-full text-left" onClick={onOpen}>
|
||||
<div className="p-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
@@ -379,33 +442,32 @@ function AssetCard({ asset, onOpen, onEdit, onCopy, onDownload, onDelete }: { as
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<span className="flex shrink-0 flex-wrap justify-end gap-1">
|
||||
<Tag className="m-0 text-[11px]">{asset.kind === "image" ? "图片" : asset.kind === "video" ? "视频" : asset.kind === "model" ? "3D 模型" : "文本"}</Tag>
|
||||
<StorageTag asset={asset} />
|
||||
<span className="rounded bg-foreground/[.055] px-1.5 py-0.5 text-[10px] text-foreground/55">{assetKindLabel(asset.kind)}</span>
|
||||
<span className="rounded bg-foreground/[.055] px-1.5 py-0.5 text-[10px] text-foreground/55">{assetCategoryLabel(asset.category)}</span>
|
||||
</span>
|
||||
</div>
|
||||
<Typography.Paragraph type="secondary" ellipsis={{ rows: 2 }} className="!mb-0 !mt-2 !text-xs !leading-5">
|
||||
{summary}
|
||||
</Typography.Paragraph>
|
||||
<div className="mt-2 flex items-center justify-between gap-2 text-[10px] text-foreground/42"><span className="truncate">{assetProjectLabel(asset)}</span><span className="shrink-0">{formatAssetTime(asset.updatedAt)}</span></div>
|
||||
<div className="mt-3 flex flex-wrap gap-1.5">
|
||||
{(asset.tags || []).slice(0, 3).map((tag) => (
|
||||
<Tag key={tag} className="m-0 text-[11px]">
|
||||
{tag}
|
||||
</Tag>
|
||||
<span key={tag} className="rounded bg-foreground/[.055] px-1.5 py-0.5 text-[10px] text-foreground/55">{tag}</span>
|
||||
))}
|
||||
{!asset.tags?.length ? <Tag className="m-0 text-[11px]">无标签</Tag> : null}
|
||||
{!asset.tags?.length ? <span className="text-[10px] text-foreground/35">无标签</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<div className="flex items-center justify-between gap-2 px-3 pb-3">
|
||||
<div className="flex items-center justify-between gap-2 border-t border-border/70 px-3 py-2.5">
|
||||
<Button size="small" onClick={onOpen}>
|
||||
查看
|
||||
</Button>
|
||||
<Dropdown
|
||||
trigger={["click"]}
|
||||
menu={{ items: [
|
||||
...(asset.kind !== "video" && asset.kind !== "model" ? [{ key: "edit", icon: <PencilLine className="size-3.5" />, label: "编辑", onClick: onEdit }] : []),
|
||||
...(asset.kind !== "video" && asset.kind !== "audio" && asset.kind !== "model" ? [{ key: "edit", icon: <PencilLine className="size-3.5" />, label: "编辑", onClick: onEdit }] : []),
|
||||
...(asset.kind === "text" ? [{ key: "copy", icon: <Copy className="size-3.5" />, label: "复制文本", onClick: () => void onCopy(asset) }] : []),
|
||||
...(asset.kind === "image" || asset.kind === "video" || asset.kind === "model" ? [{ key: "download", icon: <Download className="size-3.5" />, label: "下载", onClick: () => onDownload(asset) }] : []),
|
||||
...(asset.kind === "image" || asset.kind === "video" || asset.kind === "audio" || asset.kind === "model" ? [{ key: "download", icon: <Download className="size-3.5" />, label: "下载", onClick: () => onDownload(asset) }] : []),
|
||||
{ type: "divider" as const },
|
||||
{ key: "delete", danger: true, icon: <Trash2 className="size-3.5" />, label: "删除", onClick: onDelete },
|
||||
] }}
|
||||
@@ -413,7 +475,22 @@ function AssetCard({ asset, onOpen, onEdit, onCopy, onDownload, onDelete }: { as
|
||||
<Button size="small" aria-label="更多素材操作" icon={<MoreHorizontal className="size-4" />} />
|
||||
</Dropdown>
|
||||
</div>
|
||||
</Card>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function AssetFilterGroup({ title, options, value, counts, onChange, className = "" }: { title: string; options: Array<{ label: string; value: string }>; value: string; counts: Map<string, number>; onChange: (value: string) => void; className?: string }) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="mb-1.5 px-1 text-[10px] font-semibold uppercase tracking-[0.08em] text-foreground/38">{title}</div>
|
||||
<div className="flex gap-1 lg:block">
|
||||
{options.map((option) => (
|
||||
<button key={option.value} type="button" className={`flex h-8 shrink-0 items-center justify-between gap-3 rounded px-2 text-xs transition-colors lg:mb-0.5 lg:w-full ${value === option.value ? "bg-foreground/[.08] font-medium text-foreground" : "text-foreground/56 hover:bg-foreground/[.04] hover:text-foreground"}`} onClick={() => onChange(option.value)}>
|
||||
<span>{option.label}</span><span className="rounded bg-foreground/[.06] px-1.5 py-0.5 text-[10px] tabular-nums text-foreground/45">{counts.get(option.value) || 0}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -433,7 +510,8 @@ function AssetDrawer({ asset, onClose, onCopy, onDownload }: { asset: Asset | nu
|
||||
{asset.title}
|
||||
</Typography.Title>
|
||||
<Space size={[4, 4]} wrap>
|
||||
<Tag>{asset.kind === "image" ? "图片" : asset.kind === "video" ? "视频" : asset.kind === "model" ? "3D 模型" : "文本"}</Tag>
|
||||
<Tag>{assetKindLabel(asset.kind)}</Tag>
|
||||
<Tag>{assetCategoryLabel(asset.category)}</Tag>
|
||||
<StorageTag asset={asset} />
|
||||
{(asset.tags || []).map((tag) => (
|
||||
<Tag key={tag}>{tag}</Tag>
|
||||
@@ -448,6 +526,8 @@ function AssetDrawer({ asset, onClose, onCopy, onDownload }: { asset: Asset | nu
|
||||
<Typography.Paragraph className="mt-2 whitespace-pre-wrap">{asset.data.content}</Typography.Paragraph>
|
||||
) : asset.kind === "video" ? (
|
||||
<video src={asset.data.url} controls className="mt-2 aspect-video w-full rounded-lg bg-black" />
|
||||
) : asset.kind === "audio" ? (
|
||||
<audio src={asset.data.url} controls className="mt-2 w-full" />
|
||||
) : asset.kind === "model" ? (
|
||||
<Typography.Text className="mt-2 block">{asset.data.fileName} · {formatBytes(asset.data.bytes)} · {asset.data.mimeType}</Typography.Text>
|
||||
) : (
|
||||
@@ -455,7 +535,7 @@ function AssetDrawer({ asset, onClose, onCopy, onDownload }: { asset: Asset | nu
|
||||
{asset.data.width}x{asset.data.height} · {formatBytes(asset.data.bytes)} · {asset.data.mimeType}
|
||||
</Typography.Text>
|
||||
)}
|
||||
{asset.kind === "image" || asset.kind === "video" || asset.kind === "model" ? (
|
||||
{asset.kind === "image" || asset.kind === "video" || asset.kind === "audio" || asset.kind === "model" ? (
|
||||
<Typography.Text type="secondary" className="mt-2 block text-xs">
|
||||
存储位置:{resourceStorageTitle(asset.data.storageKey)}
|
||||
</Typography.Text>
|
||||
@@ -473,9 +553,9 @@ function AssetDrawer({ asset, onClose, onCopy, onDownload }: { asset: Asset | nu
|
||||
复制文本
|
||||
</Button>
|
||||
) : null}
|
||||
{asset.kind === "image" || asset.kind === "video" || asset.kind === "model" ? (
|
||||
{asset.kind === "image" || asset.kind === "video" || asset.kind === "audio" || asset.kind === "model" ? (
|
||||
<Button type="primary" icon={<Download className="size-4" />} onClick={() => onDownload(asset)}>
|
||||
{asset.kind === "video" ? "下载视频" : asset.kind === "model" ? "下载模型" : "下载图片"}
|
||||
{asset.kind === "video" ? "下载视频" : asset.kind === "audio" ? "下载音频" : asset.kind === "model" ? "下载模型" : "下载图片"}
|
||||
</Button>
|
||||
) : null}
|
||||
</Space>
|
||||
@@ -487,12 +567,13 @@ function AssetDrawer({ asset, onClose, onCopy, onDownload }: { asset: Asset | nu
|
||||
|
||||
function assetSummary(asset: Asset) {
|
||||
if (asset.kind === "text") return asset.data.content;
|
||||
if (asset.kind === "audio") return `${formatAssetDuration(asset.data.durationMs)} · ${formatBytes(asset.data.bytes)} · ${asset.data.mimeType}`;
|
||||
if (asset.kind === "model") return `${asset.data.fileName} · ${formatBytes(asset.data.bytes)} · ${asset.data.mimeType}`;
|
||||
return `${asset.data.width}x${asset.data.height} · ${formatBytes(asset.data.bytes)} · ${asset.data.mimeType}`;
|
||||
}
|
||||
|
||||
function StorageTag({ asset }: { asset: Asset }) {
|
||||
if (asset.kind !== "image" && asset.kind !== "video" && asset.kind !== "model") return null;
|
||||
if (asset.kind !== "image" && asset.kind !== "video" && asset.kind !== "audio" && asset.kind !== "model") return null;
|
||||
const location = resourceStorageLocation(asset.data.storageKey);
|
||||
const color = location === "oss" ? "green" : location === "local" ? "gold" : "default";
|
||||
return (
|
||||
@@ -503,5 +584,29 @@ function StorageTag({ asset }: { asset: Asset }) {
|
||||
}
|
||||
|
||||
function assetSearchText(asset: Asset) {
|
||||
return [asset.title, asset.source || "", asset.note || "", (asset.tags || []).join(" "), asset.kind === "text" ? asset.data.content : asset.data.mimeType].join(" ").toLowerCase();
|
||||
return [asset.title, asset.source || "", asset.note || "", assetCategoryLabel(asset.category), (asset.tags || []).join(" "), asset.kind === "text" ? asset.data.content : asset.data.mimeType].join(" ").toLowerCase();
|
||||
}
|
||||
|
||||
function assetCategoryLabel(category?: AssetCategory) {
|
||||
return categoryOptions.find((item) => item.value === (category || "other"))?.label || "其他";
|
||||
}
|
||||
|
||||
function assetProjectLabel(asset: Asset) {
|
||||
const projectName = asset.metadata?.projectName;
|
||||
if (typeof projectName === "string" && projectName.trim()) return projectName;
|
||||
return Array.isArray(asset.metadata?.projectIds) && asset.metadata.projectIds.length ? "已关联项目" : "未关联项目";
|
||||
}
|
||||
|
||||
function assetKindLabel(kind: AssetKind) {
|
||||
return kind === "image" ? "图片" : kind === "video" ? "视频" : kind === "audio" ? "音频" : kind === "model" ? "3D 模型" : "文本";
|
||||
}
|
||||
|
||||
function formatAssetDuration(durationMs?: number) {
|
||||
if (!durationMs) return "时长未知";
|
||||
return `${Math.round(durationMs / 100) / 10} 秒`;
|
||||
}
|
||||
|
||||
function formatAssetTime(value: string) {
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? "-" : date.toLocaleDateString("zh-CN", { month: "2-digit", day: "2-digit" });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user