From b6c6dedfec66b6946243217afa67a68199df8fe3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=81=B5=E5=BA=94?= Date: Tue, 30 Jun 2026 23:19:20 +0800 Subject: [PATCH 01/73] =?UTF-8?q?feat(ui):=20=E6=94=B9=E9=80=A0=E7=B4=A0?= =?UTF-8?q?=E6=9D=90=E5=BA=93=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 两栏布局:左侧 260px 素材库列表 + 右侧素材网格 - AssetCard 组件:9:16 缩略图、渐变背景、播放按钮、StatusPill 状态标签 - 筛选功能:按类型(视频/配音/图片)、时间(今天/周/月)、名称搜索 - 批量操作:全选、取消选择、批量删除 - 拖拽上传区域,支持 2GB 限制校验 - 新建/删除素材库功能 - 响应式适配:1400px→3列, 1200px→单栏, 768px→2列, 480px→1列 - 使用 mock 数据,CSS 变量统一主题 --- apps/web/src/pages/assets/AssetLibrary.tsx | 836 ++++++++++++--------- apps/web/src/pages/assets/assets.css | 454 +++++++++++ 2 files changed, 937 insertions(+), 353 deletions(-) create mode 100644 apps/web/src/pages/assets/assets.css diff --git a/apps/web/src/pages/assets/AssetLibrary.tsx b/apps/web/src/pages/assets/AssetLibrary.tsx index 9b8da2a68..3c242fcc5 100644 --- a/apps/web/src/pages/assets/AssetLibrary.tsx +++ b/apps/web/src/pages/assets/AssetLibrary.tsx @@ -1,400 +1,530 @@ /** - * 素材库页面 - * 展示用户所有素材,支持响应式上传 + * 素材库页面 — V21 设计系统 + * 两栏布局:左侧素材库列表(260px)+ 右侧素材网格 + * 使用 mock 数据,后端 API 对接暂不要求 */ -import React, { useState } from "react"; -import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import React, { useMemo, useState } from "react"; import { - Card, - Button, Upload, + Modal as AntModal, message, - Typography, - Space, - Tabs, - Empty, - Spin, - Modal, - Select, - Input, - Row, - Col, - Tag, - Image, Popconfirm, } from "antd"; import { PlusOutlined, - DeleteOutlined, - VideoCameraOutlined, - PictureOutlined, - AudioOutlined, + SearchOutlined, InboxOutlined, + VideoCameraOutlined, + SoundOutlined, + PictureOutlined, + PlayCircleOutlined, + CheckOutlined, + DeleteOutlined, + ExperimentOutlined, } 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"; +import { Button, Input, Select } from "@/components/ui"; +import "./assets.css"; -const { Text } = Typography; -const { Dragger } = Upload; +/* ============================================================ + * 类型 + * ============================================================ */ +type AssetKind = "video" | "voice" | "image"; +type StatusType = "ok" | "warn" | "bad" | "info"; -/** 最大文件大小: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 +interface LibraryItem { + id: string; + name: string; + kind: AssetKind; + count: number; +} -/** 素材库类型图标 */ -const KindIcon: React.FC<{ kind: string }> = ({ kind }) => { +interface AssetItem { + id: string; + name: string; + kind: AssetKind; + thumbUrl?: string; + status: StatusType; + statusLabel: string; + duration?: string; + size: number; + createdAt: string; +} + +/* ============================================================ + * Mock 数据 + * ============================================================ */ +const MOCK_LIBRARIES: LibraryItem[] = [ + { id: "lib-1", name: "产品演示视频", kind: "video", count: 12 }, + { id: "lib-2", name: "品牌配音素材", kind: "voice", count: 8 }, + { id: "lib-3", name: "营销图片素材", kind: "image", count: 24 }, + { id: "lib-4", name: "用户访谈录像", kind: "video", count: 5 }, +]; + +const MOCK_ASSETS: AssetItem[] = [ + { id: "a-1", name: "产品功能演示_v2.mp4", kind: "video", status: "ok", statusLabel: "合格", duration: "02:34", size: 128, createdAt: "2026-06-28" }, + { id: "a-2", name: "品牌宣传片_终版.mp4", kind: "video", status: "warn", statusLabel: "待优化", duration: "05:12", size: 356, createdAt: "2026-06-27" }, + { id: "a-3", name: "用户案例_张三.mp4", kind: "video", status: "bad", statusLabel: "不合格", duration: "01:48", size: 96, createdAt: "2026-06-26" }, + { id: "a-4", name: "功能讲解_配音.mp3", kind: "voice", status: "ok", statusLabel: "合格", duration: "03:22", size: 48, createdAt: "2026-06-25" }, + { id: "a-5", name: "旁白_中文版.wav", kind: "voice", status: "info", statusLabel: "处理中", duration: "04:05", size: 72, createdAt: "2026-06-24" }, + { id: "a-6", name: "Banner_春季活动.png", kind: "image", status: "ok", statusLabel: "合格", size: 2.4, createdAt: "2026-06-23" }, + { id: "a-7", name: "产品截图_首页.png", kind: "image", status: "ok", statusLabel: "合格", size: 1.8, createdAt: "2026-06-22" }, + { id: "a-8", name: "社交媒体封面.jpg", kind: "image", status: "warn", statusLabel: "待优化", size: 3.2, createdAt: "2026-06-21" }, + { id: "a-9", name: "操作指南_录屏.mp4", kind: "video", status: "ok", statusLabel: "合格", duration: "08:15", size: 512, createdAt: "2026-06-20" }, + { id: "a-10", name: "背景音乐_轻快.mp3", kind: "voice", status: "ok", statusLabel: "合格", duration: "02:00", size: 36, createdAt: "2026-06-19" }, + { id: "a-11", name: "Logo_高清.png", kind: "image", status: "ok", statusLabel: "合格", size: 0.8, createdAt: "2026-06-18" }, + { id: "a-12", name: "活动预告_短视频.mp4", kind: "video", status: "info", statusLabel: "处理中", duration: "00:30", size: 64, createdAt: "2026-06-17" }, +]; + +const MAX_FILE_SIZE = 2048 * 1024 * 1024; +const LARGE_FILE_THRESHOLD = 100 * 1024 * 1024; + +/* ============================================================ + * 工具函数 + * ============================================================ */ +const kindIcon = (kind: AssetKind) => { switch (kind) { - case "video": - return ; - case "voice": - return ; - case "image": - return ; - default: - return ; + case "video": return ; + case "voice": return ; + case "image": return ; } }; -/** 素材库类型标签 */ -const kindLabel: Record = { - video: "视频", - voice: "配音", - image: "图片", +const kindLabel = (kind: AssetKind) => { + switch (kind) { + case "video": return "视频"; + case "voice": return "配音"; + case "image": return "图片"; + } }; +/** 根据素材类型返回渐变背景 */ +const thumbGradient = (kind: AssetKind): string => { + switch (kind) { + case "video": + return "linear-gradient(135deg, #312e81 0%, #4f46e5 50%, #6366f1 100%)"; + case "voice": + return "linear-gradient(135deg, #064e3b 0%, #059669 50%, #10b981 100%)"; + case "image": + return "linear-gradient(135deg, #78350f 0%, #d97706 50%, #f59e0b 100%)"; + } +}; + +/* ============================================================ + * StatusPill 组件 + * ============================================================ */ +const StatusPill: React.FC<{ status: StatusType; label: string }> = ({ + status, + label, +}) => ( + + {label} + +); + +/* ============================================================ + * AssetCard 组件 + * ============================================================ */ +const AssetCard: React.FC<{ + asset: AssetItem; + selected: boolean; + onToggle: () => void; + onDiagnose: () => void; +}> = ({ asset, selected, onToggle, onDiagnose }) => ( +
+ {/* 缩略图区 */} +
+ {asset.thumbUrl ? ( + {asset.name} + ) : ( + + {kindIcon(asset.kind)} + + )} + + {/* 视频/配音类显示播放按钮 */} + {(asset.kind === "video" || asset.kind === "voice") && ( + + + + )} + + {/* 选中态勾选 */} + {selected && ( + + + + )} +
+ + {/* 信息区 */} +
+

+ {asset.name} +

+
+ + {asset.duration && {asset.duration}} +
+ +
+
+); + +/* ============================================================ + * 主组件 + * ============================================================ */ const AssetLibrary: React.FC = () => { - const queryClient = useQueryClient(); - const [activeLibrary, setActiveLibrary] = useState(""); - const [createModalOpen, setCreateModalOpen] = useState(false); - const [newLibName, setNewLibName] = useState(""); - const [newLibKind, setNewLibKind] = useState<"video" | "voice" | "image">( - "video", - ); + /* 状态 */ + const [libraries, setLibraries] = useState(MOCK_LIBRARIES); + const [activeLibId, setActiveLibId] = useState(MOCK_LIBRARIES[0].id); + const [assets] = useState(MOCK_ASSETS); + const [selectedIds, setSelectedIds] = useState>(new Set()); + + /* 筛选 */ + const [searchText, setSearchText] = useState(""); + const [filterType, setFilterType] = useState("all"); + const [filterTime, setFilterTime] = useState("all"); + + /* 上传 */ const [uploading, setUploading] = useState(false); - // 获取素材库列表 - const { data: libraries = [], isLoading: libsLoading } = useQuery({ - queryKey: ["asset-libraries"], - queryFn: getAssetLibraries, - }); + /* 新建素材库 */ + const [createModalOpen, setCreateModalOpen] = useState(false); + const [newLibName, setNewLibName] = useState(""); + const [newLibKind, setNewLibKind] = useState("video"); - // 获取当前素材库的素材 - const { data: assets = [], isLoading: assetsLoading } = useQuery({ - queryKey: ["assets", activeLibrary], - queryFn: () => getAssets(activeLibrary), - enabled: !!activeLibrary, - }); + /* 派生数据 */ + const activeLibrary = libraries.find((l) => l.id === activeLibId); - // 创建素材库 - 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 filteredAssets = useMemo(() => { + let list = assets; - // 上传素材(小文件表单上传) - 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; + /* 按素材库类型过滤 */ + if (activeLibrary) { + list = list.filter((a) => a.kind === activeLibrary.kind); } - // 文件大小校验:上限 2GB + /* 按类型筛选 */ + if (filterType !== "all") { + list = list.filter((a) => a.kind === filterType); + } + + /* 按时间筛选 */ + if (filterTime !== "all") { + const now = new Date("2026-06-30"); + list = list.filter((a) => { + const d = new Date(a.createdAt); + const diffDays = (now.getTime() - d.getTime()) / (1000 * 60 * 60 * 24); + if (filterTime === "today") return diffDays < 1; + if (filterTime === "week") return diffDays < 7; + if (filterTime === "month") return diffDays < 30; + return true; + }); + } + + /* 搜索 */ + if (searchText.trim()) { + const q = searchText.trim().toLowerCase(); + list = list.filter((a) => a.name.toLowerCase().includes(q)); + } + + return list; + }, [assets, activeLibrary, filterType, filterTime, searchText]); + + /* 选择操作 */ + const toggleSelect = (id: string) => { + setSelectedIds((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }; + + const selectAll = () => { + setSelectedIds(new Set(filteredAssets.map((a) => a.id))); + }; + + const deselectAll = () => { + setSelectedIds(new Set()); + }; + + /* 上传 */ + const handleUpload = (file: File) => { if (file.size > MAX_FILE_SIZE) { - message.error("文件大小不能超过 2GB"); + message.error(`文件 "${file.name}" 超过 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); + if (file.size > LARGE_FILE_THRESHOLD) { + message.info(`大文件 "${file.name}" 将使用直传上传(mock)`); } + setUploading(true); + /* mock 上传 — 1.5s 后完成 */ + setTimeout(() => { + setUploading(false); + message.success(`"${file.name}" 上传成功`); + }, 1500); return false; }; - /** 当前选中的素材库 */ - const currentLib = libraries.find((l) => l.id === activeLibrary); + /* 新建素材库 */ + const handleCreateLibrary = () => { + if (!newLibName.trim()) { + message.warning("请输入素材库名称"); + return; + } + const lib: LibraryItem = { + id: `lib-${Date.now()}`, + name: newLibName.trim(), + kind: newLibKind, + count: 0, + }; + setLibraries((prev) => [...prev, lib]); + setActiveLibId(lib.id); + setCreateModalOpen(false); + setNewLibName(""); + setNewLibKind("video"); + message.success(`素材库 "${lib.name}" 创建成功`); + }; + + /* 删除素材库 */ + const handleDeleteLibrary = (id: string) => { + setLibraries((prev) => prev.filter((l) => l.id !== id)); + if (activeLibId === id) { + const remaining = libraries.filter((l) => l.id !== id); + if (remaining.length > 0) setActiveLibId(remaining[0].id); + } + message.success("素材库已删除"); + }; + + /* 诊断 */ + const handleDiagnose = (asset: AssetItem) => { + message.info(`正在诊断 "${asset.name}"...(mock)`); + }; + + /* 批量删除 */ + const handleBatchDelete = () => { + message.success(`已删除 ${selectedIds.size} 个素材(mock)`); + deselectAll(); + }; return ( -
- } - onClick={() => setCreateModalOpen(true)} - > - 新建素材库 - - } - /> - - {libsLoading ? ( -
- -
- ) : libraries.length === 0 ? ( - - - - ) : ( - <> - {/* 素材库标签页 */} - ({ - key: lib.id, - label: ( - - - {lib.name} - {lib.asset_count ?? 0} - - ), - }))} - style={{ marginBottom: 16 }} - /> - - {/* 上传区域 */} - {activeLibrary && ( - + {/* 两栏布局 */} +
+ {/* ─── 左侧:素材库列表 ─── */} +
+ {libraries.map((lib) => ( +
setActiveLibId(lib.id)} > -

- {uploading ? : } -

-

- {uploading - ? "正在上传,请稍候..." - : "点击或拖拽文件到此区域上传"} -

-

- 支持 {kindLabel[currentLib?.kind || "video"]}{" "} - 格式文件,单文件不超过 2GB -

- - )} - - {/* 素材列表 */} - {assetsLoading ? ( -
- -
- ) : assets.length === 0 ? ( - - ) : ( - - {assets.map((asset) => ( - - - ) : ( -
- {currentLib?.kind === "video" ? ( - - ) : ( - - )} -
- ) - } - actions={[ - deleteMutation.mutate(asset.id)} - > - - , - ]} +
+

+ {kindIcon(lib.kind)} {lib.name} +

+ { + e?.stopPropagation(); + handleDeleteLibrary(lib.id); + }} + onCancel={(e) => e?.stopPropagation()} + okText="删除" + cancelText="取消" + > + + +
+ + {kindLabel(lib.kind)} · {lib.count} 个素材 + +
+ ))} - {/* 新建素材库弹窗 */} - setCreateModalOpen(true)} + > + + 新建素材库 +
+
+ + {/* ─── 右侧:内容区 ─── */} +
+ {/* 上传区域 */} + +
+

