8d185492d7
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
Deploy / Deploy Staging (push) Has been cancelled
Deploy / Build Production Runtime Images (push) Has been cancelled
Deploy / Deploy Production (push) Has been cancelled
Deploy / Production Browser E2E (push) Has been cancelled
- 新增 PageHead 组件:页面标题、面包屑导航、操作按钮区域 - 面包屑根据路由路径自动生成,支持手动覆盖 - 响应式适配:移动端隐藏面包屑,操作区换行 - 复用 global.css 中 .xx-page-head 基础样式 - 集成到所有页面:Dashboard、素材库、配音库、标题库、 任务历史、成品库、模板库、一键生成、订阅页
403 lines
13 KiB
TypeScript
403 lines
13 KiB
TypeScript
/**
|
|
* 素材库页面
|
|
* 展示用户所有素材,支持响应式上传
|
|
*/
|
|
import React, { useState } from "react";
|
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
|
import {
|
|
Card,
|
|
Button,
|
|
Upload,
|
|
message,
|
|
Typography,
|
|
Space,
|
|
Tabs,
|
|
Empty,
|
|
Spin,
|
|
Modal,
|
|
Select,
|
|
Input,
|
|
Row,
|
|
Col,
|
|
Tag,
|
|
Image,
|
|
Popconfirm,
|
|
} from "antd";
|
|
import {
|
|
PlusOutlined,
|
|
DeleteOutlined,
|
|
VideoCameraOutlined,
|
|
PictureOutlined,
|
|
AudioOutlined,
|
|
InboxOutlined,
|
|
} from "@ant-design/icons";
|
|
import {
|
|
getAssetLibraries,
|
|
createAssetLibrary,
|
|
getAssets,
|
|
deleteAsset,
|
|
uploadAsset,
|
|
uploadAssetDirect,
|
|
} from "@/api/assets";
|
|
import { getOrCreateDefaultProject } from "@/api/projects";
|
|
import PageHead from "@/components/layout/PageHead";
|
|
|
|
const { Text } = Typography;
|
|
const { Dragger } = Upload;
|
|
|
|
/** 最大文件大小:2048MB(与后端 OSS_DIRECT_UPLOAD_MAX_MB=2000 对齐,留少量余量) */
|
|
const MAX_FILE_SIZE = 2048 * 1024 * 1024; // 2147483648 bytes
|
|
/** 大文件阈值:100MB,超过此值走 OSS 直传通道 */
|
|
const LARGE_FILE_THRESHOLD = 100 * 1024 * 1024; // 104857600 bytes
|
|
|
|
/** 素材库类型图标 */
|
|
const KindIcon: React.FC<{ kind: string }> = ({ kind }) => {
|
|
switch (kind) {
|
|
case "video":
|
|
return <VideoCameraOutlined style={{ color: "#1890ff" }} />;
|
|
case "voice":
|
|
return <AudioOutlined style={{ color: "#52c41a" }} />;
|
|
case "image":
|
|
return <PictureOutlined style={{ color: "#faad14" }} />;
|
|
default:
|
|
return <VideoCameraOutlined />;
|
|
}
|
|
};
|
|
|
|
/** 素材库类型标签 */
|
|
const kindLabel: Record<string, string> = {
|
|
video: "视频",
|
|
voice: "配音",
|
|
image: "图片",
|
|
};
|
|
|
|
const AssetLibrary: React.FC = () => {
|
|
const queryClient = useQueryClient();
|
|
const [activeLibrary, setActiveLibrary] = useState<string>("");
|
|
const [createModalOpen, setCreateModalOpen] = useState(false);
|
|
const [newLibName, setNewLibName] = useState("");
|
|
const [newLibKind, setNewLibKind] = useState<"video" | "voice" | "image">(
|
|
"video",
|
|
);
|
|
const [uploading, setUploading] = useState(false);
|
|
|
|
// 获取素材库列表
|
|
const { data: libraries = [], isLoading: libsLoading } = useQuery({
|
|
queryKey: ["asset-libraries"],
|
|
queryFn: getAssetLibraries,
|
|
});
|
|
|
|
// 获取当前素材库的素材
|
|
const { data: assets = [], isLoading: assetsLoading } = useQuery({
|
|
queryKey: ["assets", activeLibrary],
|
|
queryFn: () => getAssets(activeLibrary),
|
|
enabled: !!activeLibrary,
|
|
});
|
|
|
|
// 创建素材库
|
|
const createLibMutation = useMutation({
|
|
mutationFn: createAssetLibrary,
|
|
onSuccess: () => {
|
|
message.success("素材库创建成功");
|
|
setCreateModalOpen(false);
|
|
setNewLibName("");
|
|
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] });
|
|
},
|
|
onError: (err: unknown) => {
|
|
if (!(err as { __msgShown?: boolean })?.__msgShown)
|
|
message.error("创建失败");
|
|
},
|
|
});
|
|
|
|
// 上传素材(小文件表单上传)
|
|
const uploadMutation = useMutation({
|
|
mutationFn: uploadAsset,
|
|
onSuccess: () => {
|
|
message.success("上传成功");
|
|
queryClient.invalidateQueries({ queryKey: ["assets", activeLibrary] });
|
|
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] });
|
|
},
|
|
onError: (err: unknown) => {
|
|
if (!(err as { __msgShown?: boolean })?.__msgShown)
|
|
message.error("上传失败");
|
|
},
|
|
});
|
|
|
|
// 大文件直传(OSS 预签名)
|
|
const directUploadMutation = useMutation({
|
|
mutationFn: uploadAssetDirect,
|
|
onSuccess: () => {
|
|
message.success("上传成功");
|
|
queryClient.invalidateQueries({ queryKey: ["assets", activeLibrary] });
|
|
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] });
|
|
},
|
|
onError: (err: unknown) => {
|
|
if (!(err as { __msgShown?: boolean })?.__msgShown)
|
|
message.error("上传失败");
|
|
},
|
|
});
|
|
|
|
// 删除素材
|
|
const deleteMutation = useMutation({
|
|
mutationFn: deleteAsset,
|
|
onSuccess: () => {
|
|
message.success("已删除");
|
|
queryClient.invalidateQueries({ queryKey: ["assets", activeLibrary] });
|
|
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] });
|
|
},
|
|
onError: (err: unknown) => {
|
|
if (!(err as { __msgShown?: boolean })?.__msgShown)
|
|
message.error("删除失败");
|
|
},
|
|
});
|
|
|
|
/** 处理上传 */
|
|
const handleUpload = async (file: File) => {
|
|
if (!activeLibrary) {
|
|
message.warning("请先选择素材库");
|
|
return false;
|
|
}
|
|
|
|
// 文件大小校验:上限 2GB
|
|
if (file.size > MAX_FILE_SIZE) {
|
|
message.error("文件大小不能超过 2GB");
|
|
return false;
|
|
}
|
|
|
|
setUploading(true);
|
|
try {
|
|
if (file.size > LARGE_FILE_THRESHOLD) {
|
|
// 大文件(>100MB)走 OSS 预签名直传通道
|
|
await directUploadMutation.mutateAsync({
|
|
file,
|
|
library_id: activeLibrary,
|
|
});
|
|
} else {
|
|
// 小文件走表单上传
|
|
const project = await getOrCreateDefaultProject();
|
|
const formData = new FormData();
|
|
formData.append("file", file);
|
|
formData.append("library_id", activeLibrary);
|
|
formData.append("project_id", project.id);
|
|
await uploadMutation.mutateAsync(formData);
|
|
}
|
|
} catch {
|
|
// mutation.onError 已处理错误提示
|
|
} finally {
|
|
setUploading(false);
|
|
}
|
|
return false;
|
|
};
|
|
|
|
/** 当前选中的素材库 */
|
|
const currentLib = libraries.find((l) => l.id === activeLibrary);
|
|
|
|
return (
|
|
<div style={{ padding: "24px", maxWidth: 1200, margin: "0 auto" }}>
|
|
<PageHead
|
|
title="素材库"
|
|
actions={
|
|
<Button
|
|
type="primary"
|
|
icon={<PlusOutlined />}
|
|
onClick={() => setCreateModalOpen(true)}
|
|
>
|
|
新建素材库
|
|
</Button>
|
|
}
|
|
/>
|
|
|
|
{libsLoading ? (
|
|
<div style={{ textAlign: "center", padding: 60 }}>
|
|
<Spin size="large" />
|
|
</div>
|
|
) : libraries.length === 0 ? (
|
|
<Empty description="暂无素材库,点击上方按钮创建">
|
|
<Button
|
|
type="primary"
|
|
icon={<PlusOutlined />}
|
|
onClick={() => setCreateModalOpen(true)}
|
|
>
|
|
创建第一个素材库
|
|
</Button>
|
|
</Empty>
|
|
) : (
|
|
<>
|
|
{/* 素材库标签页 */}
|
|
<Tabs
|
|
activeKey={activeLibrary}
|
|
onChange={setActiveLibrary}
|
|
items={libraries.map((lib) => ({
|
|
key: lib.id,
|
|
label: (
|
|
<Space>
|
|
<KindIcon kind={lib.kind} />
|
|
<span>{lib.name}</span>
|
|
<Tag>{lib.asset_count ?? 0}</Tag>
|
|
</Space>
|
|
),
|
|
}))}
|
|
style={{ marginBottom: 16 }}
|
|
/>
|
|
|
|
{/* 上传区域 */}
|
|
{activeLibrary && (
|
|
<Dragger
|
|
accept={
|
|
currentLib?.kind === "video"
|
|
? "video/*"
|
|
: currentLib?.kind === "voice"
|
|
? "audio/*"
|
|
: "image/*"
|
|
}
|
|
beforeUpload={handleUpload}
|
|
showUploadList={false}
|
|
multiple
|
|
disabled={
|
|
uploading ||
|
|
uploadMutation.isPending ||
|
|
directUploadMutation.isPending
|
|
}
|
|
style={{ marginBottom: 24 }}
|
|
>
|
|
<p className="ant-upload-drag-icon">
|
|
{uploading ? <Spin /> : <InboxOutlined />}
|
|
</p>
|
|
<p className="ant-upload-text">
|
|
{uploading
|
|
? "正在上传,请稍候..."
|
|
: "点击或拖拽文件到此区域上传"}
|
|
</p>
|
|
<p className="ant-upload-hint">
|
|
支持 {kindLabel[currentLib?.kind || "video"]}{" "}
|
|
格式文件,单文件不超过 2GB
|
|
</p>
|
|
</Dragger>
|
|
)}
|
|
|
|
{/* 素材列表 */}
|
|
{assetsLoading ? (
|
|
<div style={{ textAlign: "center", padding: 40 }}>
|
|
<Spin />
|
|
</div>
|
|
) : assets.length === 0 ? (
|
|
<Empty description="该素材库暂无素材" />
|
|
) : (
|
|
<Row gutter={[16, 16]}>
|
|
{assets.map((asset) => (
|
|
<Col xs={24} sm={12} md={8} lg={6} key={asset.id}>
|
|
<Card
|
|
hoverable
|
|
size="small"
|
|
cover={
|
|
currentLib?.kind === "image" ? (
|
|
<Image
|
|
src={`/api/v1/assets/${asset.id}/thumbnail`}
|
|
alt={asset.name}
|
|
style={{ height: 150, objectFit: "cover" }}
|
|
fallback="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwIiBoZWlnaHQ9IjE1MCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iMjAwIiBoZWlnaHQ9IjE1MCIgZmlsbD0iI2YwZjBmMCIvPjx0ZXh0IHg9IjUwJSIgeT0iNTAlIiBkb21pbmFudC1iYXNlbGluZT0ibWlkZGxlIiB0ZXh0LWFuY2hvcj0ibWlkZGxlIiBmaWxsPSIjOTk5IiBmb250LXNpemU9IjE0Ij7lm77niYfliqDovb3lpLHotKU8L3RleHQ+PC9zdmc+"
|
|
/>
|
|
) : (
|
|
<div
|
|
style={{
|
|
height: 150,
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
background: "#f5f5f5",
|
|
}}
|
|
>
|
|
{currentLib?.kind === "video" ? (
|
|
<VideoCameraOutlined
|
|
style={{ fontSize: 40, color: "#bbb" }}
|
|
/>
|
|
) : (
|
|
<AudioOutlined
|
|
style={{ fontSize: 40, color: "#bbb" }}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
actions={[
|
|
<Popconfirm
|
|
key="delete"
|
|
title="确定删除此素材?"
|
|
onConfirm={() => deleteMutation.mutate(asset.id)}
|
|
>
|
|
<DeleteOutlined />
|
|
</Popconfirm>,
|
|
]}
|
|
>
|
|
<Card.Meta
|
|
title={
|
|
<Text ellipsis style={{ maxWidth: "100%" }}>
|
|
{asset.name}
|
|
</Text>
|
|
}
|
|
description={
|
|
<Space direction="vertical" size={0}>
|
|
<Text type="secondary" style={{ fontSize: 12 }}>
|
|
{asset.file_size
|
|
? `${(asset.file_size / 1024 / 1024).toFixed(1)} MB`
|
|
: "-"}
|
|
</Text>
|
|
{asset.status && (
|
|
<Tag
|
|
color={
|
|
asset.status === "completed"
|
|
? "success"
|
|
: asset.status === "failed"
|
|
? "error"
|
|
: "processing"
|
|
}
|
|
style={{ marginTop: 4 }}
|
|
>
|
|
{asset.status}
|
|
</Tag>
|
|
)}
|
|
</Space>
|
|
}
|
|
/>
|
|
</Card>
|
|
</Col>
|
|
))}
|
|
</Row>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{/* 新建素材库弹窗 */}
|
|
<Modal
|
|
title="新建素材库"
|
|
open={createModalOpen}
|
|
onCancel={() => setCreateModalOpen(false)}
|
|
onOk={() =>
|
|
createLibMutation.mutate({ name: newLibName, kind: newLibKind })
|
|
}
|
|
confirmLoading={createLibMutation.isPending}
|
|
>
|
|
<Space direction="vertical" style={{ width: "100%" }}>
|
|
<Input
|
|
placeholder="素材库名称"
|
|
value={newLibName}
|
|
onChange={(e) => setNewLibName(e.target.value)}
|
|
/>
|
|
<Select
|
|
value={newLibKind}
|
|
onChange={setNewLibKind}
|
|
style={{ width: "100%" }}
|
|
options={[
|
|
{ value: "video", label: "视频素材" },
|
|
{ value: "voice", label: "配音素材" },
|
|
{ value: "image", label: "图片素材" },
|
|
]}
|
|
/>
|
|
</Space>
|
|
</Modal>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default AssetLibrary;
|