diff --git a/apps/web/src/pages/assets/AssetLibrary.tsx b/apps/web/src/pages/assets/AssetLibrary.tsx
index de1193f9d..64f5f09f5 100644
--- a/apps/web/src/pages/assets/AssetLibrary.tsx
+++ b/apps/web/src/pages/assets/AssetLibrary.tsx
@@ -4,36 +4,8 @@
* 使用 useQuery 对接后端真实 API(api/assets.ts)
*/
import React, { useMemo, useState } from "react"
-import {
- Upload,
- Modal as AntModal,
- message,
- Popconfirm,
- Drawer,
- Tag,
- Input as AntInput,
- Radio,
- Select as AntSelect,
-} from "antd"
-import {
- PlusOutlined,
- SearchOutlined,
- InboxOutlined,
- VideoCameraOutlined,
- PictureOutlined,
- PlayCircleOutlined,
- CheckOutlined,
- DeleteOutlined,
- ExperimentOutlined,
- LoadingOutlined,
- ExclamationCircleOutlined,
- TagsOutlined,
- FolderOutlined,
- ThunderboltOutlined,
- CheckCircleOutlined,
- CloseCircleOutlined,
- AudioOutlined,
-} from "@ant-design/icons"
+import { Upload, message } from "antd"
+import { InboxOutlined, PictureOutlined, ExclamationCircleOutlined } from "@ant-design/icons"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import {
getAssetLibraries,
@@ -51,170 +23,24 @@ import {
type AssetItem as ApiAssetItem,
type BatchOperationResult,
} from "@/api/assets"
-import { Button, Input, Select } from "@/components/ui"
-import {
- type AssetItem,
- type AssetKind,
- type StatusType,
- mapLibrary,
- mapAsset,
-} from "@/pages/assets/types"
-import {
- MAX_FILE_SIZE,
- LARGE_FILE_THRESHOLD,
- TYPE_FILTER_OPTIONS,
- TIME_FILTER_OPTIONS,
- LIBRARY_KIND_OPTIONS,
- CATEGORY_OPTIONS,
-} from "@/pages/assets/constants"
-import { kindLabel, thumbGradient } from "@/pages/assets/utils/asset"
+import { Button } from "@/components/ui"
+import { type AssetItem, type AssetKind, mapLibrary, mapAsset } from "@/pages/assets/types"
+import { MAX_FILE_SIZE, LARGE_FILE_THRESHOLD } from "@/pages/assets/constants"
+import AssetCard from "@/pages/assets/components/AssetCard"
+import { SkeletonCard } from "@/pages/assets/components/AssetSkeleton"
+import LibrarySidebar from "@/pages/assets/components/LibrarySidebar"
+import AssetFilterBar from "@/pages/assets/components/AssetFilterBar"
+import BatchOperationBar from "@/pages/assets/components/BatchOperationBar"
+import CreateLibraryModal from "@/pages/assets/components/CreateLibraryModal"
+import PlayModal from "@/pages/assets/components/PlayModal"
+import BatchTagModal from "@/pages/assets/components/BatchTagModal"
+import BatchClassifyModal from "@/pages/assets/components/BatchClassifyModal"
+import BatchMarkModal from "@/pages/assets/components/BatchMarkModal"
+import type { SmartViewType } from "@/pages/assets/components/BatchMarkModal"
+import ResultDrawer from "@/pages/assets/components/ResultDrawer"
+import UploadProgressModal from "@/pages/assets/components/UploadProgressModal"
import "./assets.css"
-/* ============================================================
- * 工具函数(UI 相关)
- * ============================================================ */
-const kindIcon = (kind: AssetKind) => {
- switch (kind) {
- case "video":
- return
- case "image":
- return
- case "voice":
- return
- }
-}
-
-/* ============================================================
- * StatusPill 组件
- * ============================================================ */
-const StatusPill: React.FC<{ status: StatusType; label: string }> = ({ status, label }) => (
- {label}
-)
-
-/* ============================================================
- * SkeletonCard — 骨架屏卡片(素材列表加载时占位)
- * ============================================================ */
-const SkeletonCard: React.FC = () => (
-
-)
-
-/* ============================================================
- * AssetCard 组件
- * ============================================================ */
-const AssetCard: React.FC<{
- asset: AssetItem
- selected: boolean
- diagnosing?: boolean
- onToggle: () => void
- onDiagnose: () => void
- onPlay: () => void
- onDelete: () => void
-}> = ({ asset, selected, diagnosing, onToggle, onDiagnose, onPlay, onDelete }) => (
-
- {/* 缩略图区 */}
-
- {asset.thumbUrl ? (
-

- ) : (
-
- {asset.loading ? : kindIcon(asset.kind)}
-
- )}
-
- {/* 处理中遮罩 */}
- {asset.loading && (
-
-
- 处理中
-
- )}
-
- {/* 失败状态标识 */}
- {asset.status === "bad" && asset.statusLabel === "处理失败" && (
-
-
- 处理失败
-
- )}
-
- {/* 视频/配音类显示播放按钮(处理中/失败不显示) */}
- {asset.kind === "video" && !asset.loading && asset.status !== "bad" && (
-
{
- e.stopPropagation()
- onPlay()
- }}
- >
-
-
- )}
-
- {/* 删除按钮 */}
-
{
- e?.stopPropagation()
- onDelete()
- }}
- onCancel={(e) => e?.stopPropagation()}
- okText="删除"
- cancelText="取消"
- okButtonProps={{ danger: true }}
- >
- e.stopPropagation()}>
-
-
-
-
- {/* 选中态勾选 */}
- {selected && (
-
-
-
- )}
-
-
- {/* 信息区 */}
-
-
- {asset.name}
-
-
-
- {asset.duration && {asset.duration}}
-
-
-
-
-)
-
/* ============================================================
* 主组件
* ============================================================ */
@@ -328,9 +154,7 @@ const AssetLibrary: React.FC = () => {
const [batchCategory, setBatchCategory] = useState("")
/* 批量智能标记 */
- const [batchSmartView, setBatchSmartView] = useState<"recommended" | "caution" | "high_risk">(
- "recommended",
- )
+ const [batchSmartView, setBatchSmartView] = useState("recommended")
/* 操作结果 */
const [operationResult, setOperationResult] = useState(null)
@@ -474,7 +298,6 @@ const AssetLibrary: React.FC = () => {
}
}
- /* 批量删除 */
/* 单个素材删除 */
const handleSingleDelete = async (assetId: string) => {
try {
@@ -493,6 +316,7 @@ const AssetLibrary: React.FC = () => {
}
}
+ /* 批量删除 */
const handleBatchDelete = async () => {
const ids = Array.from(selectedIds)
setBatchLoading(true)
@@ -604,7 +428,7 @@ const AssetLibrary: React.FC = () => {
setMarkModalOpen(false)
queryClient.invalidateQueries({ queryKey: ["assets"] })
setSelectedIds(new Set())
- const labelMap = {
+ const labelMap: Record = {
recommended: "推荐",
caution: "慎用",
high_risk: "高风险",
@@ -656,94 +480,19 @@ const AssetLibrary: React.FC = () => {
return (
- {/* ─── 上传进度弹窗(圆形动画 + 百分比) ─── */}
-
-
-
-
- {uploadProgress}%
- 上传中…
-
-
-
+ {/* ─── 上传进度弹窗 ─── */}
+
{/* 两栏布局 */}
{/* ─── 左侧:视频库列表 ─── */}
-
- {libraries.map((lib) => (
-
setActiveLibId(lib.id)}
- >
-
-
- {kindIcon(lib.kind)} {lib.name}
-
-
{
- e?.stopPropagation()
- handleDeleteLibrary(lib.id)
- }}
- onCancel={(e) => e?.stopPropagation()}
- okText="删除"
- cancelText="取消"
- >
-
-
-
-
- {kindLabel(lib.kind)} · {lib.count} 个素材
-
-
- ))}
-
- {/* 新建视频库 */}
-
setCreateModalOpen(true)}>
-
- 新建视频库
-
-
+
setCreateModalOpen(true)}
+ />
{/* ─── 右侧:内容区 ─── */}
@@ -771,79 +520,27 @@ const AssetLibrary: React.FC = () => {
{/* 筛选栏 */}
-
-
- }
- value={searchText}
- onChange={(e) => setSearchText(e.target.value)}
- allowClear
- style={{ width: 220 }}
- />
-
-
-
-
-
- 共 {filteredAssets.length} 个素材
-
-
+
{/* 批量操作栏 */}
{selectedIds.size > 0 && (
-
-
已选 {selectedIds.size} 项
-
-
}
- onClick={() => setTagModalOpen(true)}
- >
- 打标签
-
-
}
- onClick={() => setClassifyModalOpen(true)}
- >
- 改分类
-
-
}
- onClick={() => setMarkModalOpen(true)}
- >
- 智能标记
-
-
- }>
- 批量删除
-
-
-
+
setTagModalOpen(true)}
+ onClassifyClick={() => setClassifyModalOpen(true)}
+ onMarkClick={() => setMarkModalOpen(true)}
+ onBatchDelete={handleBatchDelete}
+ />
)}
{/* 素材网格 */}
@@ -890,231 +587,75 @@ const AssetLibrary: React.FC = () => {
{/* ─── 新建视频库弹窗 ─── */}
- setCreateModalOpen(false)}
onOk={handleCreateLibrary}
- okText="创建"
- cancelText="取消"
- destroyOnClose
+ name={newLibName}
+ onNameChange={setNewLibName}
+ kind={newLibKind}
+ onKindChange={setNewLibKind}
confirmLoading={createLibMutation.isPending}
- >
-
-
-
名称
-
setNewLibName(e.target.value)}
- maxLength={50}
- />
-
-
-
类型
-
-
-
+ />
{/* ─── 视频/音频播放弹窗 ─── */}
- setPlayingAsset(null)}
- footer={null}
- width={640}
- destroyOnClose
- >
- {playingAsset?.fileUrl ? (
-
- ) : (
-
-
暂无可播放的文件地址
-
素材 ID: {playingAsset?.id}
-
- )}
-
+ setPlayingAsset(null)} />
{/* ─── 批量打标签弹窗 ─── */}
- {
setTagModalOpen(false)
setBatchTags([])
setBatchTagInput("")
}}
onOk={handleBatchTag}
+ tags={batchTags}
+ onTagInputChange={setBatchTagInput}
+ onTagInputKeyDown={handleTagInputKeyDown}
+ onRemoveTag={removeBatchTag}
+ tagInput={batchTagInput}
+ tagMode={tagMode}
+ onTagModeChange={setTagMode}
confirmLoading={batchLoading}
- okText="确认打标签"
- cancelText="取消"
- >
-
-
- 模式:
- setTagMode(e.target.value)}>
- 追加标签
- 替换全部标签
-
-
-
-
setBatchTagInput(e.target.value)}
- onKeyDown={handleTagInputKeyDown}
- style={{ flex: 1 }}
- />
-
- {batchTags.length > 0 && (
-
- {batchTags.map((tag) => (
- removeBatchTag(tag)} color="blue">
- {tag}
-
- ))}
-
- )}
- {tagMode === "replace" && batchTags.length > 0 && (
-
- 替换模式将清除素材原有全部标签
-
- )}
-
-
+ />
{/* ─── 批量改分类弹窗 ─── */}
- {
setClassifyModalOpen(false)
setBatchCategory("")
}}
onOk={handleBatchClassify}
+ category={batchCategory}
+ onCategoryChange={setBatchCategory}
confirmLoading={batchLoading}
- okText="确认修改"
- cancelText="取消"
- >
-
-
- 将选中的 {selectedIds.size} 个素材统一修改为以下分类:
-
-
setBatchCategory(v)}
- placeholder="请选择分类"
- style={{ width: "100%" }}
- options={CATEGORY_OPTIONS}
- />
-
-
+ />
{/* ─── 批量智能标记弹窗 ─── */}
- setMarkModalOpen(false)}
onOk={handleBatchMark}
+ smartView={batchSmartView}
+ onSmartViewChange={setBatchSmartView}
confirmLoading={batchLoading}
- okText="确认标记"
- cancelText="取消"
- >
-
-
将选中的 {selectedIds.size} 个素材标记为:
-
setBatchSmartView(e.target.value)}
- className="xx-batch-mark-options"
- >
-
-
- 推荐
- 质量优良,可直接用于生产
-
-
-
-
- 慎用
- 存在一定问题,需人工审核后再使用
-
-
-
-
- 高风险
- 存在严重问题,不建议使用
-
-
-
-
-
+ />
{/* ─── 操作结果 Drawer ─── */}
- {
setResultDrawerOpen(false)
setOperationResult(null)
}}
- width={420}
- >
- {operationResult && (
-
-
-
- 总计 {operationResult.total} 个
-
-
-
- 成功 {operationResult.success_count} 个
-
- {operationResult.failure_count > 0 && (
-
-
- 失败 {operationResult.failure_count} 个
-
- )}
-
-
- {(operationResult?.succeeded?.length ?? 0) > 0 && (
-
-
- 成功列表
-
-
- {operationResult?.succeeded?.map((id) => (
-
- {id}
-
- ))}
-
-
- )}
-
- {(operationResult?.failed?.length ?? 0) > 0 && (
-
-
- 失败列表
-
-
- {operationResult?.failed?.map((id) => (
-
- {id}
-
- ))}
-
-
- )}
-
- )}
-
+ />
)
}
diff --git a/apps/web/src/pages/assets/components/AssetCard.tsx b/apps/web/src/pages/assets/components/AssetCard.tsx
new file mode 100644
index 000000000..6f83b573d
--- /dev/null
+++ b/apps/web/src/pages/assets/components/AssetCard.tsx
@@ -0,0 +1,128 @@
+import React from "react"
+import {
+ PlayCircleOutlined,
+ CheckOutlined,
+ DeleteOutlined,
+ ExperimentOutlined,
+ LoadingOutlined,
+ CloseCircleOutlined,
+} from "@ant-design/icons"
+import { Popconfirm } from "antd"
+import type { AssetItem } from "@/pages/assets/types"
+import { thumbGradient } from "@/pages/assets/utils/asset"
+import { kindIcon } from "@/pages/assets/utils/kindIcon"
+import { StatusPill } from "./AssetSkeleton"
+
+/* ============================================================
+ * AssetCard — 素材卡片(网格视图)
+ * ============================================================ */
+export interface AssetCardProps {
+ asset: AssetItem
+ selected: boolean
+ diagnosing?: boolean
+ onToggle: () => void
+ onDiagnose: () => void
+ onPlay: () => void
+ onDelete: () => void
+}
+
+const AssetCard: React.FC
= ({
+ asset,
+ selected,
+ diagnosing,
+ onToggle,
+ onDiagnose,
+ onPlay,
+ onDelete,
+}) => (
+
+ {/* 缩略图区 */}
+
+ {asset.thumbUrl ? (
+

+ ) : (
+
+ {asset.loading ? : kindIcon(asset.kind)}
+
+ )}
+
+ {/* 处理中遮罩 */}
+ {asset.loading && (
+
+
+ 处理中
+
+ )}
+
+ {/* 失败状态标识 */}
+ {asset.status === "bad" && asset.statusLabel === "处理失败" && (
+
+
+ 处理失败
+
+ )}
+
+ {/* 视频/配音类显示播放按钮(处理中/失败不显示) */}
+ {asset.kind === "video" && !asset.loading && asset.status !== "bad" && (
+
{
+ e.stopPropagation()
+ onPlay()
+ }}
+ >
+
+
+ )}
+
+ {/* 删除按钮 */}
+
{
+ e?.stopPropagation()
+ onDelete()
+ }}
+ onCancel={(e) => e?.stopPropagation()}
+ okText="删除"
+ cancelText="取消"
+ okButtonProps={{ danger: true }}
+ >
+ e.stopPropagation()}>
+
+
+
+
+ {/* 选中态勾选 */}
+ {selected && (
+
+
+
+ )}
+
+
+ {/* 信息区 */}
+
+
+ {asset.name}
+
+
+
+ {asset.duration && {asset.duration}}
+
+
+
+
+)
+
+export default AssetCard
diff --git a/apps/web/src/pages/assets/components/AssetFilterBar.tsx b/apps/web/src/pages/assets/components/AssetFilterBar.tsx
new file mode 100644
index 000000000..4abba47ce
--- /dev/null
+++ b/apps/web/src/pages/assets/components/AssetFilterBar.tsx
@@ -0,0 +1,62 @@
+import React from "react"
+import { SearchOutlined } from "@ant-design/icons"
+import { Button, Input, Select } from "@/components/ui"
+import { TYPE_FILTER_OPTIONS, TIME_FILTER_OPTIONS } from "@/pages/assets/constants"
+
+/* ============================================================
+ * AssetFilterBar — 筛选栏(搜索/类型/时间 + 全选/计数)
+ * ============================================================ */
+export interface AssetFilterBarProps {
+ searchText: string
+ onSearchChange: (value: string) => void
+ filterType: string
+ onFilterTypeChange: (value: string) => void
+ filterTime: string
+ onFilterTimeChange: (value: string) => void
+ onSelectAll: () => void
+ totalCount: number
+}
+
+const AssetFilterBar: React.FC = ({
+ searchText,
+ onSearchChange,
+ filterType,
+ onFilterTypeChange,
+ filterTime,
+ onFilterTimeChange,
+ onSelectAll,
+ totalCount,
+}) => (
+
+
+ }
+ value={searchText}
+ onChange={(e) => onSearchChange(e.target.value)}
+ allowClear
+ style={{ width: 220 }}
+ />
+
+
+
+
+
+ 共 {totalCount} 个素材
+
+
+)
+
+export default AssetFilterBar
diff --git a/apps/web/src/pages/assets/components/AssetSkeleton.tsx b/apps/web/src/pages/assets/components/AssetSkeleton.tsx
new file mode 100644
index 000000000..8af74816a
--- /dev/null
+++ b/apps/web/src/pages/assets/components/AssetSkeleton.tsx
@@ -0,0 +1,36 @@
+import React from "react"
+import type { StatusType } from "@/pages/assets/types"
+
+/* ============================================================
+ * StatusPill — 状态标签
+ * ============================================================ */
+export interface StatusPillProps {
+ status: StatusType
+ label: string
+}
+
+export const StatusPill: React.FC = ({ status, label }) => (
+ {label}
+)
+
+/* ============================================================
+ * SkeletonCard — 骨架屏卡片(素材列表加载时占位)
+ * ============================================================ */
+export const SkeletonCard: React.FC = () => (
+
+)
diff --git a/apps/web/src/pages/assets/components/BatchClassifyModal.tsx b/apps/web/src/pages/assets/components/BatchClassifyModal.tsx
new file mode 100644
index 000000000..fef1673ae
--- /dev/null
+++ b/apps/web/src/pages/assets/components/BatchClassifyModal.tsx
@@ -0,0 +1,49 @@
+import React from "react"
+import { Modal as AntModal, Select as AntSelect } from "antd"
+import { CATEGORY_OPTIONS } from "@/pages/assets/constants"
+
+/* ============================================================
+ * BatchClassifyModal — 批量改分类弹窗
+ * ============================================================ */
+export interface BatchClassifyModalProps {
+ open: boolean
+ selectedCount: number
+ onCancel: () => void
+ onOk: () => void
+ category: string
+ onCategoryChange: (value: string) => void
+ confirmLoading?: boolean
+}
+
+const BatchClassifyModal: React.FC = ({
+ open,
+ selectedCount,
+ onCancel,
+ onOk,
+ category,
+ onCategoryChange,
+ confirmLoading,
+}) => (
+
+
+
将选中的 {selectedCount} 个素材统一修改为以下分类:
+
onCategoryChange(v)}
+ placeholder="请选择分类"
+ style={{ width: "100%" }}
+ options={CATEGORY_OPTIONS}
+ />
+
+
+)
+
+export default BatchClassifyModal
diff --git a/apps/web/src/pages/assets/components/BatchMarkModal.tsx b/apps/web/src/pages/assets/components/BatchMarkModal.tsx
new file mode 100644
index 000000000..95a61628f
--- /dev/null
+++ b/apps/web/src/pages/assets/components/BatchMarkModal.tsx
@@ -0,0 +1,67 @@
+import React from "react"
+import { Modal as AntModal, Tag, Radio } from "antd"
+
+/* ============================================================
+ * BatchMarkModal — 批量智能标记弹窗
+ * ============================================================ */
+export type SmartViewType = "recommended" | "caution" | "high_risk"
+
+export interface BatchMarkModalProps {
+ open: boolean
+ selectedCount: number
+ onCancel: () => void
+ onOk: () => void
+ smartView: SmartViewType
+ onSmartViewChange: (value: SmartViewType) => void
+ confirmLoading?: boolean
+}
+
+const BatchMarkModal: React.FC = ({
+ open,
+ selectedCount,
+ onCancel,
+ onOk,
+ smartView,
+ onSmartViewChange,
+ confirmLoading,
+}) => (
+
+
+
将选中的 {selectedCount} 个素材标记为:
+
onSmartViewChange(e.target.value)}
+ className="xx-batch-mark-options"
+ >
+
+
+ 推荐
+ 质量优良,可直接用于生产
+
+
+
+
+ 慎用
+ 存在一定问题,需人工审核后再使用
+
+
+
+
+ 高风险
+ 存在严重问题,不建议使用
+
+
+
+
+
+)
+
+export default BatchMarkModal
diff --git a/apps/web/src/pages/assets/components/BatchOperationBar.tsx b/apps/web/src/pages/assets/components/BatchOperationBar.tsx
new file mode 100644
index 000000000..f420d4a29
--- /dev/null
+++ b/apps/web/src/pages/assets/components/BatchOperationBar.tsx
@@ -0,0 +1,58 @@
+import React from "react"
+import {
+ TagsOutlined,
+ FolderOutlined,
+ ThunderboltOutlined,
+ DeleteOutlined,
+} from "@ant-design/icons"
+import { Popconfirm } from "antd"
+import { Button } from "@/components/ui"
+
+/* ============================================================
+ * BatchOperationBar — 批量操作栏
+ * ============================================================ */
+export interface BatchOperationBarProps {
+ selectedCount: number
+ onDeselectAll: () => void
+ onTagClick: () => void
+ onClassifyClick: () => void
+ onMarkClick: () => void
+ onBatchDelete: () => void
+}
+
+const BatchOperationBar: React.FC = ({
+ selectedCount,
+ onDeselectAll,
+ onTagClick,
+ onClassifyClick,
+ onMarkClick,
+ onBatchDelete,
+}) => (
+
+
已选 {selectedCount} 项
+
+
} onClick={onTagClick}>
+ 打标签
+
+
} onClick={onClassifyClick}>
+ 改分类
+
+
} onClick={onMarkClick}>
+ 智能标记
+
+
+ }>
+ 批量删除
+
+
+
+)
+
+export default BatchOperationBar
diff --git a/apps/web/src/pages/assets/components/BatchTagModal.tsx b/apps/web/src/pages/assets/components/BatchTagModal.tsx
new file mode 100644
index 000000000..74db258ee
--- /dev/null
+++ b/apps/web/src/pages/assets/components/BatchTagModal.tsx
@@ -0,0 +1,81 @@
+import React from "react"
+import { Modal as AntModal, Tag, Radio, Input as AntInput } from "antd"
+import { ExclamationCircleOutlined } from "@ant-design/icons"
+
+/* ============================================================
+ * BatchTagModal — 批量打标签弹窗
+ * ============================================================ */
+export interface BatchTagModalProps {
+ open: boolean
+ selectedCount: number
+ onCancel: () => void
+ onOk: () => void
+ tags: string[]
+ onTagInputChange: (value: string) => void
+ onTagInputKeyDown: (e: React.KeyboardEvent) => void
+ onRemoveTag: (tag: string) => void
+ tagInput: string
+ tagMode: "add" | "replace"
+ onTagModeChange: (mode: "add" | "replace") => void
+ confirmLoading?: boolean
+}
+
+const BatchTagModal: React.FC = ({
+ open,
+ selectedCount,
+ onCancel,
+ onOk,
+ tags,
+ onTagInputChange,
+ onTagInputKeyDown,
+ onRemoveTag,
+ tagInput,
+ tagMode,
+ onTagModeChange,
+ confirmLoading,
+}) => (
+
+
+
+ 模式:
+ onTagModeChange(e.target.value)}>
+ 追加标签
+ 替换全部标签
+
+
+
+
onTagInputChange(e.target.value)}
+ onKeyDown={onTagInputKeyDown}
+ style={{ flex: 1 }}
+ />
+
+ {tags.length > 0 && (
+
+ {tags.map((tag) => (
+ onRemoveTag(tag)} color="blue">
+ {tag}
+
+ ))}
+
+ )}
+ {tagMode === "replace" && tags.length > 0 && (
+
+ 替换模式将清除素材原有全部标签
+
+ )}
+
+
+)
+
+export default BatchTagModal
diff --git a/apps/web/src/pages/assets/components/CreateLibraryModal.tsx b/apps/web/src/pages/assets/components/CreateLibraryModal.tsx
new file mode 100644
index 000000000..d66693bc7
--- /dev/null
+++ b/apps/web/src/pages/assets/components/CreateLibraryModal.tsx
@@ -0,0 +1,64 @@
+import React from "react"
+import { Modal as AntModal } from "antd"
+import { Input, Select } from "@/components/ui"
+import type { AssetKind } from "@/pages/assets/types"
+import { LIBRARY_KIND_OPTIONS } from "@/pages/assets/constants"
+
+/* ============================================================
+ * CreateLibraryModal — 新建视频库弹窗
+ * ============================================================ */
+export interface CreateLibraryModalProps {
+ open: boolean
+ onCancel: () => void
+ onOk: () => void
+ name: string
+ onNameChange: (value: string) => void
+ kind: AssetKind
+ onKindChange: (value: AssetKind) => void
+ confirmLoading?: boolean
+}
+
+const CreateLibraryModal: React.FC = ({
+ open,
+ onCancel,
+ onOk,
+ name,
+ onNameChange,
+ kind,
+ onKindChange,
+ confirmLoading,
+}) => (
+
+
+
+
名称
+
onNameChange(e.target.value)}
+ maxLength={50}
+ />
+
+
+
类型
+
+
+
+)
+
+export default CreateLibraryModal
diff --git a/apps/web/src/pages/assets/components/LibrarySidebar.tsx b/apps/web/src/pages/assets/components/LibrarySidebar.tsx
new file mode 100644
index 000000000..6c932486c
--- /dev/null
+++ b/apps/web/src/pages/assets/components/LibrarySidebar.tsx
@@ -0,0 +1,70 @@
+import React from "react"
+import { PlusOutlined, DeleteOutlined } from "@ant-design/icons"
+import { Popconfirm } from "antd"
+import type { LibraryItem } from "@/pages/assets/types"
+import { kindLabel } from "@/pages/assets/utils/asset"
+import { kindIcon } from "@/pages/assets/utils/kindIcon"
+
+/* ============================================================
+ * LibrarySidebar — 左侧素材库列表
+ * ============================================================ */
+export interface LibrarySidebarProps {
+ libraries: LibraryItem[]
+ activeLibId: string
+ onSelect: (id: string) => void
+ onDelete: (id: string) => void
+ onCreateClick: () => void
+}
+
+const LibrarySidebar: React.FC = ({
+ libraries,
+ activeLibId,
+ onSelect,
+ onDelete,
+ onCreateClick,
+}) => (
+
+ {libraries.map((lib) => (
+
onSelect(lib.id)}
+ >
+
+
+ {kindIcon(lib.kind)} {lib.name}
+
+
{
+ e?.stopPropagation()
+ onDelete(lib.id)
+ }}
+ onCancel={(e) => e?.stopPropagation()}
+ okText="删除"
+ cancelText="取消"
+ >
+
+
+
+
+ {kindLabel(lib.kind)} · {lib.count} 个素材
+
+
+ ))}
+
+ {/* 新建视频库 */}
+
+
+)
+
+export default LibrarySidebar
diff --git a/apps/web/src/pages/assets/components/PlayModal.tsx b/apps/web/src/pages/assets/components/PlayModal.tsx
new file mode 100644
index 000000000..ca5161a62
--- /dev/null
+++ b/apps/web/src/pages/assets/components/PlayModal.tsx
@@ -0,0 +1,34 @@
+import React from "react"
+import { Modal as AntModal } from "antd"
+import type { AssetItem } from "@/pages/assets/types"
+
+/* ============================================================
+ * PlayModal — 视频/音频播放弹窗
+ * ============================================================ */
+export interface PlayModalProps {
+ open: boolean
+ asset: AssetItem | null
+ onClose: () => void
+}
+
+const PlayModal: React.FC = ({ open, asset, onClose }) => (
+
+ {asset?.fileUrl ? (
+
+ ) : (
+
+
暂无可播放的文件地址
+
素材 ID: {asset?.id}
+
+ )}
+
+)
+
+export default PlayModal
diff --git a/apps/web/src/pages/assets/components/ResultDrawer.tsx b/apps/web/src/pages/assets/components/ResultDrawer.tsx
new file mode 100644
index 000000000..bbd119bc3
--- /dev/null
+++ b/apps/web/src/pages/assets/components/ResultDrawer.tsx
@@ -0,0 +1,70 @@
+import React from "react"
+import { Drawer } from "antd"
+import { CheckCircleOutlined, CloseCircleOutlined } from "@ant-design/icons"
+import type { BatchOperationResult } from "@/api/assets"
+
+/* ============================================================
+ * ResultDrawer — 操作结果抽屉
+ * ============================================================ */
+export interface ResultDrawerProps {
+ open: boolean
+ title: string
+ result: BatchOperationResult | null
+ onClose: () => void
+}
+
+const ResultDrawer: React.FC = ({ open, title, result, onClose }) => (
+
+ {result && (
+
+
+
+ 总计 {result.total} 个
+
+
+
+ 成功 {result.success_count} 个
+
+ {result.failure_count > 0 && (
+
+
+ 失败 {result.failure_count} 个
+
+ )}
+
+
+ {(result?.succeeded?.length ?? 0) > 0 && (
+
+
+ 成功列表
+
+
+ {result?.succeeded?.map((id) => (
+
+ {id}
+
+ ))}
+
+
+ )}
+
+ {(result?.failed?.length ?? 0) > 0 && (
+
+
+ 失败列表
+
+
+ {result?.failed?.map((id) => (
+
+ {id}
+
+ ))}
+
+
+ )}
+
+ )}
+
+)
+
+export default ResultDrawer
diff --git a/apps/web/src/pages/assets/components/UploadProgressModal.tsx b/apps/web/src/pages/assets/components/UploadProgressModal.tsx
new file mode 100644
index 000000000..bbb2db574
--- /dev/null
+++ b/apps/web/src/pages/assets/components/UploadProgressModal.tsx
@@ -0,0 +1,56 @@
+import React from "react"
+import { Modal as AntModal } from "antd"
+
+/* ============================================================
+ * UploadProgressModal — 上传进度弹窗(圆形动画 + 百分比)
+ * ============================================================ */
+export interface UploadProgressModalProps {
+ open: boolean
+ progress: number
+}
+
+const UploadProgressModal: React.FC = ({ open, progress }) => (
+
+
+
+
+ {progress}%
+ 上传中…
+
+
+
+)
+
+export default UploadProgressModal
diff --git a/apps/web/src/pages/assets/utils/kindIcon.tsx b/apps/web/src/pages/assets/utils/kindIcon.tsx
new file mode 100644
index 000000000..a8a0a4b98
--- /dev/null
+++ b/apps/web/src/pages/assets/utils/kindIcon.tsx
@@ -0,0 +1,15 @@
+import React from "react"
+import { VideoCameraOutlined, PictureOutlined, AudioOutlined } from "@ant-design/icons"
+import type { AssetKind } from "../types"
+
+/** 根据素材类型返回对应图标 */
+export const kindIcon = (kind: AssetKind): React.ReactNode => {
+ switch (kind) {
+ case "video":
+ return
+ case "image":
+ return
+ case "voice":
+ return
+ }
+}
diff --git a/apps/web/src/test/pages/assets/smoke.test.tsx b/apps/web/src/test/pages/assets/smoke.test.tsx
index d5c4d985b..d46884aa4 100644
--- a/apps/web/src/test/pages/assets/smoke.test.tsx
+++ b/apps/web/src/test/pages/assets/smoke.test.tsx
@@ -15,6 +15,21 @@ import "@/pages/assets/constants"
// 工具函数
import "@/pages/assets/utils/format"
import "@/pages/assets/utils/asset"
+import "@/pages/assets/utils/kindIcon"
+
+// UI 组件
+import "@/pages/assets/components/AssetCard"
+import "@/pages/assets/components/AssetSkeleton"
+import "@/pages/assets/components/LibrarySidebar"
+import "@/pages/assets/components/AssetFilterBar"
+import "@/pages/assets/components/BatchOperationBar"
+import "@/pages/assets/components/CreateLibraryModal"
+import "@/pages/assets/components/PlayModal"
+import "@/pages/assets/components/BatchTagModal"
+import "@/pages/assets/components/BatchClassifyModal"
+import "@/pages/assets/components/BatchMarkModal"
+import "@/pages/assets/components/ResultDrawer"
+import "@/pages/assets/components/UploadProgressModal"
describe("AssetLibrary module smoke test", () => {
it("should load all asset modules", () => {