Files
xiaoxia-saas/apps/web/src/pages/assets/AssetLibrary.tsx
T
CI Bot 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
feat(ui): 实现页面头部组件 PageHead
- 新增 PageHead 组件:页面标题、面包屑导航、操作按钮区域
- 面包屑根据路由路径自动生成,支持手动覆盖
- 响应式适配:移动端隐藏面包屑,操作区换行
- 复用 global.css 中 .xx-page-head 基础样式
- 集成到所有页面:Dashboard、素材库、配音库、标题库、
  任务历史、成品库、模板库、一键生成、订阅页
2026-06-30 21:33:02 +08:00

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;