Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 51a79230d5 | |||
| be1a1dae60 | |||
| 71701e27ae | |||
| 2781663c61 | |||
| 185431788c | |||
| 922e0bdc58 |
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* 成品 / 视频相关 API
|
||||
* 后端实际接口:/videos
|
||||
*/
|
||||
import apiClient from "./client"
|
||||
|
||||
/** 复核状态 */
|
||||
export type ReviewStatus = "pending_review" | "approved" | "rejected"
|
||||
|
||||
/** 成品条目 */
|
||||
export interface ProductItem {
|
||||
id: string
|
||||
title: string
|
||||
video_url?: string
|
||||
thumbnail_url?: string
|
||||
duration_seconds?: number
|
||||
file_size?: number
|
||||
resolution?: string
|
||||
status: "processing" | "completed" | "failed"
|
||||
/** 复核状态 */
|
||||
review_status?: ReviewStatus
|
||||
/** 所属项目 ID */
|
||||
project_id?: string
|
||||
/** 所属项目名称 */
|
||||
project_name?: string
|
||||
/** 查重率(百分比) */
|
||||
duplicate_rate?: number
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
/** 列表查询参数 */
|
||||
export interface ProductListParams {
|
||||
page?: number
|
||||
page_size?: number
|
||||
project_id?: string
|
||||
review_status?: ReviewStatus | "all"
|
||||
}
|
||||
|
||||
/** 分页响应 */
|
||||
export interface ProductListResponse {
|
||||
items: ProductItem[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
}
|
||||
|
||||
/** 批量下载任务状态 */
|
||||
export interface BatchDownloadStatus {
|
||||
job_id: string
|
||||
status: "processing" | "completed" | "failed"
|
||||
/** 完成后返回的下载 URL */
|
||||
download_url?: string
|
||||
/** 进度百分比 */
|
||||
progress?: number
|
||||
}
|
||||
|
||||
/** 后端 /videos 接口返回的原始视频条目 */
|
||||
interface VideoItem {
|
||||
id: string
|
||||
project_id: string
|
||||
generation_task_id: string
|
||||
name: string
|
||||
file_url: string
|
||||
file_size: number
|
||||
duration: number
|
||||
thumbnail_url: string | null
|
||||
width: number
|
||||
height: number
|
||||
fps: number
|
||||
status: string
|
||||
review_status: ReviewStatus
|
||||
generation_params: Record<string, unknown>
|
||||
download_url: string
|
||||
generated_at: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 将后端 VideoItem 映射为 ProductItem 格式
|
||||
*/
|
||||
function mapVideoToProductItem(video: VideoItem): ProductItem {
|
||||
return {
|
||||
id: video.id,
|
||||
title: video.name || "未命名视频",
|
||||
// 优先用 download_url(带签名)播放,file_url 无签名无法访问
|
||||
video_url: video.download_url || video.file_url,
|
||||
thumbnail_url: video.thumbnail_url || undefined,
|
||||
duration_seconds: video.duration,
|
||||
file_size: video.file_size,
|
||||
resolution: video.width && video.height ? `${video.width}x${video.height}` : undefined,
|
||||
status:
|
||||
video.status === "completed"
|
||||
? "completed"
|
||||
: video.status === "failed"
|
||||
? "failed"
|
||||
: "processing",
|
||||
review_status: video.review_status,
|
||||
project_id: video.project_id,
|
||||
// 后端 /videos 接口暂无 project_name 字段
|
||||
project_name: undefined,
|
||||
// 后端字段名为 generated_at,映射为 created_at 供前端统一使用
|
||||
created_at: video.generated_at,
|
||||
updated_at: video.generated_at,
|
||||
// 后端 /videos 接口暂无 duplicate_rate 字段
|
||||
duplicate_rate: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取成品列表(支持分页和筛选) */
|
||||
export const getProducts = async (params?: ProductListParams): Promise<ProductItem[]> => {
|
||||
const response = await apiClient.get("/videos", { params })
|
||||
const data = response.data
|
||||
const videos: VideoItem[] = Array.isArray(data?.items)
|
||||
? data.items
|
||||
: Array.isArray(data)
|
||||
? data
|
||||
: []
|
||||
return videos.map(mapVideoToProductItem)
|
||||
}
|
||||
|
||||
/** 获取单个成品详情 */
|
||||
export const getProduct = async (productId: string): Promise<ProductItem> => {
|
||||
const response = await apiClient.get(`/videos/${productId}`)
|
||||
return mapVideoToProductItem(response.data as VideoItem)
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除成品
|
||||
* 注意:后端暂未实现 /videos DELETE 接口,调用会返回 405
|
||||
* 待后端实现后自动生效
|
||||
*/
|
||||
export const deleteProduct = async (productId: string): Promise<void> => {
|
||||
await apiClient.delete(`/videos/${productId}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取成品下载链接
|
||||
* 直接使用列表返回的 download_url(带OSS签名)
|
||||
*/
|
||||
export const getProductDownloadUrl = async (
|
||||
productId: string,
|
||||
): Promise<{ url: string; expires_at: string }> => {
|
||||
// 优先从列表缓存取;如果没有则调详情接口
|
||||
const product = await getProduct(productId)
|
||||
if (!product.video_url) throw new Error("下载链接不可用")
|
||||
return { url: product.video_url, expires_at: "" }
|
||||
}
|
||||
|
||||
/** 更新复核状态 — TODO: 后端暂无对应端点,暂存本地状态 */
|
||||
export const updateReviewStatus = async (
|
||||
productId: string,
|
||||
status: ReviewStatus,
|
||||
): Promise<ProductItem> => {
|
||||
// 后端暂无 /videos/{id}/review 端点
|
||||
// 暂时返回当前状态,后续可扩展
|
||||
const product = await getProduct(productId)
|
||||
return { ...product, review_status: status }
|
||||
}
|
||||
|
||||
/** 发起批量下载 — TODO: 后端暂无对应端点 */
|
||||
export const batchDownload = async (videoIds: string[]): Promise<{ job_id: string }> => {
|
||||
// 后端暂无 /videos/batch-download 端点
|
||||
// 暂时返回模拟 job_id,后续可扩展
|
||||
console.warn("[batchDownload] 后端暂无批量下载端点", videoIds)
|
||||
return { job_id: `mock-${Date.now()}` }
|
||||
}
|
||||
|
||||
/** 查询批量下载状态 — TODO: 后端暂无对应端点 */
|
||||
export const getBatchDownloadStatus = async (jobId: string): Promise<BatchDownloadStatus> => {
|
||||
// 后端暂无 /videos/batch-download/{jobId} 端点
|
||||
// 暂时返回模拟状态,后续可扩展
|
||||
console.warn("[getBatchDownloadStatus] 后端暂无批量下载状态端点", jobId)
|
||||
return { job_id: jobId, status: "processing", progress: 0 }
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
/**
|
||||
* 成品 / 视频相关 API — 目录化入口
|
||||
* 保持与原 products.ts 相同导出,向后兼容
|
||||
*/
|
||||
|
||||
// 类型
|
||||
export type {
|
||||
ReviewStatus,
|
||||
ProductItem,
|
||||
ProductListParams,
|
||||
ProductListResponse,
|
||||
BatchDownloadStatus,
|
||||
VideoItem,
|
||||
} from "./types"
|
||||
|
||||
// 工具函数
|
||||
export { mapVideoToProductItem } from "./utils"
|
||||
|
||||
// API 函数
|
||||
export {
|
||||
getProducts,
|
||||
getProduct,
|
||||
deleteProduct,
|
||||
getProductDownloadUrl,
|
||||
updateReviewStatus,
|
||||
batchDownload,
|
||||
getBatchDownloadStatus,
|
||||
} from "./products"
|
||||
@@ -1,80 +0,0 @@
|
||||
/**
|
||||
* 成品 / 视频相关 API 函数
|
||||
* 后端实际接口:/videos
|
||||
*/
|
||||
import apiClient from "../client"
|
||||
import type {
|
||||
BatchDownloadStatus,
|
||||
ProductItem,
|
||||
ProductListParams,
|
||||
VideoItem,
|
||||
ReviewStatus,
|
||||
} from "./types"
|
||||
import { mapVideoToProductItem } from "./utils"
|
||||
|
||||
/** 获取成品列表(支持分页和筛选) */
|
||||
export const getProducts = async (params?: ProductListParams): Promise<ProductItem[]> => {
|
||||
const response = await apiClient.get("/videos", { params })
|
||||
const data = response.data
|
||||
const videos: VideoItem[] = Array.isArray(data?.items)
|
||||
? data.items
|
||||
: Array.isArray(data)
|
||||
? data
|
||||
: []
|
||||
return videos.map(mapVideoToProductItem)
|
||||
}
|
||||
|
||||
/** 获取单个成品详情 */
|
||||
export const getProduct = async (productId: string): Promise<ProductItem> => {
|
||||
const response = await apiClient.get(`/videos/${productId}`)
|
||||
return mapVideoToProductItem(response.data as VideoItem)
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除成品
|
||||
* 注意:后端暂未实现 /videos DELETE 接口,调用会返回 405
|
||||
* 待后端实现后自动生效
|
||||
*/
|
||||
export const deleteProduct = async (productId: string): Promise<void> => {
|
||||
await apiClient.delete(`/videos/${productId}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取成品下载链接
|
||||
* 直接使用列表返回的 download_url(带OSS签名)
|
||||
*/
|
||||
export const getProductDownloadUrl = async (
|
||||
productId: string,
|
||||
): Promise<{ url: string; expires_at: string }> => {
|
||||
// 优先从列表缓存取;如果没有则调详情接口
|
||||
const product = await getProduct(productId)
|
||||
if (!product.video_url) throw new Error("下载链接不可用")
|
||||
return { url: product.video_url, expires_at: "" }
|
||||
}
|
||||
|
||||
/** 更新复核状态 — TODO: 后端暂无对应端点,暂存本地状态 */
|
||||
export const updateReviewStatus = async (
|
||||
productId: string,
|
||||
status: ReviewStatus,
|
||||
): Promise<ProductItem> => {
|
||||
// 后端暂无 /videos/{id}/review 端点
|
||||
// 暂时返回当前状态,后续可扩展
|
||||
const product = await getProduct(productId)
|
||||
return { ...product, review_status: status }
|
||||
}
|
||||
|
||||
/** 发起批量下载 — TODO: 后端暂无对应端点 */
|
||||
export const batchDownload = async (videoIds: string[]): Promise<{ job_id: string }> => {
|
||||
// 后端暂无 /videos/batch-download 端点
|
||||
// 暂时返回模拟 job_id,后续可扩展
|
||||
console.warn("[batchDownload] 后端暂无批量下载端点", videoIds)
|
||||
return { job_id: `mock-${Date.now()}` }
|
||||
}
|
||||
|
||||
/** 查询批量下载状态 — TODO: 后端暂无对应端点 */
|
||||
export const getBatchDownloadStatus = async (jobId: string): Promise<BatchDownloadStatus> => {
|
||||
// 后端暂无 /videos/batch-download/{jobId} 端点
|
||||
// 暂时返回模拟状态,后续可扩展
|
||||
console.warn("[getBatchDownloadStatus] 后端暂无批量下载状态端点", jobId)
|
||||
return { job_id: jobId, status: "processing", progress: 0 }
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
/**
|
||||
* 成品 / 视频相关类型定义
|
||||
*/
|
||||
|
||||
/** 复核状态 */
|
||||
export type ReviewStatus = "pending_review" | "approved" | "rejected"
|
||||
|
||||
/** 成品条目 */
|
||||
export interface ProductItem {
|
||||
id: string
|
||||
title: string
|
||||
video_url?: string
|
||||
thumbnail_url?: string
|
||||
duration_seconds?: number
|
||||
file_size?: number
|
||||
resolution?: string
|
||||
status: "processing" | "completed" | "failed"
|
||||
/** 复核状态 */
|
||||
review_status?: ReviewStatus
|
||||
/** 所属项目 ID */
|
||||
project_id?: string
|
||||
/** 所属项目名称 */
|
||||
project_name?: string
|
||||
/** 查重率(百分比) */
|
||||
duplicate_rate?: number
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
/** 列表查询参数 */
|
||||
export interface ProductListParams {
|
||||
page?: number
|
||||
page_size?: number
|
||||
project_id?: string
|
||||
review_status?: ReviewStatus | "all"
|
||||
}
|
||||
|
||||
/** 分页响应 */
|
||||
export interface ProductListResponse {
|
||||
items: ProductItem[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
}
|
||||
|
||||
/** 批量下载任务状态 */
|
||||
export interface BatchDownloadStatus {
|
||||
job_id: string
|
||||
status: "processing" | "completed" | "failed"
|
||||
/** 完成后返回的下载 URL */
|
||||
download_url?: string
|
||||
/** 进度百分比 */
|
||||
progress?: number
|
||||
}
|
||||
|
||||
/** 后端 /videos 接口返回的原始视频条目 */
|
||||
export interface VideoItem {
|
||||
id: string
|
||||
project_id: string
|
||||
generation_task_id: string
|
||||
name: string
|
||||
file_url: string
|
||||
file_size: number
|
||||
duration: number
|
||||
thumbnail_url: string | null
|
||||
width: number
|
||||
height: number
|
||||
fps: number
|
||||
status: string
|
||||
review_status: ReviewStatus
|
||||
generation_params: Record<string, unknown>
|
||||
download_url: string
|
||||
generated_at: string
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
/**
|
||||
* 成品数据转换工具函数
|
||||
*/
|
||||
import type { ProductItem, VideoItem } from "./types"
|
||||
|
||||
/**
|
||||
* 将后端 VideoItem 映射为 ProductItem 格式
|
||||
*/
|
||||
export function mapVideoToProductItem(video: VideoItem): ProductItem {
|
||||
return {
|
||||
id: video.id,
|
||||
title: video.name || "未命名视频",
|
||||
// 优先用 download_url(带签名)播放,file_url 无签名无法访问
|
||||
video_url: video.download_url || video.file_url,
|
||||
thumbnail_url: video.thumbnail_url || undefined,
|
||||
duration_seconds: video.duration,
|
||||
file_size: video.file_size,
|
||||
resolution: video.width && video.height ? `${video.width}x${video.height}` : undefined,
|
||||
status:
|
||||
video.status === "completed"
|
||||
? "completed"
|
||||
: video.status === "failed"
|
||||
? "failed"
|
||||
: "processing",
|
||||
review_status: video.review_status,
|
||||
project_id: video.project_id,
|
||||
// 后端 /videos 接口暂无 project_name 字段
|
||||
project_name: undefined,
|
||||
// 后端字段名为 generated_at,映射为 created_at 供前端统一使用
|
||||
created_at: video.generated_at,
|
||||
updated_at: video.generated_at,
|
||||
// 后端 /videos 接口暂无 duplicate_rate 字段
|
||||
duplicate_rate: undefined,
|
||||
}
|
||||
}
|
||||
@@ -1,286 +1,5 @@
|
||||
/**
|
||||
* 封面选择器
|
||||
* 抽帧选封面 + 上传自定义封面 + 智能封面推荐
|
||||
* 封面选择器入口(向后兼容)
|
||||
* 实际实现位于 ./cover-selector/ 目录
|
||||
*/
|
||||
import React, { useCallback, useRef, useState } from "react"
|
||||
import { Drawer } from "antd"
|
||||
import type { CoverConfig, CoverMode } from "../types"
|
||||
import { DEFAULT_COVER_CONFIG } from "../types"
|
||||
|
||||
interface CoverSelectorProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
config: CoverConfig
|
||||
onChange: (config: CoverConfig) => void
|
||||
totalDuration: number
|
||||
}
|
||||
|
||||
/** 封面模式标签 */
|
||||
const MODE_LABELS: Record<CoverMode, string> = {
|
||||
auto: "智能封面",
|
||||
frame: "抽帧选封面",
|
||||
upload: "上传封面",
|
||||
}
|
||||
|
||||
/** 封面模式图标 */
|
||||
const MODE_ICONS: Record<CoverMode, string> = {
|
||||
auto: "🤖",
|
||||
frame: "🎞️",
|
||||
upload: "📤",
|
||||
}
|
||||
|
||||
const CoverSelector: React.FC<CoverSelectorProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
config,
|
||||
onChange,
|
||||
totalDuration,
|
||||
}) => {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
|
||||
const update = useCallback(
|
||||
(partial: Partial<CoverConfig>) => {
|
||||
onChange({ ...config, ...partial })
|
||||
},
|
||||
[config, onChange],
|
||||
)
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
onChange({ ...DEFAULT_COVER_CONFIG, enabled: config.enabled })
|
||||
}, [config.enabled, onChange])
|
||||
|
||||
/** 切换模式 */
|
||||
const handleModeChange = useCallback(
|
||||
(mode: CoverMode) => {
|
||||
update({ mode })
|
||||
},
|
||||
[update],
|
||||
)
|
||||
|
||||
/** 处理文件上传 */
|
||||
const handleFileUpload = useCallback(
|
||||
(file: File) => {
|
||||
if (!file.type.startsWith("image/")) return
|
||||
const reader = new FileReader()
|
||||
reader.onload = (e) => {
|
||||
const url = e.target?.result as string
|
||||
update({ upload_url: url, thumbnail_url: url, mode: "upload" })
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
},
|
||||
[update],
|
||||
)
|
||||
|
||||
/** 拖拽上传 */
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(false)
|
||||
const file = e.dataTransfer.files[0]
|
||||
if (file) handleFileUpload(file)
|
||||
},
|
||||
[handleFileUpload],
|
||||
)
|
||||
|
||||
/** 使用 AI 推荐时间 */
|
||||
const handleUseAiSuggestion = useCallback(() => {
|
||||
if (config.ai_suggested_time !== null) {
|
||||
update({ frame_time: config.ai_suggested_time, mode: "frame" })
|
||||
}
|
||||
}, [config.ai_suggested_time, update])
|
||||
|
||||
/** 格式化时间 */
|
||||
const formatTime = (seconds: number) => {
|
||||
const m = Math.floor(seconds / 60)
|
||||
const s = Math.floor(seconds % 60)
|
||||
const ms = Math.floor((seconds % 1) * 10)
|
||||
return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}.${ms}`
|
||||
}
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title="封面选择"
|
||||
placement="right"
|
||||
width={440}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
className="cover-selector-drawer"
|
||||
>
|
||||
{/* 顶部开关 */}
|
||||
<div className="cover-header">
|
||||
<span className="cover-header-label">启用自定义封面</span>
|
||||
<label className="cover-switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.enabled}
|
||||
onChange={(e) => update({ enabled: e.target.checked })}
|
||||
/>
|
||||
<span className="cover-switch-slider" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* 模式选择 */}
|
||||
<div className="cover-mode-section">
|
||||
<div className="cover-section-title">封面来源</div>
|
||||
<div className="cover-mode-tabs">
|
||||
{(["auto", "frame", "upload"] as CoverMode[]).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
className={`cover-mode-tab${config.mode === m ? " active" : ""}`}
|
||||
onClick={() => handleModeChange(m)}
|
||||
>
|
||||
<span className="cover-mode-icon">{MODE_ICONS[m]}</span>
|
||||
<span className="cover-mode-label">{MODE_LABELS[m]}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 模式内容区 */}
|
||||
<div className="cover-mode-content">
|
||||
{/* 智能封面 */}
|
||||
{config.mode === "auto" && (
|
||||
<div className="cover-auto-section">
|
||||
<div className="cover-auto-desc">
|
||||
AI 将分析视频内容,自动选择最具吸引力的画面作为封面。
|
||||
</div>
|
||||
{config.ai_suggested_time !== null ? (
|
||||
<div className="cover-auto-suggestion">
|
||||
<div className="cover-auto-badge">AI 推荐</div>
|
||||
<div className="cover-auto-time">
|
||||
推荐时间点:{formatTime(config.ai_suggested_time)}
|
||||
</div>
|
||||
<button className="cover-auto-use-btn" onClick={handleUseAiSuggestion}>
|
||||
使用此时间点
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="cover-auto-pending">
|
||||
<div className="cover-auto-spinner" />
|
||||
<span>AI 分析中...(生成视频后自动推荐)</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 抽帧选封面 */}
|
||||
{config.mode === "frame" && (
|
||||
<div className="cover-frame-section">
|
||||
<div className="cover-frame-preview">
|
||||
<div className="cover-frame-placeholder">
|
||||
<span className="cover-frame-icon">🎞️</span>
|
||||
<span className="cover-frame-time">{formatTime(config.frame_time)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="cover-frame-timeline">
|
||||
<div className="cover-frame-slider-header">
|
||||
<span className="cover-frame-slider-label">拖动选择封面帧</span>
|
||||
<span className="cover-frame-slider-value">{formatTime(config.frame_time)}</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
className="cover-frame-slider"
|
||||
min={0}
|
||||
max={Math.max(totalDuration, 1)}
|
||||
step={0.1}
|
||||
value={config.frame_time}
|
||||
onChange={(e) => update({ frame_time: Number(e.target.value) })}
|
||||
/>
|
||||
<div className="cover-frame-range">
|
||||
<span>00:00</span>
|
||||
<span>{formatTime(totalDuration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* 快捷时间点 */}
|
||||
<div className="cover-frame-quick">
|
||||
<span className="cover-quick-label">快捷选帧:</span>
|
||||
{[0, 0.25, 0.5, 0.75].map((ratio) => {
|
||||
const t = totalDuration * ratio
|
||||
return (
|
||||
<button
|
||||
key={ratio}
|
||||
className="cover-quick-btn"
|
||||
onClick={() => update({ frame_time: t })}
|
||||
>
|
||||
{formatTime(t)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 上传封面 */}
|
||||
{config.mode === "upload" && (
|
||||
<div className="cover-upload-section">
|
||||
<div
|
||||
className={`cover-upload-area${isDragging ? " dragging" : ""}`}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(true)
|
||||
}}
|
||||
onDragLeave={() => setIsDragging(false)}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
{config.upload_url ? (
|
||||
<div className="cover-upload-preview">
|
||||
<img src={config.upload_url} alt="封面预览" />
|
||||
<div className="cover-upload-overlay">点击更换</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="cover-upload-placeholder">
|
||||
<span className="cover-upload-icon">📤</span>
|
||||
<span className="cover-upload-text">点击或拖拽上传封面图片</span>
|
||||
<span className="cover-upload-hint">支持 JPG / PNG,建议 16:9 比例</span>
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
style={{ display: "none" }}
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) handleFileUpload(file)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 封面预览 */}
|
||||
<div className="cover-preview-section">
|
||||
<div className="cover-section-title">封面预览</div>
|
||||
<div className="cover-preview-box">
|
||||
{config.upload_url ? (
|
||||
<img src={config.upload_url} alt="封面预览" className="cover-preview-img" />
|
||||
) : (
|
||||
<div className="cover-preview-placeholder">
|
||||
<span className="cover-preview-icon">🖼️</span>
|
||||
<span className="cover-preview-text">
|
||||
{config.mode === "auto"
|
||||
? "AI 智能选择"
|
||||
: config.mode === "frame"
|
||||
? `帧 ${formatTime(config.frame_time)}`
|
||||
: "未上传封面"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="cover-preview-ratio">16:9</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 底部 */}
|
||||
<div className="cover-footer">
|
||||
<button className="cover-reset-btn" onClick={handleReset}>
|
||||
重置封面
|
||||
</button>
|
||||
</div>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
export default CoverSelector
|
||||
export { default } from "./cover-selector"
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import React from "react"
|
||||
import type { CoverConfig } from "../../types"
|
||||
|
||||
interface CoverAutoModeProps {
|
||||
config: CoverConfig
|
||||
formatTime: (s: number) => string
|
||||
onUseAiSuggestion: () => void
|
||||
}
|
||||
|
||||
/** 智能封面模式面板 */
|
||||
export const CoverAutoMode: React.FC<CoverAutoModeProps> = ({
|
||||
config,
|
||||
formatTime,
|
||||
onUseAiSuggestion,
|
||||
}) => (
|
||||
<div className="cover-auto-section">
|
||||
<div className="cover-auto-desc">AI 将分析视频内容,自动选择最具吸引力的画面作为封面。</div>
|
||||
{config.ai_suggested_time !== null ? (
|
||||
<div className="cover-auto-suggestion">
|
||||
<div className="cover-auto-badge">AI 推荐</div>
|
||||
<div className="cover-auto-time">推荐时间点:{formatTime(config.ai_suggested_time)}</div>
|
||||
<button className="cover-auto-use-btn" onClick={onUseAiSuggestion}>
|
||||
使用此时间点
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="cover-auto-pending">
|
||||
<div className="cover-auto-spinner" />
|
||||
<span>AI 分析中...(生成视频后自动推荐)</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
interface CoverFrameModeProps {
|
||||
config: CoverConfig
|
||||
totalDuration: number
|
||||
formatTime: (s: number) => string
|
||||
onFrameTimeChange: (time: number) => void
|
||||
}
|
||||
|
||||
/** 抽帧选封面模式面板 */
|
||||
export const CoverFrameMode: React.FC<CoverFrameModeProps> = ({
|
||||
config,
|
||||
totalDuration,
|
||||
formatTime,
|
||||
onFrameTimeChange,
|
||||
}) => (
|
||||
<div className="cover-frame-section">
|
||||
<div className="cover-frame-preview">
|
||||
<div className="cover-frame-placeholder">
|
||||
<span className="cover-frame-icon">🎞️</span>
|
||||
<span className="cover-frame-time">{formatTime(config.frame_time)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="cover-frame-timeline">
|
||||
<div className="cover-frame-slider-header">
|
||||
<span className="cover-frame-slider-label">拖动选择封面帧</span>
|
||||
<span className="cover-frame-slider-value">{formatTime(config.frame_time)}</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
className="cover-frame-slider"
|
||||
min={0}
|
||||
max={Math.max(totalDuration, 1)}
|
||||
step={0.1}
|
||||
value={config.frame_time}
|
||||
onChange={(e) => onFrameTimeChange(Number(e.target.value))}
|
||||
/>
|
||||
<div className="cover-frame-range">
|
||||
<span>00:00</span>
|
||||
<span>{formatTime(totalDuration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="cover-frame-quick">
|
||||
<span className="cover-quick-label">快捷选帧:</span>
|
||||
{[0, 0.25, 0.5, 0.75].map((ratio) => {
|
||||
const t = totalDuration * ratio
|
||||
return (
|
||||
<button key={ratio} className="cover-quick-btn" onClick={() => onFrameTimeChange(t)}>
|
||||
{formatTime(t)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
interface CoverUploadModeProps {
|
||||
config: CoverConfig
|
||||
isDragging: boolean
|
||||
fileInputRef: React.RefObject<HTMLInputElement>
|
||||
onDragOver: (e: React.DragEvent) => void
|
||||
onDragLeave: () => void
|
||||
onDrop: (e: React.DragEvent) => void
|
||||
onAreaClick: () => void
|
||||
onFileChange: (file: File) => void
|
||||
}
|
||||
|
||||
/** 上传封面模式面板 */
|
||||
export const CoverUploadMode: React.FC<CoverUploadModeProps> = ({
|
||||
config,
|
||||
isDragging,
|
||||
fileInputRef,
|
||||
onDragOver,
|
||||
onDragLeave,
|
||||
onDrop,
|
||||
onAreaClick,
|
||||
onFileChange,
|
||||
}) => (
|
||||
<div className="cover-upload-section">
|
||||
<div
|
||||
className={`cover-upload-area${isDragging ? " dragging" : ""}`}
|
||||
onDragOver={onDragOver}
|
||||
onDragLeave={onDragLeave}
|
||||
onDrop={onDrop}
|
||||
onClick={onAreaClick}
|
||||
>
|
||||
{config.upload_url ? (
|
||||
<div className="cover-upload-preview">
|
||||
<img src={config.upload_url} alt="封面预览" />
|
||||
<div className="cover-upload-overlay">点击更换</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="cover-upload-placeholder">
|
||||
<span className="cover-upload-icon">📤</span>
|
||||
<span className="cover-upload-text">点击或拖拽上传封面图片</span>
|
||||
<span className="cover-upload-hint">支持 JPG / PNG,建议 16:9 比例</span>
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
style={{ display: "none" }}
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) onFileChange(file)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* 封面选择器
|
||||
* 抽帧选封面 + 上传自定义封面 + 智能封面推荐
|
||||
*/
|
||||
import React from "react"
|
||||
import { Drawer } from "antd"
|
||||
import type { CoverConfig, CoverMode } from "../../types"
|
||||
import { useCoverSelector, MODE_LABELS, MODE_ICONS } from "./useCoverSelector"
|
||||
import { CoverAutoMode, CoverFrameMode, CoverUploadMode } from "./CoverModePanels"
|
||||
|
||||
interface CoverSelectorProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
config: CoverConfig
|
||||
onChange: (config: CoverConfig) => void
|
||||
totalDuration: number
|
||||
}
|
||||
|
||||
const CoverSelector: React.FC<CoverSelectorProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
config,
|
||||
onChange,
|
||||
totalDuration,
|
||||
}) => {
|
||||
const {
|
||||
fileInputRef,
|
||||
isDragging,
|
||||
setIsDragging,
|
||||
update,
|
||||
handleReset,
|
||||
handleModeChange,
|
||||
handleFileUpload,
|
||||
handleDrop,
|
||||
handleUseAiSuggestion,
|
||||
formatTime,
|
||||
} = useCoverSelector({ config, onChange })
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title="封面选择"
|
||||
placement="right"
|
||||
width={440}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
className="cover-selector-drawer"
|
||||
>
|
||||
{/* 顶部开关 */}
|
||||
<div className="cover-header">
|
||||
<span className="cover-header-label">启用自定义封面</span>
|
||||
<label className="cover-switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.enabled}
|
||||
onChange={(e) => update({ enabled: e.target.checked })}
|
||||
/>
|
||||
<span className="cover-switch-slider" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* 模式选择 */}
|
||||
<div className="cover-mode-section">
|
||||
<div className="cover-section-title">封面来源</div>
|
||||
<div className="cover-mode-tabs">
|
||||
{(["auto", "frame", "upload"] as CoverMode[]).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
className={`cover-mode-tab${config.mode === m ? " active" : ""}`}
|
||||
onClick={() => handleModeChange(m)}
|
||||
>
|
||||
<span className="cover-mode-icon">{MODE_ICONS[m]}</span>
|
||||
<span className="cover-mode-label">{MODE_LABELS[m]}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 模式内容区 */}
|
||||
<div className="cover-mode-content">
|
||||
{config.mode === "auto" && (
|
||||
<CoverAutoMode
|
||||
config={config}
|
||||
formatTime={formatTime}
|
||||
onUseAiSuggestion={handleUseAiSuggestion}
|
||||
/>
|
||||
)}
|
||||
|
||||
{config.mode === "frame" && (
|
||||
<CoverFrameMode
|
||||
config={config}
|
||||
totalDuration={totalDuration}
|
||||
formatTime={formatTime}
|
||||
onFrameTimeChange={(t) => update({ frame_time: t })}
|
||||
/>
|
||||
)}
|
||||
|
||||
{config.mode === "upload" && (
|
||||
<CoverUploadMode
|
||||
config={config}
|
||||
isDragging={isDragging}
|
||||
fileInputRef={fileInputRef}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(true)
|
||||
}}
|
||||
onDragLeave={() => setIsDragging(false)}
|
||||
onDrop={handleDrop}
|
||||
onAreaClick={() => fileInputRef.current?.click()}
|
||||
onFileChange={handleFileUpload}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 封面预览 */}
|
||||
<div className="cover-preview-section">
|
||||
<div className="cover-section-title">封面预览</div>
|
||||
<div className="cover-preview-box">
|
||||
{config.upload_url ? (
|
||||
<img src={config.upload_url} alt="封面预览" className="cover-preview-img" />
|
||||
) : (
|
||||
<div className="cover-preview-placeholder">
|
||||
<span className="cover-preview-icon">🖼️</span>
|
||||
<span className="cover-preview-text">
|
||||
{config.mode === "auto"
|
||||
? "AI 智能选择"
|
||||
: config.mode === "frame"
|
||||
? `帧 ${formatTime(config.frame_time)}`
|
||||
: "未上传封面"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="cover-preview-ratio">16:9</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 底部 */}
|
||||
<div className="cover-footer">
|
||||
<button className="cover-reset-btn" onClick={handleReset}>
|
||||
重置封面
|
||||
</button>
|
||||
</div>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
export default CoverSelector
|
||||
@@ -0,0 +1,98 @@
|
||||
import { useCallback, useRef, useState } from "react"
|
||||
import type { CoverConfig, CoverMode } from "../../types"
|
||||
import { DEFAULT_COVER_CONFIG } from "../../types"
|
||||
|
||||
/** 封面模式标签 */
|
||||
export const MODE_LABELS: Record<CoverMode, string> = {
|
||||
auto: "智能封面",
|
||||
frame: "抽帧选封面",
|
||||
upload: "上传封面",
|
||||
}
|
||||
|
||||
/** 封面模式图标 */
|
||||
export const MODE_ICONS: Record<CoverMode, string> = {
|
||||
auto: "🤖",
|
||||
frame: "🎞️",
|
||||
upload: "📤",
|
||||
}
|
||||
|
||||
interface UseCoverSelectorOptions {
|
||||
config: CoverConfig
|
||||
onChange: (config: CoverConfig) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 封面选择器 Hook
|
||||
* 封装状态管理、文件上传、模式切换等逻辑
|
||||
*/
|
||||
export function useCoverSelector({ config, onChange }: UseCoverSelectorOptions) {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
|
||||
const update = useCallback(
|
||||
(partial: Partial<CoverConfig>) => {
|
||||
onChange({ ...config, ...partial })
|
||||
},
|
||||
[config, onChange],
|
||||
)
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
onChange({ ...DEFAULT_COVER_CONFIG, enabled: config.enabled })
|
||||
}, [config.enabled, onChange])
|
||||
|
||||
const handleModeChange = useCallback(
|
||||
(mode: CoverMode) => {
|
||||
update({ mode })
|
||||
},
|
||||
[update],
|
||||
)
|
||||
|
||||
const handleFileUpload = useCallback(
|
||||
(file: File) => {
|
||||
if (!file.type.startsWith("image/")) return
|
||||
const reader = new FileReader()
|
||||
reader.onload = (e) => {
|
||||
const url = e.target?.result as string
|
||||
update({ upload_url: url, thumbnail_url: url, mode: "upload" })
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
},
|
||||
[update],
|
||||
)
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(false)
|
||||
const file = e.dataTransfer.files[0]
|
||||
if (file) handleFileUpload(file)
|
||||
},
|
||||
[handleFileUpload],
|
||||
)
|
||||
|
||||
const handleUseAiSuggestion = useCallback(() => {
|
||||
if (config.ai_suggested_time !== null) {
|
||||
update({ frame_time: config.ai_suggested_time, mode: "frame" })
|
||||
}
|
||||
}, [config.ai_suggested_time, update])
|
||||
|
||||
const formatTime = (seconds: number) => {
|
||||
const m = Math.floor(seconds / 60)
|
||||
const s = Math.floor(seconds % 60)
|
||||
const ms = Math.floor((seconds % 1) * 10)
|
||||
return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}.${ms}`
|
||||
}
|
||||
|
||||
return {
|
||||
fileInputRef,
|
||||
isDragging,
|
||||
setIsDragging,
|
||||
update,
|
||||
handleReset,
|
||||
handleModeChange,
|
||||
handleFileUpload,
|
||||
handleDrop,
|
||||
handleUseAiSuggestion,
|
||||
formatTime,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user