Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c7d814cbc8 | |||
| e99f80f333 | |||
| d85b181654 | |||
| f5509ffc27 | |||
| dd7452953e |
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* 音色克隆 API
|
||||
* 任务 3.11:替换 Mock 数据,对接后端真实 API(3.05)
|
||||
* 任务 3.15:新增 progress 字段用于进度展示
|
||||
*/
|
||||
import apiClient from "./client"
|
||||
|
||||
/* ── 前端兼容类型 ─────────────────────────────────────── */
|
||||
|
||||
/** 克隆音色状态(前端展示用) */
|
||||
export type VoiceCloneStatus = "ready" | "processing" | "failed"
|
||||
|
||||
/** 克隆音色条目(前端展示用) */
|
||||
export interface VoiceClone {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
duration_seconds: number
|
||||
status: VoiceCloneStatus
|
||||
/** 克隆进度 0-100,仅 processing 状态时有值 */
|
||||
progress: number
|
||||
sample_url?: string
|
||||
language: string
|
||||
gender: string
|
||||
error_message: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
/** 创建克隆请求(前端简化版) */
|
||||
export interface CreateVoiceCloneRequest {
|
||||
name: string
|
||||
audio_url: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
/* ── 后端 API 类型 ────────────────────────────────────── */
|
||||
|
||||
/** 音色克隆元数据(克隆时附带的扩展信息) */
|
||||
export interface VoiceCloneMetadata {
|
||||
/** 语音时长(秒) */
|
||||
duration?: number
|
||||
/** 采样率(Hz) */
|
||||
sample_rate?: number
|
||||
/** 音色 ID(克隆完成后分配) */
|
||||
voice_id?: string
|
||||
/** 其他扩展字段 */
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
/** 后端克隆档案响应 */
|
||||
export interface VoiceCloneProfile {
|
||||
id: string
|
||||
user_id: string
|
||||
name: string
|
||||
description: string
|
||||
source_audio_url: string
|
||||
voice_id: string | null
|
||||
voice_model: string
|
||||
language: string
|
||||
gender: string
|
||||
status: "pending" | "processing" | "ready" | "failed"
|
||||
error_message: string | null
|
||||
retry_count: number
|
||||
max_retries: number
|
||||
metadata_: VoiceCloneMetadata | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
/** 后端克隆列表响应 */
|
||||
export interface ListVoiceCloneResponse {
|
||||
items: VoiceCloneProfile[]
|
||||
total: number
|
||||
}
|
||||
|
||||
/** 后端克隆状态响应 */
|
||||
export interface VoiceCloneStatusResponse {
|
||||
id: string
|
||||
status: "pending" | "processing" | "ready" | "failed"
|
||||
error_message: string | null
|
||||
voice_id: string | null
|
||||
retry_count: number
|
||||
}
|
||||
|
||||
/** 后端创建克隆请求(完整版) */
|
||||
export interface CreateVoiceCloneRequestFull {
|
||||
name: string
|
||||
description?: string
|
||||
source_audio_url: string
|
||||
voice_model?: string
|
||||
language?: string
|
||||
gender?: string
|
||||
max_retries?: number
|
||||
metadata_?: VoiceCloneMetadata
|
||||
}
|
||||
|
||||
/* ── 辅助函数 ─────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* 将后端 VoiceCloneProfile 转换为前端 VoiceClone
|
||||
* 后端 status "pending" 映射为前端 "processing"
|
||||
*/
|
||||
export const toVoiceClone = (profile: VoiceCloneProfile): VoiceClone => ({
|
||||
id: profile.id,
|
||||
name: profile.name,
|
||||
description: profile.description || "",
|
||||
duration_seconds: 0,
|
||||
status: profile.status === "pending" ? "processing" : profile.status,
|
||||
progress: 0,
|
||||
sample_url: profile.source_audio_url || undefined,
|
||||
language: profile.language || "",
|
||||
gender: profile.gender || "",
|
||||
error_message: profile.error_message || null,
|
||||
created_at: profile.created_at,
|
||||
updated_at: profile.updated_at,
|
||||
})
|
||||
|
||||
/** 格式化时长 */
|
||||
export const formatDuration = (seconds: number): string => {
|
||||
const m = Math.floor(seconds / 60)
|
||||
const s = seconds % 60
|
||||
return `${m}:${String(s).padStart(2, "0")}`
|
||||
}
|
||||
|
||||
/* ── 查询参数 ─────────────────────────────────────────── */
|
||||
|
||||
export interface VoiceCloneListParams {
|
||||
status?: string
|
||||
skip?: number
|
||||
limit?: number
|
||||
}
|
||||
|
||||
/* ── API 函数 ─────────────────────────────────────────── */
|
||||
|
||||
/** 获取克隆音色列表(返回前端兼容数组) */
|
||||
export const getVoiceClones = async (params?: VoiceCloneListParams): Promise<VoiceClone[]> => {
|
||||
const searchParams = new URLSearchParams()
|
||||
if (params?.status) searchParams.set("status", params.status)
|
||||
if (params?.skip !== undefined) searchParams.set("skip", String(params.skip))
|
||||
if (params?.limit !== undefined) searchParams.set("limit", String(params.limit))
|
||||
const qs = searchParams.toString()
|
||||
const response = await apiClient.get<ListVoiceCloneResponse>(`/voice-clones${qs ? `?${qs}` : ""}`)
|
||||
return response.data.items.map(toVoiceClone)
|
||||
}
|
||||
|
||||
/** 获取克隆音色列表(返回完整响应含 total) */
|
||||
export const getVoiceClonesWithTotal = async (
|
||||
params?: VoiceCloneListParams,
|
||||
): Promise<ListVoiceCloneResponse> => {
|
||||
const searchParams = new URLSearchParams()
|
||||
if (params?.status) searchParams.set("status", params.status)
|
||||
if (params?.skip !== undefined) searchParams.set("skip", String(params.skip))
|
||||
if (params?.limit !== undefined) searchParams.set("limit", String(params.limit))
|
||||
const qs = searchParams.toString()
|
||||
const response = await apiClient.get<ListVoiceCloneResponse>(`/voice-clones${qs ? `?${qs}` : ""}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 获取单个克隆音色详情 */
|
||||
export const getVoiceCloneDetail = async (id: string): Promise<VoiceCloneProfile> => {
|
||||
const response = await apiClient.get<VoiceCloneProfile>(`/voice-clones/${id}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 创建克隆音色 */
|
||||
export const createVoiceClone = async (
|
||||
data: CreateVoiceCloneRequest,
|
||||
): Promise<VoiceCloneProfile> => {
|
||||
const payload: CreateVoiceCloneRequestFull = {
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
source_audio_url: data.audio_url,
|
||||
}
|
||||
const response = await apiClient.post<VoiceCloneProfile>("/voice-clones", payload)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 删除克隆音色 */
|
||||
export const deleteVoiceClone = async (id: string): Promise<void> => {
|
||||
await apiClient.delete(`/voice-clones/${id}`)
|
||||
}
|
||||
|
||||
/** 更新克隆音色名称(stub — 后端暂无 PATCH 端点) */
|
||||
export const updateVoiceClone = async (
|
||||
id: string,
|
||||
data: Partial<Pick<VoiceClone, "name">>,
|
||||
): Promise<VoiceClone> => {
|
||||
// 后端暂未提供更新端点,暂用详情接口模拟
|
||||
const response = await apiClient.get<VoiceCloneProfile>(`/voice-clones/${id}`)
|
||||
return toVoiceClone({
|
||||
...response.data,
|
||||
...data,
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
}
|
||||
|
||||
/** 获取克隆状态 */
|
||||
export const getVoiceCloneStatus = async (id: string): Promise<VoiceCloneStatusResponse> => {
|
||||
const response = await apiClient.get<VoiceCloneStatusResponse>(`/voice-clones/${id}/status`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 重试克隆 */
|
||||
export const retryVoiceClone = async (id: string): Promise<VoiceCloneProfile> => {
|
||||
const response = await apiClient.post<VoiceCloneProfile>(`/voice-clones/${id}/retry`)
|
||||
return response.data
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
/**
|
||||
* 音色克隆 API 函数
|
||||
*/
|
||||
import apiClient from "../client"
|
||||
import { toVoiceClone } from "./utils"
|
||||
import type {
|
||||
VoiceClone,
|
||||
VoiceCloneProfile,
|
||||
CreateVoiceCloneRequest,
|
||||
CreateVoiceCloneRequestFull,
|
||||
VoiceCloneListParams,
|
||||
ListVoiceCloneResponse,
|
||||
VoiceCloneStatusResponse,
|
||||
} from "./types"
|
||||
|
||||
/** 获取克隆音色列表(返回前端兼容数组) */
|
||||
export const getVoiceClones = async (params?: VoiceCloneListParams): Promise<VoiceClone[]> => {
|
||||
const searchParams = new URLSearchParams()
|
||||
if (params?.status) searchParams.set("status", params.status)
|
||||
if (params?.skip !== undefined) searchParams.set("skip", String(params.skip))
|
||||
if (params?.limit !== undefined) searchParams.set("limit", String(params.limit))
|
||||
const qs = searchParams.toString()
|
||||
const response = await apiClient.get<ListVoiceCloneResponse>(`/voice-clones${qs ? `?${qs}` : ""}`)
|
||||
return response.data.items.map(toVoiceClone)
|
||||
}
|
||||
|
||||
/** 获取克隆音色列表(返回完整响应含 total) */
|
||||
export const getVoiceClonesWithTotal = async (
|
||||
params?: VoiceCloneListParams,
|
||||
): Promise<ListVoiceCloneResponse> => {
|
||||
const searchParams = new URLSearchParams()
|
||||
if (params?.status) searchParams.set("status", params.status)
|
||||
if (params?.skip !== undefined) searchParams.set("skip", String(params.skip))
|
||||
if (params?.limit !== undefined) searchParams.set("limit", String(params.limit))
|
||||
const qs = searchParams.toString()
|
||||
const response = await apiClient.get<ListVoiceCloneResponse>(`/voice-clones${qs ? `?${qs}` : ""}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 获取单个克隆音色详情 */
|
||||
export const getVoiceCloneDetail = async (id: string): Promise<VoiceCloneProfile> => {
|
||||
const response = await apiClient.get<VoiceCloneProfile>(`/voice-clones/${id}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 创建克隆音色 */
|
||||
export const createVoiceClone = async (
|
||||
data: CreateVoiceCloneRequest,
|
||||
): Promise<VoiceCloneProfile> => {
|
||||
const payload: CreateVoiceCloneRequestFull = {
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
source_audio_url: data.audio_url,
|
||||
}
|
||||
const response = await apiClient.post<VoiceCloneProfile>("/voice-clones", payload)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 删除克隆音色 */
|
||||
export const deleteVoiceClone = async (id: string): Promise<void> => {
|
||||
await apiClient.delete(`/voice-clones/${id}`)
|
||||
}
|
||||
|
||||
/** 更新克隆音色名称(stub — 后端暂无 PATCH 端点) */
|
||||
export const updateVoiceClone = async (
|
||||
id: string,
|
||||
data: Partial<Pick<VoiceClone, "name">>,
|
||||
): Promise<VoiceClone> => {
|
||||
const response = await apiClient.get<VoiceCloneProfile>(`/voice-clones/${id}`)
|
||||
return toVoiceClone({
|
||||
...response.data,
|
||||
...data,
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
}
|
||||
|
||||
/** 获取克隆状态 */
|
||||
export const getVoiceCloneStatus = async (id: string): Promise<VoiceCloneStatusResponse> => {
|
||||
const response = await apiClient.get<VoiceCloneStatusResponse>(`/voice-clones/${id}/status`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 重试克隆 */
|
||||
export const retryVoiceClone = async (id: string): Promise<VoiceCloneProfile> => {
|
||||
const response = await apiClient.post<VoiceCloneProfile>(`/voice-clones/${id}/retry`)
|
||||
return response.data
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
/**
|
||||
* 音色克隆 API — 目录化入口
|
||||
* 保持与原 voice-clone.ts 相同导出,向后兼容
|
||||
*/
|
||||
|
||||
// 类型
|
||||
export type {
|
||||
VoiceCloneStatus,
|
||||
VoiceClone,
|
||||
CreateVoiceCloneRequest,
|
||||
VoiceCloneMetadata,
|
||||
VoiceCloneProfile,
|
||||
ListVoiceCloneResponse,
|
||||
VoiceCloneStatusResponse,
|
||||
CreateVoiceCloneRequestFull,
|
||||
VoiceCloneListParams,
|
||||
} from "./types"
|
||||
|
||||
// 工具函数
|
||||
export { toVoiceClone, formatDuration } from "./utils"
|
||||
|
||||
// API 函数
|
||||
export {
|
||||
getVoiceClones,
|
||||
getVoiceClonesWithTotal,
|
||||
getVoiceCloneDetail,
|
||||
createVoiceClone,
|
||||
deleteVoiceClone,
|
||||
updateVoiceClone,
|
||||
getVoiceCloneStatus,
|
||||
retryVoiceClone,
|
||||
} from "./clones"
|
||||
@@ -1,92 +0,0 @@
|
||||
/**
|
||||
* 音色克隆类型定义
|
||||
*/
|
||||
|
||||
/** 克隆音色状态(前端展示用) */
|
||||
export type VoiceCloneStatus = "ready" | "processing" | "failed"
|
||||
|
||||
/** 克隆音色条目(前端展示用) */
|
||||
export interface VoiceClone {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
duration_seconds: number
|
||||
status: VoiceCloneStatus
|
||||
/** 克隆进度 0-100,仅 processing 状态时有值 */
|
||||
progress: number
|
||||
sample_url?: string
|
||||
language: string
|
||||
gender: string
|
||||
error_message: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
/** 创建克隆请求(前端简化版) */
|
||||
export interface CreateVoiceCloneRequest {
|
||||
name: string
|
||||
audio_url: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
/** 音色克隆元数据 */
|
||||
export interface VoiceCloneMetadata {
|
||||
duration?: number
|
||||
sample_rate?: number
|
||||
voice_id?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
/** 后端克隆档案响应 */
|
||||
export interface VoiceCloneProfile {
|
||||
id: string
|
||||
user_id: string
|
||||
name: string
|
||||
description: string
|
||||
source_audio_url: string
|
||||
voice_id: string | null
|
||||
voice_model: string
|
||||
language: string
|
||||
gender: string
|
||||
status: "pending" | "processing" | "ready" | "failed"
|
||||
error_message: string | null
|
||||
retry_count: number
|
||||
max_retries: number
|
||||
metadata_: VoiceCloneMetadata | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
/** 后端克隆列表响应 */
|
||||
export interface ListVoiceCloneResponse {
|
||||
items: VoiceCloneProfile[]
|
||||
total: number
|
||||
}
|
||||
|
||||
/** 后端克隆状态响应 */
|
||||
export interface VoiceCloneStatusResponse {
|
||||
id: string
|
||||
status: "pending" | "processing" | "ready" | "failed"
|
||||
error_message: string | null
|
||||
voice_id: string | null
|
||||
retry_count: number
|
||||
}
|
||||
|
||||
/** 后端创建克隆请求(完整版) */
|
||||
export interface CreateVoiceCloneRequestFull {
|
||||
name: string
|
||||
description?: string
|
||||
source_audio_url: string
|
||||
voice_model?: string
|
||||
language?: string
|
||||
gender?: string
|
||||
max_retries?: number
|
||||
metadata_?: VoiceCloneMetadata
|
||||
}
|
||||
|
||||
/** 查询参数 */
|
||||
export interface VoiceCloneListParams {
|
||||
status?: string
|
||||
skip?: number
|
||||
limit?: number
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
/**
|
||||
* 音色克隆工具函数
|
||||
*/
|
||||
import type { VoiceCloneProfile, VoiceClone } from "./types"
|
||||
|
||||
/**
|
||||
* 将后端 VoiceCloneProfile 转换为前端 VoiceClone
|
||||
* 后端 status "pending" 映射为前端 "processing"
|
||||
*/
|
||||
export const toVoiceClone = (profile: VoiceCloneProfile): VoiceClone => ({
|
||||
id: profile.id,
|
||||
name: profile.name,
|
||||
description: profile.description || "",
|
||||
duration_seconds: 0,
|
||||
status: profile.status === "pending" ? "processing" : profile.status,
|
||||
progress: 0,
|
||||
sample_url: profile.source_audio_url || undefined,
|
||||
language: profile.language || "",
|
||||
gender: profile.gender || "",
|
||||
error_message: profile.error_message || null,
|
||||
created_at: profile.created_at,
|
||||
updated_at: profile.updated_at,
|
||||
})
|
||||
|
||||
/** 格式化时长 */
|
||||
export const formatDuration = (seconds: number): string => {
|
||||
const m = Math.floor(seconds / 60)
|
||||
const s = seconds % 60
|
||||
return `${m}:${String(s).padStart(2, "0")}`
|
||||
}
|
||||
@@ -1,25 +1,27 @@
|
||||
/**
|
||||
* 视频库页面 — V21 设计系统
|
||||
* 两栏布局:左侧视频库列表(260px)+ 右侧素材网格
|
||||
* 使用 useQuery 对接后端真实 API(api/assets.ts)
|
||||
*
|
||||
* 主组件仅保留 Hook 组装与整体布局
|
||||
* 数据查询 → hooks/useAssetsData
|
||||
* 库管理 → hooks/useLibraryManagement
|
||||
* 上传 → hooks/useAssetUpload
|
||||
* 选中态 → hooks/useAssetSelection
|
||||
* 素材操作 → hooks/useAssetOperations
|
||||
* 上传区 → components/AssetUploadZone
|
||||
* 网格区 → components/AssetGridSection
|
||||
* 弹窗集合 → components/AssetModals
|
||||
*/
|
||||
import React, { useState } from "react"
|
||||
import { Upload } from "antd"
|
||||
import { InboxOutlined, PictureOutlined, ExclamationCircleOutlined } from "@ant-design/icons"
|
||||
import { Button } from "@/components/ui"
|
||||
import type { AssetItem } from "@/pages/assets/types"
|
||||
import AssetCard from "@/pages/assets/components/AssetCard"
|
||||
import type { SmartViewType } from "@/pages/assets/components/BatchMarkModal"
|
||||
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 ResultDrawer from "@/pages/assets/components/ResultDrawer"
|
||||
import UploadProgressModal from "@/pages/assets/components/UploadProgressModal"
|
||||
import AssetUploadZone from "@/pages/assets/components/AssetUploadZone"
|
||||
import AssetGridSection from "@/pages/assets/components/AssetGridSection"
|
||||
import AssetModals from "@/pages/assets/components/AssetModals"
|
||||
import { useAssetsData } from "@/pages/assets/hooks/useAssetsData"
|
||||
import { useLibraryManagement } from "@/pages/assets/hooks/useLibraryManagement"
|
||||
import { useAssetUpload } from "@/pages/assets/hooks/useAssetUpload"
|
||||
@@ -27,9 +29,6 @@ import { useAssetSelection } from "@/pages/assets/hooks/useAssetSelection"
|
||||
import { useAssetOperations } from "@/pages/assets/hooks/useAssetOperations"
|
||||
import "./assets.css"
|
||||
|
||||
/* ============================================================
|
||||
* 主组件
|
||||
* ============================================================ */
|
||||
const AssetLibrary: React.FC = () => {
|
||||
/* ── 数据查询与筛选 ── */
|
||||
const {
|
||||
@@ -129,9 +128,6 @@ const AssetLibrary: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div className="xx-assets-page">
|
||||
{/* ─── 上传进度弹窗 ─── */}
|
||||
<UploadProgressModal open={uploading} progress={uploadProgress} />
|
||||
|
||||
{/* 两栏布局 */}
|
||||
<div className="xx-assets-layout">
|
||||
{/* ─── 左侧:视频库列表 ─── */}
|
||||
@@ -146,25 +142,11 @@ const AssetLibrary: React.FC = () => {
|
||||
{/* ─── 右侧:内容区 ─── */}
|
||||
<div className="xx-assets-content">
|
||||
{/* 上传区域 */}
|
||||
<Upload.Dragger
|
||||
beforeUpload={(file) => {
|
||||
handleUpload(file as File)
|
||||
return false
|
||||
}}
|
||||
showUploadList={false}
|
||||
multiple
|
||||
accept="video/*,image/*"
|
||||
>
|
||||
<div className="xx-asset-upload-zone">
|
||||
<p className="xx-asset-upload-icon">
|
||||
<InboxOutlined />
|
||||
</p>
|
||||
<p className="xx-asset-upload-text">
|
||||
{uploading ? "上传中..." : "点击或拖拽文件到此区域上传"}
|
||||
</p>
|
||||
<p className="xx-asset-upload-hint">支持视频、图片,单文件不超过 2GB</p>
|
||||
</div>
|
||||
</Upload.Dragger>
|
||||
<AssetUploadZone
|
||||
uploading={uploading}
|
||||
uploadProgress={uploadProgress}
|
||||
onUpload={handleUpload}
|
||||
/>
|
||||
|
||||
{/* 筛选栏 */}
|
||||
<AssetFilterBar
|
||||
@@ -191,114 +173,69 @@ const AssetLibrary: React.FC = () => {
|
||||
)}
|
||||
|
||||
{/* 素材网格 */}
|
||||
{assetsLoading ? (
|
||||
<div className="xx-asset-grid">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<SkeletonCard key={i} />
|
||||
))}
|
||||
</div>
|
||||
) : assetsError ? (
|
||||
<div className="xx-assets-empty">
|
||||
<div className="xx-assets-empty-icon">
|
||||
<ExclamationCircleOutlined />
|
||||
</div>
|
||||
<p className="xx-assets-empty-title">{assetsErrorObj?.message || "加载失败"}</p>
|
||||
<Button buttonType="primary" buttonSize="sm" onClick={() => refetchAssets()}>
|
||||
重新加载
|
||||
</Button>
|
||||
</div>
|
||||
) : filteredAssets.length > 0 ? (
|
||||
<div className="xx-asset-grid">
|
||||
{filteredAssets.map((asset) => (
|
||||
<AssetCard
|
||||
key={asset.id}
|
||||
asset={asset}
|
||||
selected={selectedIds.has(asset.id)}
|
||||
diagnosing={diagnosingId === asset.id}
|
||||
onToggle={() => toggleSelect(asset.id)}
|
||||
onDiagnose={() => handleDiagnose(asset)}
|
||||
onPlay={() => setPlayingAsset(asset)}
|
||||
onDelete={() => handleSingleDelete(asset.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="xx-assets-empty">
|
||||
<div className="xx-assets-empty-icon">
|
||||
<PictureOutlined />
|
||||
</div>
|
||||
<p className="xx-assets-empty-title">暂无素材,请上传或切换视频库</p>
|
||||
</div>
|
||||
)}
|
||||
<AssetGridSection
|
||||
loading={assetsLoading}
|
||||
error={assetsError}
|
||||
errorMessage={assetsErrorObj?.message}
|
||||
assets={filteredAssets}
|
||||
selectedIds={selectedIds}
|
||||
diagnosingId={diagnosingId}
|
||||
onRetry={refetchAssets}
|
||||
onToggleSelect={toggleSelect}
|
||||
onDiagnose={handleDiagnose}
|
||||
onPlay={setPlayingAsset}
|
||||
onDelete={handleSingleDelete}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ─── 新建视频库弹窗 ─── */}
|
||||
<CreateLibraryModal
|
||||
open={createModalOpen}
|
||||
onCancel={() => setCreateModalOpen(false)}
|
||||
onOk={handleCreateLibrary}
|
||||
name={newLibName}
|
||||
onNameChange={setNewLibName}
|
||||
kind={newLibKind}
|
||||
onKindChange={setNewLibKind}
|
||||
confirmLoading={isCreating}
|
||||
/>
|
||||
|
||||
{/* ─── 视频/音频播放弹窗 ─── */}
|
||||
<PlayModal open={!!playingAsset} asset={playingAsset} onClose={() => setPlayingAsset(null)} />
|
||||
|
||||
{/* ─── 批量打标签弹窗 ─── */}
|
||||
<BatchTagModal
|
||||
open={tagModalOpen}
|
||||
{/* ─── 弹窗集合 ─── */}
|
||||
<AssetModals
|
||||
uploading={uploading}
|
||||
uploadProgress={uploadProgress}
|
||||
createModalOpen={createModalOpen}
|
||||
onCreateModalCancel={() => setCreateModalOpen(false)}
|
||||
onCreateModalOk={handleCreateLibrary}
|
||||
newLibName={newLibName}
|
||||
onNewLibNameChange={setNewLibName}
|
||||
newLibKind={newLibKind}
|
||||
onNewLibKindChange={setNewLibKind}
|
||||
createLoading={isCreating}
|
||||
playingAsset={playingAsset}
|
||||
onPlayClose={() => setPlayingAsset(null)}
|
||||
tagModalOpen={tagModalOpen}
|
||||
selectedCount={selectedIds.size}
|
||||
onCancel={() => {
|
||||
onTagCancel={() => {
|
||||
setTagModalOpen(false)
|
||||
setBatchTags([])
|
||||
setBatchTagInput("")
|
||||
}}
|
||||
onOk={handleBatchTag}
|
||||
tags={batchTags}
|
||||
onTagOk={handleBatchTag}
|
||||
batchTags={batchTags}
|
||||
batchTagInput={batchTagInput}
|
||||
onTagInputChange={setBatchTagInput}
|
||||
onTagInputKeyDown={handleTagInputKeyDown}
|
||||
onRemoveTag={removeBatchTag}
|
||||
tagInput={batchTagInput}
|
||||
tagMode={tagMode}
|
||||
onTagModeChange={setTagMode}
|
||||
confirmLoading={batchLoading}
|
||||
/>
|
||||
|
||||
{/* ─── 批量改分类弹窗 ─── */}
|
||||
<BatchClassifyModal
|
||||
open={classifyModalOpen}
|
||||
selectedCount={selectedIds.size}
|
||||
onCancel={() => {
|
||||
tagMode={tagMode as "add" | "replace"}
|
||||
onTagModeChange={setTagMode as (mode: "add" | "replace") => void}
|
||||
batchLoading={batchLoading}
|
||||
classifyModalOpen={classifyModalOpen}
|
||||
onClassifyCancel={() => {
|
||||
setClassifyModalOpen(false)
|
||||
setBatchCategory("")
|
||||
}}
|
||||
onOk={handleBatchClassify}
|
||||
category={batchCategory}
|
||||
onClassifyOk={handleBatchClassify}
|
||||
batchCategory={batchCategory}
|
||||
onCategoryChange={setBatchCategory}
|
||||
confirmLoading={batchLoading}
|
||||
/>
|
||||
|
||||
{/* ─── 批量智能标记弹窗 ─── */}
|
||||
<BatchMarkModal
|
||||
open={markModalOpen}
|
||||
selectedCount={selectedIds.size}
|
||||
onCancel={() => setMarkModalOpen(false)}
|
||||
onOk={handleBatchMark}
|
||||
smartView={batchSmartView}
|
||||
onSmartViewChange={setBatchSmartView}
|
||||
confirmLoading={batchLoading}
|
||||
/>
|
||||
|
||||
{/* ─── 操作结果 Drawer ─── */}
|
||||
<ResultDrawer
|
||||
open={resultDrawerOpen}
|
||||
title={operationTitle}
|
||||
result={operationResult}
|
||||
onClose={handleResultDrawerClose}
|
||||
markModalOpen={markModalOpen}
|
||||
onMarkCancel={() => setMarkModalOpen(false)}
|
||||
onMarkOk={handleBatchMark}
|
||||
batchSmartView={batchSmartView as SmartViewType}
|
||||
onSmartViewChange={setBatchSmartView as (val: SmartViewType) => void}
|
||||
resultDrawerOpen={resultDrawerOpen}
|
||||
operationTitle={operationTitle}
|
||||
operationResult={operationResult}
|
||||
onResultDrawerClose={handleResultDrawerClose}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* AssetLibrary 素材网格区域(含加载/错误/空状态)
|
||||
*/
|
||||
import React from "react"
|
||||
import { PictureOutlined, ExclamationCircleOutlined } from "@ant-design/icons"
|
||||
import { Button } from "@/components/ui"
|
||||
import type { AssetItem } from "../types"
|
||||
import AssetCard from "./AssetCard"
|
||||
import { SkeletonCard } from "./AssetSkeleton"
|
||||
|
||||
export interface AssetGridSectionProps {
|
||||
loading: boolean
|
||||
error: boolean
|
||||
errorMessage?: string
|
||||
assets: AssetItem[]
|
||||
selectedIds: Set<string>
|
||||
diagnosingId: string | null
|
||||
onRetry?: () => void
|
||||
onToggleSelect: (id: string) => void
|
||||
onDiagnose: (asset: AssetItem) => void
|
||||
onPlay: (asset: AssetItem) => void
|
||||
onDelete: (id: string) => void
|
||||
}
|
||||
|
||||
export const AssetGridSection: React.FC<AssetGridSectionProps> = ({
|
||||
loading,
|
||||
error,
|
||||
errorMessage,
|
||||
assets,
|
||||
selectedIds,
|
||||
diagnosingId,
|
||||
onRetry,
|
||||
onToggleSelect,
|
||||
onDiagnose,
|
||||
onPlay,
|
||||
onDelete,
|
||||
}) => {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="xx-asset-grid">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<SkeletonCard key={i} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="xx-assets-empty">
|
||||
<div className="xx-assets-empty-icon">
|
||||
<ExclamationCircleOutlined />
|
||||
</div>
|
||||
<p className="xx-assets-empty-title">{errorMessage || "加载失败"}</p>
|
||||
{onRetry && (
|
||||
<Button buttonType="primary" buttonSize="sm" onClick={onRetry}>
|
||||
重新加载
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (assets.length > 0) {
|
||||
return (
|
||||
<div className="xx-asset-grid">
|
||||
{assets.map((asset) => (
|
||||
<AssetCard
|
||||
key={asset.id}
|
||||
asset={asset}
|
||||
selected={selectedIds.has(asset.id)}
|
||||
diagnosing={diagnosingId === asset.id}
|
||||
onToggle={() => onToggleSelect(asset.id)}
|
||||
onDiagnose={() => onDiagnose(asset)}
|
||||
onPlay={() => onPlay(asset)}
|
||||
onDelete={() => onDelete(asset.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="xx-assets-empty">
|
||||
<div className="xx-assets-empty-icon">
|
||||
<PictureOutlined />
|
||||
</div>
|
||||
<p className="xx-assets-empty-title">暂无素材,请上传或切换视频库</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AssetGridSection
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* AssetLibrary 弹窗集合
|
||||
*/
|
||||
import React from "react"
|
||||
import type { AssetItem, AssetKind } from "../types"
|
||||
import type { SmartViewType } from "./BatchMarkModal"
|
||||
import type { BatchOperationResult } from "@/api/assets"
|
||||
import CreateLibraryModal from "./CreateLibraryModal"
|
||||
import PlayModal from "./PlayModal"
|
||||
import BatchTagModal from "./BatchTagModal"
|
||||
import BatchClassifyModal from "./BatchClassifyModal"
|
||||
import BatchMarkModal from "./BatchMarkModal"
|
||||
import ResultDrawer from "./ResultDrawer"
|
||||
import UploadProgressModal from "./UploadProgressModal"
|
||||
|
||||
export interface AssetModalsProps {
|
||||
/* 上传进度 */
|
||||
uploading: boolean
|
||||
uploadProgress: number
|
||||
|
||||
/* 新建视频库 */
|
||||
createModalOpen: boolean
|
||||
onCreateModalCancel: () => void
|
||||
onCreateModalOk: () => void
|
||||
newLibName: string
|
||||
onNewLibNameChange: (name: string) => void
|
||||
newLibKind: AssetKind
|
||||
onNewLibKindChange: (kind: AssetKind) => void
|
||||
createLoading: boolean
|
||||
|
||||
/* 播放弹窗 */
|
||||
playingAsset: AssetItem | null
|
||||
onPlayClose: () => void
|
||||
|
||||
/* 批量打标签 */
|
||||
tagModalOpen: boolean
|
||||
selectedCount: number
|
||||
onTagCancel: () => void
|
||||
onTagOk: () => void
|
||||
batchTags: string[]
|
||||
batchTagInput: string
|
||||
onTagInputChange: (val: string) => void
|
||||
onTagInputKeyDown: (e: React.KeyboardEvent) => void
|
||||
onRemoveTag: (tag: string) => void
|
||||
tagMode: "add" | "replace"
|
||||
onTagModeChange: (mode: "add" | "replace") => void
|
||||
batchLoading: boolean
|
||||
|
||||
/* 批量改分类 */
|
||||
classifyModalOpen: boolean
|
||||
onClassifyCancel: () => void
|
||||
onClassifyOk: () => void
|
||||
batchCategory: string
|
||||
onCategoryChange: (val: string) => void
|
||||
|
||||
/* 批量智能标记 */
|
||||
markModalOpen: boolean
|
||||
onMarkCancel: () => void
|
||||
onMarkOk: () => void
|
||||
batchSmartView: SmartViewType
|
||||
onSmartViewChange: (val: SmartViewType) => void
|
||||
|
||||
/* 操作结果 Drawer */
|
||||
resultDrawerOpen: boolean
|
||||
operationTitle: string
|
||||
operationResult: BatchOperationResult | null
|
||||
onResultDrawerClose: () => void
|
||||
}
|
||||
|
||||
export const AssetModals: React.FC<AssetModalsProps> = ({
|
||||
uploading,
|
||||
uploadProgress,
|
||||
createModalOpen,
|
||||
onCreateModalCancel,
|
||||
onCreateModalOk,
|
||||
newLibName,
|
||||
onNewLibNameChange,
|
||||
newLibKind,
|
||||
onNewLibKindChange,
|
||||
createLoading,
|
||||
playingAsset,
|
||||
onPlayClose,
|
||||
tagModalOpen,
|
||||
selectedCount,
|
||||
onTagCancel,
|
||||
onTagOk,
|
||||
batchTags,
|
||||
batchTagInput,
|
||||
onTagInputChange,
|
||||
onTagInputKeyDown,
|
||||
onRemoveTag,
|
||||
tagMode,
|
||||
onTagModeChange,
|
||||
batchLoading,
|
||||
classifyModalOpen,
|
||||
onClassifyCancel,
|
||||
onClassifyOk,
|
||||
batchCategory,
|
||||
onCategoryChange,
|
||||
markModalOpen,
|
||||
onMarkCancel,
|
||||
onMarkOk,
|
||||
batchSmartView,
|
||||
onSmartViewChange,
|
||||
resultDrawerOpen,
|
||||
operationTitle,
|
||||
operationResult,
|
||||
onResultDrawerClose,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
{/* 上传进度弹窗 */}
|
||||
<UploadProgressModal open={uploading} progress={uploadProgress} />
|
||||
|
||||
{/* 新建视频库弹窗 */}
|
||||
<CreateLibraryModal
|
||||
open={createModalOpen}
|
||||
onCancel={onCreateModalCancel}
|
||||
onOk={onCreateModalOk}
|
||||
name={newLibName}
|
||||
onNameChange={onNewLibNameChange}
|
||||
kind={newLibKind}
|
||||
onKindChange={onNewLibKindChange}
|
||||
confirmLoading={createLoading}
|
||||
/>
|
||||
|
||||
{/* 视频/音频播放弹窗 */}
|
||||
<PlayModal open={!!playingAsset} asset={playingAsset} onClose={onPlayClose} />
|
||||
|
||||
{/* 批量打标签弹窗 */}
|
||||
<BatchTagModal
|
||||
open={tagModalOpen}
|
||||
selectedCount={selectedCount}
|
||||
onCancel={onTagCancel}
|
||||
onOk={onTagOk}
|
||||
tags={batchTags}
|
||||
onTagInputChange={onTagInputChange}
|
||||
onTagInputKeyDown={onTagInputKeyDown}
|
||||
onRemoveTag={onRemoveTag}
|
||||
tagInput={batchTagInput}
|
||||
tagMode={tagMode}
|
||||
onTagModeChange={onTagModeChange}
|
||||
confirmLoading={batchLoading}
|
||||
/>
|
||||
|
||||
{/* 批量改分类弹窗 */}
|
||||
<BatchClassifyModal
|
||||
open={classifyModalOpen}
|
||||
selectedCount={selectedCount}
|
||||
onCancel={onClassifyCancel}
|
||||
onOk={onClassifyOk}
|
||||
category={batchCategory}
|
||||
onCategoryChange={onCategoryChange}
|
||||
confirmLoading={batchLoading}
|
||||
/>
|
||||
|
||||
{/* 批量智能标记弹窗 */}
|
||||
<BatchMarkModal
|
||||
open={markModalOpen}
|
||||
selectedCount={selectedCount}
|
||||
onCancel={onMarkCancel}
|
||||
onOk={onMarkOk}
|
||||
smartView={batchSmartView}
|
||||
onSmartViewChange={onSmartViewChange}
|
||||
confirmLoading={batchLoading}
|
||||
/>
|
||||
|
||||
{/* 操作结果 Drawer */}
|
||||
<ResultDrawer
|
||||
open={resultDrawerOpen}
|
||||
title={operationTitle}
|
||||
result={operationResult}
|
||||
onClose={onResultDrawerClose}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default AssetModals
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* AssetLibrary 上传拖拽区域
|
||||
*/
|
||||
import React from "react"
|
||||
import { Upload } from "antd"
|
||||
import { InboxOutlined } from "@ant-design/icons"
|
||||
|
||||
export interface AssetUploadZoneProps {
|
||||
uploading: boolean
|
||||
uploadProgress: number
|
||||
onUpload: (file: File) => void
|
||||
}
|
||||
|
||||
export const AssetUploadZone: React.FC<AssetUploadZoneProps> = ({
|
||||
uploading,
|
||||
onUpload,
|
||||
}) => {
|
||||
return (
|
||||
<Upload.Dragger
|
||||
beforeUpload={(file) => {
|
||||
onUpload(file as File)
|
||||
return false
|
||||
}}
|
||||
showUploadList={false}
|
||||
multiple
|
||||
accept="video/*,image/*"
|
||||
>
|
||||
<div className="xx-asset-upload-zone">
|
||||
<p className="xx-asset-upload-icon">
|
||||
<InboxOutlined />
|
||||
</p>
|
||||
<p className="xx-asset-upload-text">
|
||||
{uploading ? "上传中..." : "点击或拖拽文件到此区域上传"}
|
||||
</p>
|
||||
<p className="xx-asset-upload-hint">支持视频、图片,单文件不超过 2GB</p>
|
||||
</div>
|
||||
</Upload.Dragger>
|
||||
)
|
||||
}
|
||||
|
||||
export default AssetUploadZone
|
||||
Reference in New Issue
Block a user