+ +

+

+ {uploading ? "上传中..." : "点击或拖拽文件到此区域上传"} +

+

+ 支持视频、音频、图片,单文件不超过 2GB +

+
+
+ + {/* 筛选栏 */} +
+
+ } + value={searchText} + onChange={(e) => setSearchText(e.target.value)} + allowClear + style={{ width: 220 }} + /> + +
+
+ + + 共 {filteredAssets.length} 个素材 + +
+
+ + {/* 批量操作栏 */} + {selectedIds.size > 0 && ( +
+ + 已选 {selectedIds.size} 项 + + + + + +
+ )} + + {/* 素材网格 */} + {filteredAssets.length > 0 ? ( +
+ {filteredAssets.map((asset) => ( + toggleSelect(asset.id)} + onDiagnose={() => handleDiagnose(asset)} + /> + ))} +
+ ) : ( +
+
+ +
+

暂无素材,请上传或切换素材库

+
+ )} +
+
+ + {/* ─── 新建素材库弹窗 ─── */} + setCreateModalOpen(false)} - onOk={() => - createLibMutation.mutate({ name: newLibName, kind: newLibKind }) - } - confirmLoading={createLibMutation.isPending} + onOk={handleCreateLibrary} + okText="创建" + cancelText="取消" + destroyOnClose > - - setNewLibName(e.target.value)} - /> - setNewLibName(e.target.value)} + maxLength={50} + /> + +
+
+ 类型 +
+ setFormName(e.target.value)} - /> - setFormText(e.target.value)} - rows={4} - /> - } + value={searchText} + onChange={(e) => setSearchText(e.target.value)} + allowClear + style={{ width: 240 }} + /> + +
+
+ + + +
+ + + {/* 配音卡片列表 */} + {filteredVoices.length > 0 ? ( +
+ {filteredVoices.map((voice) => ( + handlePlay(voice.id)} + onPause={handlePause} + onSeek={(time) => handleSeek(voice.id, time)} + /> + ))} +
+ ) : ( +
+
+ +
+

暂无配音,请切换配音库或添加新配音

+
+ )} + + + + {/* ─── 新建配音库弹窗 ─── */} + setCreateModalOpen(false)} + onOk={handleCreateLibrary} + okText="创建" + cancelText="取消" + destroyOnClose + > +
+
+
+ 名称 +
+ setNewLibName(e.target.value)} + maxLength={50} + /> +
+
+
+ 音色类型 +
+ +
- 语速:{aiSpeed.toFixed(1)}x - + 配音文本 +
+ setAiText(e.target.value)} + rows={6} + maxLength={2000} + showCount />
- - +
+
+ 音色类型 +
+