Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 862340ace5 | |||
| b40ae9cec7 | |||
| eb636b8fef | |||
| 09b069a65d | |||
| 378e4c8751 | |||
| f9afdd95ce | |||
| 456718ad84 | |||
| edd4b6b1ea | |||
| 4578b65965 | |||
| e4b3af78b9 | |||
| ff827aba01 | |||
| a75cce0a3b | |||
| b051acc8b4 | |||
| 271db4e989 | |||
| 9eca947f88 | |||
| 8d3647f414 | |||
| a258d7f9d1 | |||
| 30713e698c | |||
| 2b120f5c65 | |||
| bf55cfc3ca | |||
| f23b0ceae4 | |||
| 66b6ed9f12 | |||
| 1e49618817 | |||
| 06716a0678 | |||
| 31dc5b382a | |||
| 57c7543c7f | |||
| 796cac249f | |||
| abe275acc4 | |||
| 1f11981a95 | |||
| 1b7a7bcb55 | |||
| da3ca4bc46 | |||
| 8849f5b358 | |||
| f246f7d8aa | |||
| 602bb388d1 | |||
| fba0ade0ca | |||
| 8882e24fd4 |
@@ -1805,7 +1805,7 @@ jobs:
|
|||||||
echo "❌ CI Gate: FAILED"
|
echo "❌ CI Gate: FAILED"
|
||||||
echo "失败项: ${FAILED_ITEMS[*]}"
|
echo "失败项: ${FAILED_ITEMS[*]}"
|
||||||
echo "gate_result=failure" >> $GITHUB_OUTPUT
|
echo "gate_result=failure" >> $GITHUB_OUTPUT
|
||||||
exit 0
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Report CI trace
|
- name: Report CI trace
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ jobs:
|
|||||||
GITEA_TOKEN: ${{ secrets.REVIEW_GITEA_TOKEN }}
|
GITEA_TOKEN: ${{ secrets.REVIEW_GITEA_TOKEN }}
|
||||||
REPO_NAME: ${{ gitea.repository }}
|
REPO_NAME: ${{ gitea.repository }}
|
||||||
PR_NUMBER: ${{ gitea.event.pull_request.number }}
|
PR_NUMBER: ${{ gitea.event.pull_request.number }}
|
||||||
|
PR_HEAD_SHA: ${{ gitea.event.pull_request.head.sha }}
|
||||||
# LLM 提供商: coze (扣子原生Bot) / openai (OpenAI兼容)
|
# LLM 提供商: coze (扣子原生Bot) / openai (OpenAI兼容)
|
||||||
LLM_PROVIDER: "coze"
|
LLM_PROVIDER: "coze"
|
||||||
# 扣子模式配置(默认国内站 api.coze.cn)
|
# 扣子模式配置(默认国内站 api.coze.cn)
|
||||||
@@ -60,8 +61,9 @@ jobs:
|
|||||||
LLM_TIMEOUT: "120"
|
LLM_TIMEOUT: "120"
|
||||||
run: |
|
run: |
|
||||||
python3 scripts/ci_code_review.py
|
python3 scripts/ci_code_review.py
|
||||||
# 审查脚本异常不影响 CI 通过
|
# 注意:脚本退出码决定job状态
|
||||||
continue-on-error: true
|
# - 有阻塞级问题 → exit 1 → job失败 → 门禁拦截
|
||||||
|
# - 无阻塞级问题/LLM异常 → exit 0 → 通过(fail-open)
|
||||||
|
|
||||||
- name: Report CI trace
|
- name: Report CI trace
|
||||||
if: always()
|
if: always()
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import React from "react"
|
||||||
|
import type { MediaAsset } from "@/api/template-editor"
|
||||||
|
import { MATERIAL_TYPE_LABELS, MATERIAL_TYPE_ICONS } from "@/api/template-editor"
|
||||||
|
import { formatSize, formatDuration, getQualityLevel } from "./utils"
|
||||||
|
|
||||||
|
interface AssetCardProps {
|
||||||
|
asset: MediaAsset
|
||||||
|
isSelected: boolean
|
||||||
|
isDragging: boolean
|
||||||
|
isDragOver: boolean
|
||||||
|
showCheckbox: boolean
|
||||||
|
compact?: boolean
|
||||||
|
onToggleSelect: (asset: MediaAsset, shiftKey: boolean) => void
|
||||||
|
onCardClick: (asset: MediaAsset, e: React.MouseEvent) => void
|
||||||
|
onDragStart: (e: React.DragEvent) => void
|
||||||
|
onDragOver: (e: React.DragEvent) => void
|
||||||
|
onDrop: (e: React.DragEvent) => void
|
||||||
|
onDragEnd: () => void
|
||||||
|
onMouseEnter: (e: React.MouseEvent) => void
|
||||||
|
onMouseLeave: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const AssetCard: React.FC<AssetCardProps> = ({
|
||||||
|
asset,
|
||||||
|
isSelected,
|
||||||
|
isDragging,
|
||||||
|
isDragOver,
|
||||||
|
showCheckbox,
|
||||||
|
onToggleSelect,
|
||||||
|
onCardClick,
|
||||||
|
onDragStart,
|
||||||
|
onDragOver,
|
||||||
|
onDrop,
|
||||||
|
onDragEnd,
|
||||||
|
onMouseEnter,
|
||||||
|
onMouseLeave,
|
||||||
|
}) => {
|
||||||
|
const qualityLevel = getQualityLevel(asset.quality_score)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={[
|
||||||
|
"as-card",
|
||||||
|
isSelected ? "selected" : "",
|
||||||
|
isDragging ? "dragging" : "",
|
||||||
|
isDragOver ? "drag-over" : "",
|
||||||
|
showCheckbox ? "has-checkbox" : "",
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ")}
|
||||||
|
draggable
|
||||||
|
onDragStart={onDragStart}
|
||||||
|
onDragOver={onDragOver}
|
||||||
|
onDrop={onDrop}
|
||||||
|
onDragEnd={onDragEnd}
|
||||||
|
onClick={(e) => onCardClick(asset, e)}
|
||||||
|
onMouseEnter={onMouseEnter}
|
||||||
|
onMouseLeave={onMouseLeave}
|
||||||
|
>
|
||||||
|
{/* 缩略图 */}
|
||||||
|
<div className="as-card-thumb">
|
||||||
|
{asset.thumbnail_url ? (
|
||||||
|
<img src={asset.thumbnail_url} alt={asset.name} loading="lazy" />
|
||||||
|
) : (
|
||||||
|
<span className="as-card-thumb-icon">{MATERIAL_TYPE_ICONS[asset.type]}</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Checkbox */}
|
||||||
|
{showCheckbox && (
|
||||||
|
<span
|
||||||
|
data-checkbox
|
||||||
|
className={`as-card-checkbox${isSelected ? " checked" : ""}`}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onToggleSelect(asset, e.shiftKey)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 类型角标 */}
|
||||||
|
<span className="as-card-type-badge">{MATERIAL_TYPE_LABELS[asset.type]}</span>
|
||||||
|
|
||||||
|
{/* 时长角标 */}
|
||||||
|
{asset.duration != null && (
|
||||||
|
<span className="as-card-duration">{formatDuration(asset.duration)}</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 质量分角标 */}
|
||||||
|
{asset.quality_score != null && (
|
||||||
|
<span
|
||||||
|
className={`as-card-quality ${qualityLevel}`}
|
||||||
|
title={`质量分: ${asset.quality_score}`}
|
||||||
|
>
|
||||||
|
{asset.quality_score}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 信息 */}
|
||||||
|
<div className="as-card-info">
|
||||||
|
<p className="as-card-name" title={asset.name}>
|
||||||
|
{asset.name}
|
||||||
|
</p>
|
||||||
|
<div className="as-card-meta">{formatSize(asset.size)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AssetCard
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import React from "react"
|
||||||
|
import type { MediaAsset } from "@/api/template-editor"
|
||||||
|
import { MATERIAL_TYPE_LABELS, MATERIAL_TYPE_ICONS } from "@/api/template-editor"
|
||||||
|
import { formatSize, formatDuration, getQualityColor } from "./utils"
|
||||||
|
|
||||||
|
interface AssetListItemProps {
|
||||||
|
asset: MediaAsset
|
||||||
|
isSelected: boolean
|
||||||
|
isDragging: boolean
|
||||||
|
isDragOver: boolean
|
||||||
|
showCheckbox: boolean
|
||||||
|
onToggleSelect: (asset: MediaAsset, shiftKey: boolean) => void
|
||||||
|
onCardClick: (asset: MediaAsset, e: React.MouseEvent) => void
|
||||||
|
onDragStart: (e: React.DragEvent) => void
|
||||||
|
onDragOver: (e: React.DragEvent) => void
|
||||||
|
onDrop: (e: React.DragEvent) => void
|
||||||
|
onDragEnd: () => void
|
||||||
|
onMouseEnter: (e: React.MouseEvent) => void
|
||||||
|
onMouseLeave: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const AssetListItem: React.FC<AssetListItemProps> = ({
|
||||||
|
asset,
|
||||||
|
isSelected,
|
||||||
|
isDragging,
|
||||||
|
isDragOver,
|
||||||
|
showCheckbox,
|
||||||
|
onToggleSelect,
|
||||||
|
onCardClick,
|
||||||
|
onDragStart,
|
||||||
|
onDragOver,
|
||||||
|
onDrop,
|
||||||
|
onDragEnd,
|
||||||
|
onMouseEnter,
|
||||||
|
onMouseLeave,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={[
|
||||||
|
"as-list-item",
|
||||||
|
isSelected ? "selected" : "",
|
||||||
|
isDragging ? "dragging" : "",
|
||||||
|
isDragOver ? "drag-over" : "",
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ")}
|
||||||
|
draggable
|
||||||
|
onDragStart={onDragStart}
|
||||||
|
onDragOver={onDragOver}
|
||||||
|
onDrop={onDrop}
|
||||||
|
onDragEnd={onDragEnd}
|
||||||
|
onClick={(e) => onCardClick(asset, e)}
|
||||||
|
onMouseEnter={onMouseEnter}
|
||||||
|
onMouseLeave={onMouseLeave}
|
||||||
|
>
|
||||||
|
{/* 拖拽手柄 */}
|
||||||
|
<span className="as-list-item-drag" title="拖拽排序">
|
||||||
|
⠿
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Checkbox */}
|
||||||
|
{showCheckbox && (
|
||||||
|
<span
|
||||||
|
data-checkbox
|
||||||
|
className={`as-list-item-checkbox${isSelected ? " checked" : ""}`}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onToggleSelect(asset, e.shiftKey)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 图标 */}
|
||||||
|
<span className="as-list-item-icon">{MATERIAL_TYPE_ICONS[asset.type]}</span>
|
||||||
|
|
||||||
|
{/* 信息 */}
|
||||||
|
<div className="as-list-item-info">
|
||||||
|
<div className="as-list-item-name">{asset.name}</div>
|
||||||
|
<div className="as-list-item-meta">
|
||||||
|
{MATERIAL_TYPE_LABELS[asset.type]}
|
||||||
|
{asset.duration != null && ` · ${formatDuration(asset.duration)}`}
|
||||||
|
{asset.size != null && ` · ${formatSize(asset.size)}`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 质量分 */}
|
||||||
|
{asset.quality_score != null && (
|
||||||
|
<span
|
||||||
|
className="as-list-item-quality"
|
||||||
|
style={{ color: getQualityColor(asset.quality_score) }}
|
||||||
|
>
|
||||||
|
{asset.quality_score}分
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AssetListItem
|
||||||
@@ -9,73 +9,20 @@
|
|||||||
* - 筛选增强:类型筛选 + 质量分筛选
|
* - 筛选增强:类型筛选 + 质量分筛选
|
||||||
* - 视图切换:网格视图 / 列表视图
|
* - 视图切换:网格视图 / 列表视图
|
||||||
*/
|
*/
|
||||||
import React, { useState, useMemo, useCallback, useRef, useEffect } from "react"
|
import React, { useCallback } from "react"
|
||||||
import "./AssetSelector.css"
|
import "./AssetSelector.css"
|
||||||
import { Input, Select, Button } from "@/components/ui"
|
import type { AssetSelectorProps } from "./types"
|
||||||
|
import useAssetFilter from "./hooks/useAssetFilter"
|
||||||
|
import useAssetSelection from "./hooks/useAssetSelection"
|
||||||
|
import useDragReorder from "./hooks/useDragReorder"
|
||||||
|
import useAssetPreview from "./hooks/useAssetPreview"
|
||||||
|
import SelectorToolbar from "./SelectorToolbar"
|
||||||
|
import SelectorBatchBar from "./SelectorBatchBar"
|
||||||
|
import AssetCard from "./AssetCard"
|
||||||
|
import AssetListItem from "./AssetListItem"
|
||||||
|
import PreviewOverlay from "./PreviewOverlay"
|
||||||
|
import EmptyState from "./EmptyState"
|
||||||
import type { MediaAsset } from "@/api/template-editor"
|
import type { MediaAsset } from "@/api/template-editor"
|
||||||
import { MATERIAL_TYPE_LABELS, MATERIAL_TYPE_ICONS, QUALITY_OPTIONS } from "@/api/template-editor"
|
|
||||||
|
|
||||||
/* ──────────── 类型 ──────────── */
|
|
||||||
|
|
||||||
export interface AssetSelectorProps {
|
|
||||||
assets: MediaAsset[]
|
|
||||||
selectedIds?: string[]
|
|
||||||
onSelectionChange?: (ids: string[]) => void
|
|
||||||
onAssetDragStart?: (asset: MediaAsset) => void
|
|
||||||
onReorder?: (fromIdx: number, toIdx: number) => void
|
|
||||||
showQualityFilter?: boolean
|
|
||||||
showBatchSelect?: boolean
|
|
||||||
compact?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
type ViewMode = "grid" | "list"
|
|
||||||
|
|
||||||
/* ──────────── 工具函数 ──────────── */
|
|
||||||
|
|
||||||
/** 格式化文件大小 */
|
|
||||||
const formatSize = (bytes?: number): string => {
|
|
||||||
if (!bytes) return ""
|
|
||||||
if (bytes < 1024) return `${bytes}B`
|
|
||||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`
|
|
||||||
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 格式化时长 */
|
|
||||||
const formatDuration = (seconds?: number): string => {
|
|
||||||
if (!seconds) return ""
|
|
||||||
const m = Math.floor(seconds / 60)
|
|
||||||
const s = Math.floor(seconds % 60)
|
|
||||||
return m > 0 ? `${m}:${s.toString().padStart(2, "0")}` : `${s}s`
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 获取质量分等级 */
|
|
||||||
const getQualityLevel = (score?: number): string => {
|
|
||||||
if (score == null) return "none"
|
|
||||||
if (score >= 90) return "excellent"
|
|
||||||
if (score >= 70) return "good"
|
|
||||||
if (score >= 50) return "fair"
|
|
||||||
return "poor"
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 质量分颜色 */
|
|
||||||
const getQualityColor = (score?: number): string => {
|
|
||||||
if (score == null) return "var(--text-secondary)"
|
|
||||||
if (score >= 90) return "var(--success-color, #10b981)"
|
|
||||||
if (score >= 70) return "var(--primary-color, #6366f1)"
|
|
||||||
if (score >= 50) return "var(--warning-color, #f59e0b)"
|
|
||||||
return "var(--error-color, #ef4444)"
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ──────────── 类型筛选选项 ──────────── */
|
|
||||||
|
|
||||||
const TYPE_OPTIONS = [
|
|
||||||
{ value: "", label: "全部类型" },
|
|
||||||
{ value: "video", label: "🎬 视频" },
|
|
||||||
{ value: "image", label: "🖼️ 图片" },
|
|
||||||
{ value: "audio", label: "🎵 音频" },
|
|
||||||
]
|
|
||||||
|
|
||||||
/* ──────────── 组件 ──────────── */
|
|
||||||
|
|
||||||
const AssetSelector: React.FC<AssetSelectorProps> = ({
|
const AssetSelector: React.FC<AssetSelectorProps> = ({
|
||||||
assets,
|
assets,
|
||||||
@@ -87,162 +34,41 @@ const AssetSelector: React.FC<AssetSelectorProps> = ({
|
|||||||
showBatchSelect = true,
|
showBatchSelect = true,
|
||||||
compact = false,
|
compact = false,
|
||||||
}) => {
|
}) => {
|
||||||
/* ── 搜索 & 筛选 ── */
|
// 搜索 & 筛选
|
||||||
const [searchText, setSearchText] = useState("")
|
const {
|
||||||
const [filterType, setFilterType] = useState("")
|
searchText,
|
||||||
const [filterQuality, setFilterQuality] = useState("")
|
filterType,
|
||||||
const [viewMode, setViewMode] = useState<ViewMode>("grid")
|
filterQuality,
|
||||||
|
viewMode,
|
||||||
|
filteredAssets,
|
||||||
|
setSearchText,
|
||||||
|
setFilterType,
|
||||||
|
setFilterQuality,
|
||||||
|
setViewMode,
|
||||||
|
} = useAssetFilter(assets)
|
||||||
|
|
||||||
/* ── 拖拽状态 ── */
|
// 选中操作
|
||||||
const [dragIdx, setDragIdx] = useState<number | null>(null)
|
const { selectedSet, toggleSelect, clearSelection } = useAssetSelection({
|
||||||
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null)
|
filteredAssets,
|
||||||
|
selectedIds,
|
||||||
|
onSelectionChange,
|
||||||
|
})
|
||||||
|
|
||||||
/* ── 悬浮预览 ── */
|
// 拖拽排序
|
||||||
const [previewAsset, setPreviewAsset] = useState<MediaAsset | null>(null)
|
const { dragIdx, dragOverIdx, handleDragStart, handleDragOver, handleDrop, handleDragEnd } =
|
||||||
const [previewPos, setPreviewPos] = useState({ x: 0, y: 0 })
|
useDragReorder({
|
||||||
const previewTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
filteredAssets,
|
||||||
|
selectedIds,
|
||||||
|
onAssetDragStart,
|
||||||
|
onReorder,
|
||||||
|
})
|
||||||
|
|
||||||
/* ── Shift 连选 ── */
|
// 悬浮预览
|
||||||
const lastClickedIdx = useRef<number | null>(null)
|
const { previewAsset, previewPos, handleMouseEnter, handleMouseLeave } = useAssetPreview()
|
||||||
|
|
||||||
/* ── 过滤后的素材列表 ── */
|
|
||||||
const filteredAssets = useMemo(() => {
|
|
||||||
let list = assets
|
|
||||||
if (searchText) {
|
|
||||||
const q = searchText.toLowerCase()
|
|
||||||
list = list.filter(
|
|
||||||
(a) => a.name.toLowerCase().includes(q) || a.tags.some((t) => t.toLowerCase().includes(q)),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (filterType) {
|
|
||||||
list = list.filter((a) => a.type === filterType)
|
|
||||||
}
|
|
||||||
if (filterQuality) {
|
|
||||||
const opt = QUALITY_OPTIONS.find((o) => o.value === filterQuality)
|
|
||||||
if (opt?.min != null && opt?.max != null) {
|
|
||||||
list = list.filter(
|
|
||||||
(a) =>
|
|
||||||
a.quality_score != null && a.quality_score >= opt.min! && a.quality_score <= opt.max!,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return list
|
|
||||||
}, [assets, searchText, filterType, filterQuality])
|
|
||||||
|
|
||||||
/* ── 选中状态 ── */
|
|
||||||
const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds])
|
|
||||||
|
|
||||||
/* ── 选择操作 ── */
|
|
||||||
const toggleSelect = useCallback(
|
|
||||||
(asset: MediaAsset, idx: number, shiftKey: boolean) => {
|
|
||||||
if (!onSelectionChange) return
|
|
||||||
|
|
||||||
if (shiftKey && lastClickedIdx.current !== null) {
|
|
||||||
// Shift 连选
|
|
||||||
const start = Math.min(lastClickedIdx.current, idx)
|
|
||||||
const end = Math.max(lastClickedIdx.current, idx)
|
|
||||||
const rangeIds = filteredAssets.slice(start, end + 1).map((a) => a.id)
|
|
||||||
const newSet = new Set(selectedIds)
|
|
||||||
rangeIds.forEach((id) => newSet.add(id))
|
|
||||||
onSelectionChange(Array.from(newSet))
|
|
||||||
} else {
|
|
||||||
const newSet = new Set(selectedIds)
|
|
||||||
if (newSet.has(asset.id)) {
|
|
||||||
newSet.delete(asset.id)
|
|
||||||
} else {
|
|
||||||
newSet.add(asset.id)
|
|
||||||
}
|
|
||||||
onSelectionChange(Array.from(newSet))
|
|
||||||
}
|
|
||||||
lastClickedIdx.current = idx
|
|
||||||
},
|
|
||||||
[onSelectionChange, selectedIds, filteredAssets],
|
|
||||||
)
|
|
||||||
|
|
||||||
const clearSelection = useCallback(() => {
|
|
||||||
onSelectionChange?.([])
|
|
||||||
}, [onSelectionChange])
|
|
||||||
|
|
||||||
/* ── 拖拽排序 ── */
|
|
||||||
const handleDragStart = useCallback(
|
|
||||||
(e: React.DragEvent, idx: number) => {
|
|
||||||
setDragIdx(idx)
|
|
||||||
e.dataTransfer.effectAllowed = "move"
|
|
||||||
e.dataTransfer.setData("text/plain", String(idx))
|
|
||||||
// 设置素材数据,供 TimelinePanel 接收(P1-2 修复)
|
|
||||||
e.dataTransfer.setData("application/x-media-asset", JSON.stringify(filteredAssets[idx]))
|
|
||||||
// 批量拖拽:如果有多个选中素材,一起携带
|
|
||||||
if (selectedIds.length > 1 && selectedIds.includes(filteredAssets[idx].id)) {
|
|
||||||
const batchAssets = filteredAssets.filter((a) => selectedIds.includes(a.id))
|
|
||||||
e.dataTransfer.setData("application/x-media-assets", JSON.stringify(batchAssets))
|
|
||||||
}
|
|
||||||
// 通知父组件素材拖拽开始
|
|
||||||
if (onAssetDragStart) {
|
|
||||||
onAssetDragStart(filteredAssets[idx])
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[filteredAssets, selectedIds, onAssetDragStart],
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleDragOver = useCallback(
|
|
||||||
(e: React.DragEvent, idx: number) => {
|
|
||||||
e.preventDefault()
|
|
||||||
e.dataTransfer.dropEffect = "move"
|
|
||||||
if (dragIdx === null || dragIdx === idx) return
|
|
||||||
setDragOverIdx(idx)
|
|
||||||
},
|
|
||||||
[dragIdx],
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleDrop = useCallback(
|
|
||||||
(e: React.DragEvent, toIdx: number) => {
|
|
||||||
e.preventDefault()
|
|
||||||
if (dragIdx !== null && dragIdx !== toIdx && onReorder) {
|
|
||||||
onReorder(dragIdx, toIdx)
|
|
||||||
}
|
|
||||||
setDragIdx(null)
|
|
||||||
setDragOverIdx(null)
|
|
||||||
},
|
|
||||||
[dragIdx, onReorder],
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleDragEnd = useCallback(() => {
|
|
||||||
setDragIdx(null)
|
|
||||||
setDragOverIdx(null)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
/* ── 悬浮预览 ── */
|
|
||||||
const handleMouseEnter = useCallback((asset: MediaAsset, e: React.MouseEvent) => {
|
|
||||||
if (previewTimer.current) clearTimeout(previewTimer.current)
|
|
||||||
previewTimer.current = setTimeout(() => {
|
|
||||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
|
|
||||||
setPreviewAsset(asset)
|
|
||||||
setPreviewPos({
|
|
||||||
x: rect.right + 12,
|
|
||||||
y: Math.max(8, rect.top - 20),
|
|
||||||
})
|
|
||||||
}, 400)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleMouseLeave = useCallback(() => {
|
|
||||||
if (previewTimer.current) {
|
|
||||||
clearTimeout(previewTimer.current)
|
|
||||||
previewTimer.current = null
|
|
||||||
}
|
|
||||||
setPreviewAsset(null)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// 清理定时器
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
if (previewTimer.current) clearTimeout(previewTimer.current)
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
/* ── 点击卡片 ── */
|
/* ── 点击卡片 ── */
|
||||||
const handleCardClick = useCallback(
|
const handleCardClick = useCallback(
|
||||||
(asset: MediaAsset, idx: number, e: React.MouseEvent) => {
|
(asset: MediaAsset, idx: number, e: React.MouseEvent) => {
|
||||||
// 如果点击的是 checkbox 区域,不触发卡片点击
|
|
||||||
const target = e.target as HTMLElement
|
const target = e.target as HTMLElement
|
||||||
if (target.closest("[data-checkbox]")) return
|
if (target.closest("[data-checkbox]")) return
|
||||||
toggleSelect(asset, idx, e.shiftKey)
|
toggleSelect(asset, idx, e.shiftKey)
|
||||||
@@ -250,253 +76,82 @@ const AssetSelector: React.FC<AssetSelectorProps> = ({
|
|||||||
[toggleSelect],
|
[toggleSelect],
|
||||||
)
|
)
|
||||||
|
|
||||||
/* ──────────── 渲染 ──────────── */
|
|
||||||
|
|
||||||
const hasSelection = selectedIds.length > 0
|
const hasSelection = selectedIds.length > 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="as-container">
|
<div className="as-container">
|
||||||
{/* ═══ 工具栏 ═══ */}
|
{/* 工具栏 */}
|
||||||
<div className="as-toolbar">
|
<SelectorToolbar
|
||||||
<div className="as-toolbar-left">
|
searchText={searchText}
|
||||||
<div className="as-search">
|
filterType={filterType}
|
||||||
<Input
|
filterQuality={filterQuality}
|
||||||
placeholder="搜索素材..."
|
viewMode={viewMode}
|
||||||
value={searchText}
|
showQualityFilter={showQualityFilter}
|
||||||
onChange={(e) => setSearchText(e.target.value)}
|
onSearchChange={setSearchText}
|
||||||
prefix="🔍"
|
onTypeChange={setFilterType}
|
||||||
/>
|
onQualityChange={setFilterQuality}
|
||||||
</div>
|
onViewModeChange={setViewMode}
|
||||||
<Select
|
/>
|
||||||
value={filterType}
|
|
||||||
onChange={(v: string) => setFilterType(v)}
|
|
||||||
options={TYPE_OPTIONS}
|
|
||||||
/>
|
|
||||||
{showQualityFilter && (
|
|
||||||
<Select
|
|
||||||
value={filterQuality}
|
|
||||||
onChange={(v: string) => setFilterQuality(v)}
|
|
||||||
options={QUALITY_OPTIONS}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="as-toolbar-right">
|
|
||||||
<button
|
|
||||||
className={`as-view-btn${viewMode === "grid" ? " active" : ""}`}
|
|
||||||
onClick={() => setViewMode("grid")}
|
|
||||||
title="网格视图"
|
|
||||||
>
|
|
||||||
⊞
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={`as-view-btn${viewMode === "list" ? " active" : ""}`}
|
|
||||||
onClick={() => setViewMode("list")}
|
|
||||||
title="列表视图"
|
|
||||||
>
|
|
||||||
☰
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ═══ 批量操作栏 ═══ */}
|
{/* 批量操作栏 */}
|
||||||
{showBatchSelect && hasSelection && (
|
{showBatchSelect && hasSelection && (
|
||||||
<div className="as-batch-bar">
|
<SelectorBatchBar selectedCount={selectedIds.length} onClear={clearSelection} />
|
||||||
<span className="as-batch-bar-count">已选 {selectedIds.length} 项</span>
|
|
||||||
<div className="as-batch-bar-actions">
|
|
||||||
<Button buttonType="ghost" buttonSize="sm" onClick={clearSelection}>
|
|
||||||
取消选择
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ═══ 素材列表 ═══ */}
|
{/* 素材列表 */}
|
||||||
<div className="as-body">
|
<div className="as-body">
|
||||||
{filteredAssets.length === 0 ? (
|
{filteredAssets.length === 0 ? (
|
||||||
<div className="as-empty">
|
<EmptyState />
|
||||||
<div className="as-empty-icon">📂</div>
|
|
||||||
<p>暂无素材</p>
|
|
||||||
</div>
|
|
||||||
) : viewMode === "grid" ? (
|
) : viewMode === "grid" ? (
|
||||||
/* ── 网格视图 ── */
|
/* 网格视图 */
|
||||||
<div className={`as-grid${compact ? " compact" : ""}`}>
|
<div className={`as-grid${compact ? " compact" : ""}`}>
|
||||||
{filteredAssets.map((asset, idx) => {
|
{filteredAssets.map((asset, idx) => (
|
||||||
const isSelected = selectedSet.has(asset.id)
|
<AssetCard
|
||||||
const isDragging = dragIdx === idx
|
key={asset.id}
|
||||||
const isDragOver = dragOverIdx === idx
|
asset={asset}
|
||||||
const qualityLevel = getQualityLevel(asset.quality_score)
|
isSelected={selectedSet.has(asset.id)}
|
||||||
|
isDragging={dragIdx === idx}
|
||||||
return (
|
isDragOver={dragOverIdx === idx}
|
||||||
<div
|
showCheckbox={showBatchSelect}
|
||||||
key={asset.id}
|
compact={compact}
|
||||||
className={[
|
onToggleSelect={(a, shiftKey) => toggleSelect(a, idx, shiftKey)}
|
||||||
"as-card",
|
onCardClick={(a, e) => handleCardClick(a, idx, e)}
|
||||||
isSelected ? "selected" : "",
|
onDragStart={(e) => handleDragStart(e, idx)}
|
||||||
isDragging ? "dragging" : "",
|
onDragOver={(e) => handleDragOver(e, idx)}
|
||||||
isDragOver ? "drag-over" : "",
|
onDrop={(e) => handleDrop(e, idx)}
|
||||||
showBatchSelect ? "has-checkbox" : "",
|
onDragEnd={handleDragEnd}
|
||||||
]
|
onMouseEnter={(e) => handleMouseEnter(asset, e)}
|
||||||
.filter(Boolean)
|
onMouseLeave={handleMouseLeave}
|
||||||
.join(" ")}
|
/>
|
||||||
draggable
|
))}
|
||||||
onDragStart={(e) => handleDragStart(e, idx)}
|
|
||||||
onDragOver={(e) => handleDragOver(e, idx)}
|
|
||||||
onDrop={(e) => handleDrop(e, idx)}
|
|
||||||
onDragEnd={handleDragEnd}
|
|
||||||
onClick={(e) => handleCardClick(asset, idx, e)}
|
|
||||||
onMouseEnter={(e) => handleMouseEnter(asset, e)}
|
|
||||||
onMouseLeave={handleMouseLeave}
|
|
||||||
>
|
|
||||||
{/* 缩略图 */}
|
|
||||||
<div className="as-card-thumb">
|
|
||||||
{asset.thumbnail_url ? (
|
|
||||||
<img src={asset.thumbnail_url} alt={asset.name} loading="lazy" />
|
|
||||||
) : (
|
|
||||||
<span className="as-card-thumb-icon">{MATERIAL_TYPE_ICONS[asset.type]}</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Checkbox */}
|
|
||||||
{showBatchSelect && (
|
|
||||||
<span
|
|
||||||
data-checkbox
|
|
||||||
className={`as-card-checkbox${isSelected ? " checked" : ""}`}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
toggleSelect(asset, idx, e.shiftKey)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 类型角标 */}
|
|
||||||
<span className="as-card-type-badge">{MATERIAL_TYPE_LABELS[asset.type]}</span>
|
|
||||||
|
|
||||||
{/* 时长角标 */}
|
|
||||||
{asset.duration != null && (
|
|
||||||
<span className="as-card-duration">{formatDuration(asset.duration)}</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 质量分角标 */}
|
|
||||||
{asset.quality_score != null && (
|
|
||||||
<span
|
|
||||||
className={`as-card-quality ${qualityLevel}`}
|
|
||||||
title={`质量分: ${asset.quality_score}`}
|
|
||||||
>
|
|
||||||
{asset.quality_score}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 信息 */}
|
|
||||||
<div className="as-card-info">
|
|
||||||
<p className="as-card-name" title={asset.name}>
|
|
||||||
{asset.name}
|
|
||||||
</p>
|
|
||||||
<div className="as-card-meta">{formatSize(asset.size)}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
/* ── 列表视图 ── */
|
/* 列表视图 */
|
||||||
<div className="as-list">
|
<div className="as-list">
|
||||||
{filteredAssets.map((asset, idx) => {
|
{filteredAssets.map((asset, idx) => (
|
||||||
const isSelected = selectedSet.has(asset.id)
|
<AssetListItem
|
||||||
const isDragging = dragIdx === idx
|
key={asset.id}
|
||||||
const isDragOver = dragOverIdx === idx
|
asset={asset}
|
||||||
|
isSelected={selectedSet.has(asset.id)}
|
||||||
return (
|
isDragging={dragIdx === idx}
|
||||||
<div
|
isDragOver={dragOverIdx === idx}
|
||||||
key={asset.id}
|
showCheckbox={showBatchSelect}
|
||||||
className={[
|
onToggleSelect={(a, shiftKey) => toggleSelect(a, idx, shiftKey)}
|
||||||
"as-list-item",
|
onCardClick={(a, e) => handleCardClick(a, idx, e)}
|
||||||
isSelected ? "selected" : "",
|
onDragStart={(e) => handleDragStart(e, idx)}
|
||||||
isDragging ? "dragging" : "",
|
onDragOver={(e) => handleDragOver(e, idx)}
|
||||||
isDragOver ? "drag-over" : "",
|
onDrop={(e) => handleDrop(e, idx)}
|
||||||
]
|
onDragEnd={handleDragEnd}
|
||||||
.filter(Boolean)
|
onMouseEnter={(e) => handleMouseEnter(asset, e)}
|
||||||
.join(" ")}
|
onMouseLeave={handleMouseLeave}
|
||||||
draggable
|
/>
|
||||||
onDragStart={(e) => handleDragStart(e, idx)}
|
))}
|
||||||
onDragOver={(e) => handleDragOver(e, idx)}
|
|
||||||
onDrop={(e) => handleDrop(e, idx)}
|
|
||||||
onDragEnd={handleDragEnd}
|
|
||||||
onClick={(e) => handleCardClick(asset, idx, e)}
|
|
||||||
onMouseEnter={(e) => handleMouseEnter(asset, e)}
|
|
||||||
onMouseLeave={handleMouseLeave}
|
|
||||||
>
|
|
||||||
{/* 拖拽手柄 */}
|
|
||||||
<span className="as-list-item-drag" title="拖拽排序">
|
|
||||||
⠿
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{/* Checkbox */}
|
|
||||||
{showBatchSelect && (
|
|
||||||
<span
|
|
||||||
data-checkbox
|
|
||||||
className={`as-list-item-checkbox${isSelected ? " checked" : ""}`}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
toggleSelect(asset, idx, e.shiftKey)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 图标 */}
|
|
||||||
<span className="as-list-item-icon">{MATERIAL_TYPE_ICONS[asset.type]}</span>
|
|
||||||
|
|
||||||
{/* 信息 */}
|
|
||||||
<div className="as-list-item-info">
|
|
||||||
<div className="as-list-item-name">{asset.name}</div>
|
|
||||||
<div className="as-list-item-meta">
|
|
||||||
{MATERIAL_TYPE_LABELS[asset.type]}
|
|
||||||
{asset.duration != null && ` · ${formatDuration(asset.duration)}`}
|
|
||||||
{asset.size != null && ` · ${formatSize(asset.size)}`}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 质量分 */}
|
|
||||||
{asset.quality_score != null && (
|
|
||||||
<span
|
|
||||||
className="as-list-item-quality"
|
|
||||||
style={{ color: getQualityColor(asset.quality_score) }}
|
|
||||||
>
|
|
||||||
{asset.quality_score}分
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ═══ 悬浮预览 ═══ */}
|
{/* 悬浮预览 */}
|
||||||
{previewAsset && (
|
{previewAsset && <PreviewOverlay asset={previewAsset} position={previewPos} />}
|
||||||
<div className="as-preview-overlay" style={{ left: previewPos.x, top: previewPos.y }}>
|
|
||||||
<div className="as-preview-overlay-thumb">
|
|
||||||
{previewAsset.thumbnail_url ? (
|
|
||||||
<img src={previewAsset.thumbnail_url} alt={previewAsset.name} />
|
|
||||||
) : (
|
|
||||||
<span className="as-preview-overlay-thumb-icon">
|
|
||||||
{MATERIAL_TYPE_ICONS[previewAsset.type]}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p className="as-preview-overlay-name">{previewAsset.name}</p>
|
|
||||||
<div className="as-preview-overlay-meta">
|
|
||||||
<span>类型: {MATERIAL_TYPE_LABELS[previewAsset.type]}</span>
|
|
||||||
{previewAsset.duration != null && (
|
|
||||||
<span>时长: {formatDuration(previewAsset.duration)}</span>
|
|
||||||
)}
|
|
||||||
{previewAsset.size != null && <span>大小: {formatSize(previewAsset.size)}</span>}
|
|
||||||
{previewAsset.quality_score != null && (
|
|
||||||
<span>质量分: {previewAsset.quality_score}</span>
|
|
||||||
)}
|
|
||||||
{previewAsset.tags.length > 0 && <span>标签: {previewAsset.tags.join(", ")}</span>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import React from "react"
|
||||||
|
|
||||||
|
const EmptyState: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<div className="as-empty">
|
||||||
|
<div className="as-empty-icon">📂</div>
|
||||||
|
<p>暂无素材</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default EmptyState
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import React from "react"
|
||||||
|
import type { MediaAsset } from "@/api/template-editor"
|
||||||
|
import { MATERIAL_TYPE_LABELS, MATERIAL_TYPE_ICONS } from "@/api/template-editor"
|
||||||
|
import { formatSize, formatDuration } from "./utils"
|
||||||
|
|
||||||
|
interface PreviewOverlayProps {
|
||||||
|
asset: MediaAsset
|
||||||
|
position: { x: number; y: number }
|
||||||
|
}
|
||||||
|
|
||||||
|
const PreviewOverlay: React.FC<PreviewOverlayProps> = ({ asset, position }) => {
|
||||||
|
return (
|
||||||
|
<div className="as-preview-overlay" style={{ left: position.x, top: position.y }}>
|
||||||
|
<div className="as-preview-overlay-thumb">
|
||||||
|
{asset.thumbnail_url ? (
|
||||||
|
<img src={asset.thumbnail_url} alt={asset.name} />
|
||||||
|
) : (
|
||||||
|
<span className="as-preview-overlay-thumb-icon">{MATERIAL_TYPE_ICONS[asset.type]}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="as-preview-overlay-name">{asset.name}</p>
|
||||||
|
<div className="as-preview-overlay-meta">
|
||||||
|
<span>类型: {MATERIAL_TYPE_LABELS[asset.type]}</span>
|
||||||
|
{asset.duration != null && <span>时长: {formatDuration(asset.duration)}</span>}
|
||||||
|
{asset.size != null && <span>大小: {formatSize(asset.size)}</span>}
|
||||||
|
{asset.quality_score != null && <span>质量分: {asset.quality_score}</span>}
|
||||||
|
{asset.tags.length > 0 && <span>标签: {asset.tags.join(", ")}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PreviewOverlay
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import React from "react"
|
||||||
|
import { Button } from "@/components/ui"
|
||||||
|
|
||||||
|
interface SelectorBatchBarProps {
|
||||||
|
selectedCount: number
|
||||||
|
onClear: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const SelectorBatchBar: React.FC<SelectorBatchBarProps> = ({ selectedCount, onClear }) => {
|
||||||
|
return (
|
||||||
|
<div className="as-batch-bar">
|
||||||
|
<span className="as-batch-bar-count">已选 {selectedCount} 项</span>
|
||||||
|
<div className="as-batch-bar-actions">
|
||||||
|
<Button buttonType="ghost" buttonSize="sm" onClick={onClear}>
|
||||||
|
取消选择
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SelectorBatchBar
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import React from "react"
|
||||||
|
import { Input, Select } from "@/components/ui"
|
||||||
|
import { TYPE_OPTIONS } from "./constants"
|
||||||
|
import { QUALITY_OPTIONS } from "@/api/template-editor"
|
||||||
|
import type { ViewMode } from "./types"
|
||||||
|
|
||||||
|
interface SelectorToolbarProps {
|
||||||
|
searchText: string
|
||||||
|
filterType: string
|
||||||
|
filterQuality: string
|
||||||
|
viewMode: ViewMode
|
||||||
|
showQualityFilter: boolean
|
||||||
|
onSearchChange: (value: string) => void
|
||||||
|
onTypeChange: (value: string) => void
|
||||||
|
onQualityChange: (value: string) => void
|
||||||
|
onViewModeChange: (mode: ViewMode) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const SelectorToolbar: React.FC<SelectorToolbarProps> = ({
|
||||||
|
searchText,
|
||||||
|
filterType,
|
||||||
|
filterQuality,
|
||||||
|
viewMode,
|
||||||
|
showQualityFilter,
|
||||||
|
onSearchChange,
|
||||||
|
onTypeChange,
|
||||||
|
onQualityChange,
|
||||||
|
onViewModeChange,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className="as-toolbar">
|
||||||
|
<div className="as-toolbar-left">
|
||||||
|
<div className="as-search">
|
||||||
|
<Input
|
||||||
|
placeholder="搜索素材..."
|
||||||
|
value={searchText}
|
||||||
|
onChange={(e) => onSearchChange(e.target.value)}
|
||||||
|
prefix="🔍"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Select
|
||||||
|
value={filterType}
|
||||||
|
onChange={(v: string) => onTypeChange(v)}
|
||||||
|
options={TYPE_OPTIONS}
|
||||||
|
/>
|
||||||
|
{showQualityFilter && (
|
||||||
|
<Select
|
||||||
|
value={filterQuality}
|
||||||
|
onChange={(v: string) => onQualityChange(v)}
|
||||||
|
options={QUALITY_OPTIONS}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="as-toolbar-right">
|
||||||
|
<button
|
||||||
|
className={`as-view-btn${viewMode === "grid" ? " active" : ""}`}
|
||||||
|
onClick={() => onViewModeChange("grid")}
|
||||||
|
title="网格视图"
|
||||||
|
>
|
||||||
|
⊞
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`as-view-btn${viewMode === "list" ? " active" : ""}`}
|
||||||
|
onClick={() => onViewModeChange("list")}
|
||||||
|
title="列表视图"
|
||||||
|
>
|
||||||
|
☰
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SelectorToolbar
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
/** 类型筛选选项 */
|
||||||
|
export const TYPE_OPTIONS = [
|
||||||
|
{ value: "", label: "全部类型" },
|
||||||
|
{ value: "video", label: "🎬 视频" },
|
||||||
|
{ value: "image", label: "🖼️ 图片" },
|
||||||
|
{ value: "audio", label: "🎵 音频" },
|
||||||
|
]
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { useState, useMemo } from "react"
|
||||||
|
import type { MediaAsset } from "@/api/template-editor"
|
||||||
|
import { QUALITY_OPTIONS } from "@/api/template-editor"
|
||||||
|
import type { ViewMode } from "../types"
|
||||||
|
|
||||||
|
interface UseAssetFilterReturn {
|
||||||
|
searchText: string
|
||||||
|
filterType: string
|
||||||
|
filterQuality: string
|
||||||
|
viewMode: ViewMode
|
||||||
|
filteredAssets: MediaAsset[]
|
||||||
|
setSearchText: (value: string) => void
|
||||||
|
setFilterType: (value: string) => void
|
||||||
|
setFilterQuality: (value: string) => void
|
||||||
|
setViewMode: (mode: ViewMode) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 素材筛选 Hook —— 搜索 + 类型 + 质量分 + 视图切换
|
||||||
|
*/
|
||||||
|
const useAssetFilter = (assets: MediaAsset[]): UseAssetFilterReturn => {
|
||||||
|
const [searchText, setSearchText] = useState("")
|
||||||
|
const [filterType, setFilterType] = useState("")
|
||||||
|
const [filterQuality, setFilterQuality] = useState("")
|
||||||
|
const [viewMode, setViewMode] = useState<ViewMode>("grid")
|
||||||
|
|
||||||
|
const filteredAssets = useMemo(() => {
|
||||||
|
let list = assets
|
||||||
|
if (searchText) {
|
||||||
|
const q = searchText.toLowerCase()
|
||||||
|
list = list.filter(
|
||||||
|
(a) => a.name.toLowerCase().includes(q) || a.tags.some((t) => t.toLowerCase().includes(q)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (filterType) {
|
||||||
|
list = list.filter((a) => a.type === filterType)
|
||||||
|
}
|
||||||
|
if (filterQuality) {
|
||||||
|
const opt = QUALITY_OPTIONS.find((o) => o.value === filterQuality)
|
||||||
|
if (opt?.min != null && opt?.max != null) {
|
||||||
|
list = list.filter(
|
||||||
|
(a) =>
|
||||||
|
a.quality_score != null && a.quality_score >= opt.min! && a.quality_score <= opt.max!,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return list
|
||||||
|
}, [assets, searchText, filterType, filterQuality])
|
||||||
|
|
||||||
|
return {
|
||||||
|
searchText,
|
||||||
|
filterType,
|
||||||
|
filterQuality,
|
||||||
|
viewMode,
|
||||||
|
filteredAssets,
|
||||||
|
setSearchText,
|
||||||
|
setFilterType,
|
||||||
|
setFilterQuality,
|
||||||
|
setViewMode,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useAssetFilter
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { useState, useRef, useCallback, useEffect } from "react"
|
||||||
|
import type { MediaAsset } from "@/api/template-editor"
|
||||||
|
|
||||||
|
interface UseAssetPreviewReturn {
|
||||||
|
previewAsset: MediaAsset | null
|
||||||
|
previewPos: { x: number; y: number }
|
||||||
|
handleMouseEnter: (asset: MediaAsset, e: React.MouseEvent) => void
|
||||||
|
handleMouseLeave: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 悬浮预览 Hook —— 延迟 400ms 显示预览浮层
|
||||||
|
*/
|
||||||
|
const useAssetPreview = (): UseAssetPreviewReturn => {
|
||||||
|
const [previewAsset, setPreviewAsset] = useState<MediaAsset | null>(null)
|
||||||
|
const [previewPos, setPreviewPos] = useState({ x: 0, y: 0 })
|
||||||
|
const previewTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
|
||||||
|
const handleMouseEnter = useCallback((asset: MediaAsset, e: React.MouseEvent) => {
|
||||||
|
if (previewTimer.current) clearTimeout(previewTimer.current)
|
||||||
|
previewTimer.current = setTimeout(() => {
|
||||||
|
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
|
||||||
|
setPreviewAsset(asset)
|
||||||
|
setPreviewPos({
|
||||||
|
x: rect.right + 12,
|
||||||
|
y: Math.max(8, rect.top - 20),
|
||||||
|
})
|
||||||
|
}, 400)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleMouseLeave = useCallback(() => {
|
||||||
|
if (previewTimer.current) {
|
||||||
|
clearTimeout(previewTimer.current)
|
||||||
|
previewTimer.current = null
|
||||||
|
}
|
||||||
|
setPreviewAsset(null)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// 清理定时器
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (previewTimer.current) clearTimeout(previewTimer.current)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return {
|
||||||
|
previewAsset,
|
||||||
|
previewPos,
|
||||||
|
handleMouseEnter,
|
||||||
|
handleMouseLeave,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useAssetPreview
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { useCallback, useRef, useMemo } from "react"
|
||||||
|
import type { MediaAsset } from "@/api/template-editor"
|
||||||
|
|
||||||
|
interface UseAssetSelectionOptions {
|
||||||
|
filteredAssets: MediaAsset[]
|
||||||
|
selectedIds: string[]
|
||||||
|
onSelectionChange?: (ids: string[]) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseAssetSelectionReturn {
|
||||||
|
selectedSet: Set<string>
|
||||||
|
toggleSelect: (asset: MediaAsset, idx: number, shiftKey: boolean) => void
|
||||||
|
clearSelection: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 素材选择 Hook —— 单选/多选/Shift 连选
|
||||||
|
*/
|
||||||
|
const useAssetSelection = ({
|
||||||
|
filteredAssets,
|
||||||
|
selectedIds,
|
||||||
|
onSelectionChange,
|
||||||
|
}: UseAssetSelectionOptions): UseAssetSelectionReturn => {
|
||||||
|
const lastClickedIdx = useRef<number | null>(null)
|
||||||
|
|
||||||
|
const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds])
|
||||||
|
|
||||||
|
const toggleSelect = useCallback(
|
||||||
|
(asset: MediaAsset, idx: number, shiftKey: boolean) => {
|
||||||
|
if (!onSelectionChange) return
|
||||||
|
|
||||||
|
if (shiftKey && lastClickedIdx.current !== null) {
|
||||||
|
// Shift 连选
|
||||||
|
const start = Math.min(lastClickedIdx.current, idx)
|
||||||
|
const end = Math.max(lastClickedIdx.current, idx)
|
||||||
|
const rangeIds = filteredAssets.slice(start, end + 1).map((a) => a.id)
|
||||||
|
const newSet = new Set(selectedIds)
|
||||||
|
rangeIds.forEach((id) => newSet.add(id))
|
||||||
|
onSelectionChange(Array.from(newSet))
|
||||||
|
} else {
|
||||||
|
const newSet = new Set(selectedIds)
|
||||||
|
if (newSet.has(asset.id)) {
|
||||||
|
newSet.delete(asset.id)
|
||||||
|
} else {
|
||||||
|
newSet.add(asset.id)
|
||||||
|
}
|
||||||
|
onSelectionChange(Array.from(newSet))
|
||||||
|
}
|
||||||
|
lastClickedIdx.current = idx
|
||||||
|
},
|
||||||
|
[onSelectionChange, selectedIds, filteredAssets],
|
||||||
|
)
|
||||||
|
|
||||||
|
const clearSelection = useCallback(() => {
|
||||||
|
onSelectionChange?.([])
|
||||||
|
}, [onSelectionChange])
|
||||||
|
|
||||||
|
return {
|
||||||
|
selectedSet,
|
||||||
|
toggleSelect,
|
||||||
|
clearSelection,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useAssetSelection
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { useState, useCallback } from "react"
|
||||||
|
import type { MediaAsset } from "@/api/template-editor"
|
||||||
|
|
||||||
|
interface UseDragReorderOptions {
|
||||||
|
filteredAssets: MediaAsset[]
|
||||||
|
selectedIds: string[]
|
||||||
|
onAssetDragStart?: (asset: MediaAsset) => void
|
||||||
|
onReorder?: (fromIdx: number, toIdx: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseDragReorderReturn {
|
||||||
|
dragIdx: number | null
|
||||||
|
dragOverIdx: number | null
|
||||||
|
handleDragStart: (e: React.DragEvent, idx: number) => void
|
||||||
|
handleDragOver: (e: React.DragEvent, idx: number) => void
|
||||||
|
handleDrop: (e: React.DragEvent, toIdx: number) => void
|
||||||
|
handleDragEnd: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 拖拽排序 Hook —— HTML5 DnD,支持批量拖拽携带数据
|
||||||
|
*/
|
||||||
|
const useDragReorder = ({
|
||||||
|
filteredAssets,
|
||||||
|
selectedIds,
|
||||||
|
onAssetDragStart,
|
||||||
|
onReorder,
|
||||||
|
}: UseDragReorderOptions): UseDragReorderReturn => {
|
||||||
|
const [dragIdx, setDragIdx] = useState<number | null>(null)
|
||||||
|
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null)
|
||||||
|
|
||||||
|
const handleDragStart = useCallback(
|
||||||
|
(e: React.DragEvent, idx: number) => {
|
||||||
|
setDragIdx(idx)
|
||||||
|
e.dataTransfer.effectAllowed = "move"
|
||||||
|
e.dataTransfer.setData("text/plain", String(idx))
|
||||||
|
// 设置素材数据,供 TimelinePanel 接收
|
||||||
|
e.dataTransfer.setData("application/x-media-asset", JSON.stringify(filteredAssets[idx]))
|
||||||
|
// 批量拖拽:如果有多个选中素材,一起携带
|
||||||
|
if (selectedIds.length > 1 && selectedIds.includes(filteredAssets[idx].id)) {
|
||||||
|
const batchAssets = filteredAssets.filter((a) => selectedIds.includes(a.id))
|
||||||
|
e.dataTransfer.setData("application/x-media-assets", JSON.stringify(batchAssets))
|
||||||
|
}
|
||||||
|
// 通知父组件素材拖拽开始
|
||||||
|
if (onAssetDragStart) {
|
||||||
|
onAssetDragStart(filteredAssets[idx])
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[filteredAssets, selectedIds, onAssetDragStart],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleDragOver = useCallback(
|
||||||
|
(e: React.DragEvent, idx: number) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.dataTransfer.dropEffect = "move"
|
||||||
|
if (dragIdx === null || dragIdx === idx) return
|
||||||
|
setDragOverIdx(idx)
|
||||||
|
},
|
||||||
|
[dragIdx],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleDrop = useCallback(
|
||||||
|
(e: React.DragEvent, toIdx: number) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (dragIdx !== null && dragIdx !== toIdx && onReorder) {
|
||||||
|
onReorder(dragIdx, toIdx)
|
||||||
|
}
|
||||||
|
setDragIdx(null)
|
||||||
|
setDragOverIdx(null)
|
||||||
|
},
|
||||||
|
[dragIdx, onReorder],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleDragEnd = useCallback(() => {
|
||||||
|
setDragIdx(null)
|
||||||
|
setDragOverIdx(null)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return {
|
||||||
|
dragIdx,
|
||||||
|
dragOverIdx,
|
||||||
|
handleDragStart,
|
||||||
|
handleDragOver,
|
||||||
|
handleDrop,
|
||||||
|
handleDragEnd,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useDragReorder
|
||||||
@@ -1,2 +1,2 @@
|
|||||||
export { default as AssetSelector } from "./AssetSelector"
|
export { default as AssetSelector } from "./AssetSelector"
|
||||||
export type { AssetSelectorProps } from "./AssetSelector"
|
export type { AssetSelectorProps, ViewMode } from "./types"
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import type { MediaAsset } from "@/api/template-editor"
|
||||||
|
|
||||||
|
export interface AssetSelectorProps {
|
||||||
|
assets: MediaAsset[]
|
||||||
|
selectedIds?: string[]
|
||||||
|
onSelectionChange?: (ids: string[]) => void
|
||||||
|
onAssetDragStart?: (asset: MediaAsset) => void
|
||||||
|
onReorder?: (fromIdx: number, toIdx: number) => void
|
||||||
|
showQualityFilter?: boolean
|
||||||
|
showBatchSelect?: boolean
|
||||||
|
compact?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ViewMode = "grid" | "list"
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
/** 格式化文件大小 */
|
||||||
|
export const formatSize = (bytes?: number): string => {
|
||||||
|
if (!bytes) return ""
|
||||||
|
if (bytes < 1024) return `${bytes}B`
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 格式化时长 */
|
||||||
|
export const formatDuration = (seconds?: number): string => {
|
||||||
|
if (!seconds) return ""
|
||||||
|
const m = Math.floor(seconds / 60)
|
||||||
|
const s = Math.floor(seconds % 60)
|
||||||
|
return m > 0 ? `${m}:${s.toString().padStart(2, "0")}` : `${s}s`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取质量分等级 */
|
||||||
|
export const getQualityLevel = (score?: number): string => {
|
||||||
|
if (score == null) return "none"
|
||||||
|
if (score >= 90) return "excellent"
|
||||||
|
if (score >= 70) return "good"
|
||||||
|
if (score >= 50) return "fair"
|
||||||
|
return "poor"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 质量分颜色 */
|
||||||
|
export const getQualityColor = (score?: number): string => {
|
||||||
|
if (score == null) return "var(--text-secondary)"
|
||||||
|
if (score >= 90) return "var(--success-color, #10b981)"
|
||||||
|
if (score >= 70) return "var(--primary-color, #6366f1)"
|
||||||
|
if (score >= 50) return "var(--warning-color, #f59e0b)"
|
||||||
|
return "var(--error-color, #ef4444)"
|
||||||
|
}
|
||||||
@@ -10,357 +10,35 @@
|
|||||||
*
|
*
|
||||||
* V21 Design System — 零 antd 直接导入
|
* V21 Design System — 零 antd 直接导入
|
||||||
*/
|
*/
|
||||||
import React, { useState, useCallback, useRef, useEffect } from "react"
|
import React from "react"
|
||||||
import { Modal, Button } from "@/components/ui"
|
import { Modal } from "@/components/ui"
|
||||||
import { createVoiceClone, toVoiceClone } from "@/api/voice-clone"
|
import type { CloneModalProps } from "./types/cloneModal"
|
||||||
import type { VoiceClone } from "@/api/voice-clone"
|
import useCloneModal from "./hooks/useCloneModal"
|
||||||
import { uploadAsset } from "@/api/assets"
|
import InputView from "./clone-modal/InputView"
|
||||||
|
import ProgressView from "./clone-modal/ProgressView"
|
||||||
import "./clone-modal.css"
|
import "./clone-modal.css"
|
||||||
|
|
||||||
/* ── 类型定义 ───────────────────────────────────────────── */
|
|
||||||
|
|
||||||
type ModalPhase = "input" | "uploading" | "cloning" | "done"
|
|
||||||
|
|
||||||
export interface CloneModalProps {
|
|
||||||
/** 弹窗是否可见 */
|
|
||||||
open: boolean
|
|
||||||
/** 关闭弹窗回调 */
|
|
||||||
onClose: () => void
|
|
||||||
/** 克隆成功回调(返回新创建的音色) */
|
|
||||||
onSuccess?: (voice: VoiceClone) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── 进度阶段配置 ─────────────────────────────────────────── */
|
|
||||||
|
|
||||||
const PROGRESS_STEPS: { key: string; label: string; icon: string }[] = [
|
|
||||||
{ key: "uploading", label: "上传中", icon: "📤" },
|
|
||||||
{ key: "cloning", label: "克隆中", icon: "🧬" },
|
|
||||||
{ key: "done", label: "完成", icon: "✅" },
|
|
||||||
]
|
|
||||||
|
|
||||||
/* ── 常量 ───────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
const ACCEPTED_EXTENSIONS = ["mp3", "wav", "m4a"]
|
|
||||||
const ACCEPTED_MIME = ".mp3,.wav,.m4a,audio/mpeg,audio/wav,audio/mp4"
|
|
||||||
const MAX_FILE_SIZE = 10 * 1024 * 1024 // 10MB
|
|
||||||
|
|
||||||
/** 最长录制时长:5 分钟(秒) */
|
|
||||||
const MAX_RECORD_SECONDS = 5 * 60
|
|
||||||
|
|
||||||
/* ── 组件 ───────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
const CloneModal: React.FC<CloneModalProps> = ({ open, onClose, onSuccess }) => {
|
const CloneModal: React.FC<CloneModalProps> = ({ open, onClose, onSuccess }) => {
|
||||||
const [phase, setPhase] = useState<ModalPhase>("input")
|
const {
|
||||||
const [voiceName, setVoiceName] = useState("")
|
phase,
|
||||||
const [voiceDescription, setVoiceDescription] = useState("")
|
voiceName,
|
||||||
const [selectedFile, setSelectedFile] = useState<File | null>(null)
|
voiceDescription,
|
||||||
const [dragActive, setDragActive] = useState(false)
|
selectedFile,
|
||||||
const [errorMessage, setErrorMessage] = useState("")
|
dragActive,
|
||||||
|
errorMessage,
|
||||||
// 录音状态
|
isRecording,
|
||||||
const [isRecording, setIsRecording] = useState(false)
|
recordTime,
|
||||||
const [recordTime, setRecordTime] = useState(0)
|
recordedBlob,
|
||||||
const [recordedBlob, setRecordedBlob] = useState<Blob | null>(null)
|
canSubmit,
|
||||||
|
isProcessing,
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
setVoiceName,
|
||||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
setVoiceDescription,
|
||||||
const recordTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
setDragActive,
|
||||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null)
|
handleFileSelect,
|
||||||
const audioChunksRef = useRef<Blob[]>([])
|
handleRecordToggle,
|
||||||
/** 默认音色名称计数器(组件级 ref,避免多实例串号) */
|
handleClose,
|
||||||
const cloneCounterRef = useRef(1)
|
handleSubmit,
|
||||||
|
} = useCloneModal({ open, onClose, onSuccess })
|
||||||
/** 生成下一个默认音色名称 */
|
|
||||||
const getNextDefaultName = useCallback((): string => {
|
|
||||||
const name = `我的声音 ${cloneCounterRef.current}`
|
|
||||||
cloneCounterRef.current += 1
|
|
||||||
return name
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
/** 组件卸载时清理定时器和 MediaRecorder */
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
if (timerRef.current) clearTimeout(timerRef.current)
|
|
||||||
if (recordTimerRef.current) clearInterval(recordTimerRef.current)
|
|
||||||
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== "inactive") {
|
|
||||||
mediaRecorderRef.current.stop()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
/** 重置弹窗状态 */
|
|
||||||
const resetState = useCallback(() => {
|
|
||||||
setPhase("input")
|
|
||||||
setVoiceName(getNextDefaultName())
|
|
||||||
setVoiceDescription("")
|
|
||||||
setSelectedFile(null)
|
|
||||||
setDragActive(false)
|
|
||||||
setErrorMessage("")
|
|
||||||
setIsRecording(false)
|
|
||||||
setRecordTime(0)
|
|
||||||
setRecordedBlob(null)
|
|
||||||
audioChunksRef.current = []
|
|
||||||
if (recordTimerRef.current) {
|
|
||||||
clearInterval(recordTimerRef.current)
|
|
||||||
recordTimerRef.current = null
|
|
||||||
}
|
|
||||||
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== "inactive") {
|
|
||||||
mediaRecorderRef.current.stop()
|
|
||||||
}
|
|
||||||
mediaRecorderRef.current = null
|
|
||||||
}, [getNextDefaultName])
|
|
||||||
|
|
||||||
/** 关闭弹窗 */
|
|
||||||
const handleClose = useCallback(() => {
|
|
||||||
resetState()
|
|
||||||
onClose()
|
|
||||||
}, [resetState, onClose])
|
|
||||||
|
|
||||||
/** 弹窗打开时重置状态 */
|
|
||||||
useEffect(() => {
|
|
||||||
if (open) {
|
|
||||||
resetState()
|
|
||||||
}
|
|
||||||
}, [open, resetState])
|
|
||||||
|
|
||||||
/* ── 文件验证 ──────────────────────────────────────── */
|
|
||||||
|
|
||||||
const validateFile = (file: File): string | null => {
|
|
||||||
const ext = file.name.split(".").pop()?.toLowerCase()
|
|
||||||
if (!ext || !ACCEPTED_EXTENSIONS.includes(ext)) {
|
|
||||||
return "不支持的音频格式,请上传 MP3、WAV 或 M4A 文件"
|
|
||||||
}
|
|
||||||
if (file.size > MAX_FILE_SIZE) {
|
|
||||||
return "文件大小超过 10MB,请压缩后重试"
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── 文件上传 ──────────────────────────────────────── */
|
|
||||||
|
|
||||||
const handleUploadClick = () => {
|
|
||||||
fileInputRef.current?.click()
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const file = e.target.files?.[0]
|
|
||||||
if (file) {
|
|
||||||
const error = validateFile(file)
|
|
||||||
if (error) {
|
|
||||||
setErrorMessage(error)
|
|
||||||
setSelectedFile(null)
|
|
||||||
} else {
|
|
||||||
setErrorMessage("")
|
|
||||||
setSelectedFile(file)
|
|
||||||
// 清除录音
|
|
||||||
setRecordedBlob(null)
|
|
||||||
setIsRecording(false)
|
|
||||||
setRecordTime(0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
e.target.value = ""
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── 拖拽 ──────────────────────────────────────────── */
|
|
||||||
|
|
||||||
const handleDrag = (e: React.DragEvent) => {
|
|
||||||
e.preventDefault()
|
|
||||||
e.stopPropagation()
|
|
||||||
if (e.type === "dragenter" || e.type === "dragover") {
|
|
||||||
setDragActive(true)
|
|
||||||
} else if (e.type === "dragleave") {
|
|
||||||
setDragActive(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDrop = (e: React.DragEvent) => {
|
|
||||||
e.preventDefault()
|
|
||||||
e.stopPropagation()
|
|
||||||
setDragActive(false)
|
|
||||||
const file = e.dataTransfer.files?.[0]
|
|
||||||
if (file) {
|
|
||||||
const error = validateFile(file)
|
|
||||||
if (error) {
|
|
||||||
setErrorMessage(error)
|
|
||||||
setSelectedFile(null)
|
|
||||||
} else {
|
|
||||||
setErrorMessage("")
|
|
||||||
setSelectedFile(file)
|
|
||||||
setRecordedBlob(null)
|
|
||||||
setIsRecording(false)
|
|
||||||
setRecordTime(0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── 录音(真实 MediaRecorder) ───────────────────── */
|
|
||||||
|
|
||||||
const handleRecord = async () => {
|
|
||||||
if (isRecording) {
|
|
||||||
// 停止录制
|
|
||||||
setIsRecording(false)
|
|
||||||
if (recordTimerRef.current) {
|
|
||||||
clearInterval(recordTimerRef.current)
|
|
||||||
recordTimerRef.current = null
|
|
||||||
}
|
|
||||||
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== "inactive") {
|
|
||||||
mediaRecorderRef.current.stop()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 开始录制
|
|
||||||
try {
|
|
||||||
const stream = await navigator.mediaDevices.getUserMedia({
|
|
||||||
audio: true,
|
|
||||||
})
|
|
||||||
const mediaRecorder = new MediaRecorder(stream)
|
|
||||||
mediaRecorderRef.current = mediaRecorder
|
|
||||||
audioChunksRef.current = []
|
|
||||||
|
|
||||||
mediaRecorder.ondataavailable = (event) => {
|
|
||||||
if (event.data.size > 0) {
|
|
||||||
audioChunksRef.current.push(event.data)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
mediaRecorder.onstop = () => {
|
|
||||||
const blob = new Blob(audioChunksRef.current, { type: "audio/webm" })
|
|
||||||
setRecordedBlob(blob)
|
|
||||||
// 清除上传的文件
|
|
||||||
setSelectedFile(null)
|
|
||||||
// 停止音轨
|
|
||||||
stream.getTracks().forEach((track) => track.stop())
|
|
||||||
}
|
|
||||||
|
|
||||||
mediaRecorder.start()
|
|
||||||
setIsRecording(true)
|
|
||||||
setRecordTime(0)
|
|
||||||
setRecordedBlob(null)
|
|
||||||
setErrorMessage("")
|
|
||||||
|
|
||||||
recordTimerRef.current = setInterval(() => {
|
|
||||||
setRecordTime((prev) => {
|
|
||||||
const next = prev + 1
|
|
||||||
if (next >= MAX_RECORD_SECONDS) {
|
|
||||||
// 达到 5 分钟上限,自动停止录制
|
|
||||||
setTimeout(() => {
|
|
||||||
setIsRecording(false)
|
|
||||||
if (recordTimerRef.current) {
|
|
||||||
clearInterval(recordTimerRef.current)
|
|
||||||
recordTimerRef.current = null
|
|
||||||
}
|
|
||||||
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== "inactive") {
|
|
||||||
mediaRecorderRef.current.stop()
|
|
||||||
}
|
|
||||||
setErrorMessage("已达最长录制时长(5分钟),已自动停止")
|
|
||||||
}, 0)
|
|
||||||
return MAX_RECORD_SECONDS
|
|
||||||
}
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
}, 1000)
|
|
||||||
} catch {
|
|
||||||
setErrorMessage("无法访问麦克风,请检查浏览器权限设置")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 格式化录制时间 mm:ss */
|
|
||||||
const formatRecordTime = (seconds: number): string => {
|
|
||||||
const m = Math.floor(seconds / 60)
|
|
||||||
const s = seconds % 60
|
|
||||||
return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── 表单验证 ──────────────────────────────────────── */
|
|
||||||
|
|
||||||
const hasAudio = selectedFile !== null || recordedBlob !== null
|
|
||||||
|
|
||||||
const validateForm = (): string | null => {
|
|
||||||
const name = voiceName.trim()
|
|
||||||
if (!name) {
|
|
||||||
return "请输入音色名称"
|
|
||||||
}
|
|
||||||
if (name.length < 2 || name.length > 20) {
|
|
||||||
return "音色名称需在 2-20 个字符之间"
|
|
||||||
}
|
|
||||||
if (!hasAudio) {
|
|
||||||
return "请上传音频文件或录制一段声音"
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── 提交克隆 ──────────────────────────────────────── */
|
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
|
||||||
const formError = validateForm()
|
|
||||||
if (formError) {
|
|
||||||
setErrorMessage(formError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setErrorMessage("")
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 阶段 1:上传音频
|
|
||||||
setPhase("uploading")
|
|
||||||
|
|
||||||
let fileToUpload: File
|
|
||||||
if (selectedFile) {
|
|
||||||
fileToUpload = selectedFile
|
|
||||||
} else {
|
|
||||||
// 将录音 Blob 转为 File
|
|
||||||
fileToUpload = new File([recordedBlob!], `recorded-${Date.now()}.webm`, {
|
|
||||||
type: "audio/webm",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const formData = new FormData()
|
|
||||||
formData.append("file", fileToUpload)
|
|
||||||
const uploadResult = await uploadAsset(formData)
|
|
||||||
|
|
||||||
// 阶段 2:克隆
|
|
||||||
setPhase("cloning")
|
|
||||||
const result = await createVoiceClone({
|
|
||||||
name: voiceName.trim(),
|
|
||||||
description: voiceDescription.trim() || undefined,
|
|
||||||
audio_url: uploadResult.url,
|
|
||||||
})
|
|
||||||
|
|
||||||
// 阶段 3:完成
|
|
||||||
setPhase("done")
|
|
||||||
|
|
||||||
// 2秒后自动关闭
|
|
||||||
timerRef.current = setTimeout(() => {
|
|
||||||
onSuccess?.(toVoiceClone(result))
|
|
||||||
handleClose()
|
|
||||||
}, 2000)
|
|
||||||
} catch (err) {
|
|
||||||
setPhase("input")
|
|
||||||
setErrorMessage(err instanceof Error ? err.message : "克隆失败,请重试")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── 计算属性 ──────────────────────────────────────── */
|
|
||||||
|
|
||||||
const canSubmit = voiceName.trim().length >= 2 && voiceName.trim().length <= 20 && hasAudio
|
|
||||||
|
|
||||||
const isProcessing = phase === "uploading" || phase === "cloning"
|
|
||||||
|
|
||||||
/** 当前进度索引 */
|
|
||||||
const getProgressIndex = (): number => {
|
|
||||||
switch (phase) {
|
|
||||||
case "uploading":
|
|
||||||
return 0
|
|
||||||
case "cloning":
|
|
||||||
return 1
|
|
||||||
case "done":
|
|
||||||
return 2
|
|
||||||
default:
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const progressIndex = getProgressIndex()
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
@@ -373,228 +51,30 @@ const CloneModal: React.FC<CloneModalProps> = ({ open, onClose, onSuccess }) =>
|
|||||||
maskClosable={!isProcessing}
|
maskClosable={!isProcessing}
|
||||||
keyboard={!isProcessing}
|
keyboard={!isProcessing}
|
||||||
>
|
>
|
||||||
{/* ── 输入阶段 ──────────────────────────────────── */}
|
{/* 输入阶段 */}
|
||||||
{phase === "input" && (
|
{phase === "input" && (
|
||||||
<div className="xx-clonemodal-body">
|
<InputView
|
||||||
{/* 步骤引导 */}
|
voiceName={voiceName}
|
||||||
<div className="xx-clonemodal-steps">
|
voiceDescription={voiceDescription}
|
||||||
<div className="xx-clonemodal-step xx-clonemodal-step--active">
|
selectedFile={selectedFile}
|
||||||
<div className="xx-clonemodal-step-number">1</div>
|
dragActive={dragActive}
|
||||||
<span className="xx-clonemodal-step-label">上传/录制音频</span>
|
isRecording={isRecording}
|
||||||
</div>
|
recordTime={recordTime}
|
||||||
<div className="xx-clonemodal-step-connector" />
|
recordedBlob={recordedBlob}
|
||||||
<div className="xx-clonemodal-step">
|
errorMessage={errorMessage}
|
||||||
<div className="xx-clonemodal-step-number">2</div>
|
canSubmit={canSubmit}
|
||||||
<span className="xx-clonemodal-step-label">填写信息</span>
|
onVoiceNameChange={setVoiceName}
|
||||||
</div>
|
onVoiceDescChange={setVoiceDescription}
|
||||||
<div className="xx-clonemodal-step-connector" />
|
onDragActiveChange={setDragActive}
|
||||||
<div className="xx-clonemodal-step">
|
onFileSelect={handleFileSelect}
|
||||||
<div className="xx-clonemodal-step-number">3</div>
|
onRecordToggle={handleRecordToggle}
|
||||||
<span className="xx-clonemodal-step-label">提交克隆</span>
|
onClose={handleClose}
|
||||||
</div>
|
onSubmit={handleSubmit}
|
||||||
</div>
|
/>
|
||||||
|
|
||||||
{/* 音色名称 */}
|
|
||||||
<div className="xx-clonemodal-field">
|
|
||||||
<label className="xx-clonemodal-label">
|
|
||||||
音色名称 <span className="xx-clonemodal-required">*</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="xx-clonemodal-input"
|
|
||||||
value={voiceName}
|
|
||||||
onChange={(e) => setVoiceName(e.target.value)}
|
|
||||||
placeholder="输入音色名称(2-20字符)"
|
|
||||||
maxLength={20}
|
|
||||||
/>
|
|
||||||
<div className="xx-clonemodal-char-count">{voiceName.length}/20</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 上传区域 */}
|
|
||||||
<div className="xx-clonemodal-field">
|
|
||||||
<label className="xx-clonemodal-label">上传音频</label>
|
|
||||||
<div
|
|
||||||
className={`xx-clonemodal-upload-zone${dragActive ? " xx-clonemodal-upload-zone--active" : ""}${selectedFile ? " xx-clonemodal-upload-zone--has-file" : ""}`}
|
|
||||||
onClick={handleUploadClick}
|
|
||||||
onDragEnter={handleDrag}
|
|
||||||
onDragOver={handleDrag}
|
|
||||||
onDragLeave={handleDrag}
|
|
||||||
onDrop={handleDrop}
|
|
||||||
>
|
|
||||||
<div className="xx-clonemodal-upload-icon">{selectedFile ? "📄" : "🎵"}</div>
|
|
||||||
<p className="xx-clonemodal-upload-title">
|
|
||||||
{selectedFile ? selectedFile.name : "拖拽音频文件到此处,或点击上传"}
|
|
||||||
</p>
|
|
||||||
<p className="xx-clonemodal-upload-hint">支持 MP3、WAV、M4A 格式,最大 10MB</p>
|
|
||||||
<input
|
|
||||||
ref={fileInputRef}
|
|
||||||
type="file"
|
|
||||||
accept={ACCEPTED_MIME}
|
|
||||||
style={{ display: "none" }}
|
|
||||||
onChange={handleFileChange}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 或分隔 */}
|
|
||||||
<div className="xx-clonemodal-divider">
|
|
||||||
<div className="xx-clonemodal-divider-line" />
|
|
||||||
<span className="xx-clonemodal-divider-text">或</span>
|
|
||||||
<div className="xx-clonemodal-divider-line" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 录制区域 */}
|
|
||||||
<div className="xx-clonemodal-field">
|
|
||||||
<label className="xx-clonemodal-label">直接录制</label>
|
|
||||||
<div className="xx-clonemodal-record-area">
|
|
||||||
<div className="xx-clonemodal-record-info">
|
|
||||||
<p className="xx-clonemodal-record-hint">
|
|
||||||
{isRecording
|
|
||||||
? `录制中 ${formatRecordTime(recordTime)}`
|
|
||||||
: recordedBlob
|
|
||||||
? `已录制 ${formatRecordTime(recordTime)}`
|
|
||||||
: "点击按钮开始录制(最长 5 分钟)"}
|
|
||||||
</p>
|
|
||||||
{isRecording && (
|
|
||||||
<div className="xx-clonemodal-record-wave">
|
|
||||||
<span className="xx-clonemodal-record-wave-bar" />
|
|
||||||
<span className="xx-clonemodal-record-wave-bar" />
|
|
||||||
<span className="xx-clonemodal-record-wave-bar" />
|
|
||||||
<span className="xx-clonemodal-record-wave-bar" />
|
|
||||||
<span className="xx-clonemodal-record-wave-bar" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`xx-clonemodal-record-btn${isRecording ? " xx-clonemodal-record-btn--recording" : ""}`}
|
|
||||||
onClick={handleRecord}
|
|
||||||
title={isRecording ? "停止录制" : "开始录制"}
|
|
||||||
>
|
|
||||||
{isRecording ? "⏹" : "🎙️"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 音色描述 */}
|
|
||||||
<div className="xx-clonemodal-field">
|
|
||||||
<label className="xx-clonemodal-label">音色描述</label>
|
|
||||||
<textarea
|
|
||||||
className="xx-clonemodal-textarea"
|
|
||||||
value={voiceDescription}
|
|
||||||
onChange={(e) => setVoiceDescription(e.target.value)}
|
|
||||||
placeholder="可选,描述这个音色的特点(最多100字符)"
|
|
||||||
maxLength={100}
|
|
||||||
rows={3}
|
|
||||||
/>
|
|
||||||
<div className="xx-clonemodal-char-count">{voiceDescription.length}/100</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 错误提示 */}
|
|
||||||
{errorMessage && (
|
|
||||||
<div className="xx-clonemodal-error">
|
|
||||||
<span className="xx-clonemodal-error-icon">⚠️</span>
|
|
||||||
<span>{errorMessage}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 提示 */}
|
|
||||||
<div className="xx-clonemodal-tip">
|
|
||||||
<span className="xx-clonemodal-tip-icon">💡</span>
|
|
||||||
<span>建议上传 10 秒 ~ 3 分钟的清晰语音,环境安静、语速均匀效果最佳</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 底部按钮 */}
|
|
||||||
<div className="xx-clonemodal-footer">
|
|
||||||
<Button buttonType="ghost" onClick={handleClose}>
|
|
||||||
取消
|
|
||||||
</Button>
|
|
||||||
<Button buttonType="primary" disabled={!canSubmit} onClick={handleSubmit}>
|
|
||||||
🎤 开始克隆
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── 进度阶段(上传中 / 克隆中) ──────────────── */}
|
{/* 进度 / 完成阶段 */}
|
||||||
{isProcessing && (
|
{phase !== "input" && <ProgressView phase={phase} />}
|
||||||
<div className="xx-clonemodal-progress-body">
|
|
||||||
{/* 步骤指示器 */}
|
|
||||||
<div className="xx-clonemodal-steps-progress">
|
|
||||||
{PROGRESS_STEPS.map((step, idx) => {
|
|
||||||
const isActive = idx === progressIndex
|
|
||||||
const isDone = idx < progressIndex
|
|
||||||
const stepClass = [
|
|
||||||
"xx-clonemodal-step-progress",
|
|
||||||
isActive ? "xx-clonemodal-step-progress--active" : "",
|
|
||||||
isDone ? "xx-clonemodal-step-progress--done" : "",
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(" ")
|
|
||||||
|
|
||||||
return (
|
|
||||||
<React.Fragment key={step.key}>
|
|
||||||
{idx > 0 && (
|
|
||||||
<div
|
|
||||||
className={`xx-clonemodal-step-connector${isDone ? " xx-clonemodal-step-connector--done" : ""}`}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<div className={stepClass}>
|
|
||||||
<div className="xx-clonemodal-step-icon">{isDone ? "✓" : step.icon}</div>
|
|
||||||
<span className="xx-clonemodal-step-label">{step.label}</span>
|
|
||||||
</div>
|
|
||||||
</React.Fragment>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 当前阶段描述 */}
|
|
||||||
<div className="xx-clonemodal-progress-info">
|
|
||||||
{phase === "uploading" && (
|
|
||||||
<>
|
|
||||||
<div className="xx-clonemodal-progress-spinner" />
|
|
||||||
<p className="xx-clonemodal-progress-text">正在上传音频文件…</p>
|
|
||||||
<p className="xx-clonemodal-progress-sub">请稍候,正在将音频上传至服务器</p>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{phase === "cloning" && (
|
|
||||||
<>
|
|
||||||
<div className="xx-clonemodal-progress-spinner xx-clonemodal-progress-spinner--cloning" />
|
|
||||||
<p className="xx-clonemodal-progress-text">AI 正在克隆你的声音…</p>
|
|
||||||
<p className="xx-clonemodal-progress-sub">正在分析声音特征,生成专属音色模型</p>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ── 完成阶段 ──────────────────────────────────── */}
|
|
||||||
{phase === "done" && (
|
|
||||||
<div className="xx-clonemodal-progress-body">
|
|
||||||
{/* 步骤指示器(全部完成) */}
|
|
||||||
<div className="xx-clonemodal-steps-progress">
|
|
||||||
{PROGRESS_STEPS.map((step, idx) => (
|
|
||||||
<React.Fragment key={step.key}>
|
|
||||||
{idx > 0 && (
|
|
||||||
<div className="xx-clonemodal-step-connector xx-clonemodal-step-connector--done" />
|
|
||||||
)}
|
|
||||||
<div className="xx-clonemodal-step-progress xx-clonemodal-step-progress--done">
|
|
||||||
<div className="xx-clonemodal-step-icon">✓</div>
|
|
||||||
<span className="xx-clonemodal-step-label">{step.label}</span>
|
|
||||||
</div>
|
|
||||||
</React.Fragment>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="xx-clonemodal-success">
|
|
||||||
<div className="xx-clonemodal-success-icon">🎉</div>
|
|
||||||
<h3 className="xx-clonemodal-success-title">克隆已提交</h3>
|
|
||||||
<p className="xx-clonemodal-success-desc">
|
|
||||||
音色正在生成中,完成后将出现在「我的克隆」列表中
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Modal>
|
</Modal>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import React from "react"
|
||||||
|
import { Button } from "@/components/ui"
|
||||||
|
import UploadZone from "./UploadZone"
|
||||||
|
import RecordArea from "./RecordArea"
|
||||||
|
import StepIndicator from "./StepIndicator"
|
||||||
|
import { MAX_VOICE_NAME_LENGTH, MAX_VOICE_DESC_LENGTH } from "../constants/cloneModal"
|
||||||
|
|
||||||
|
interface InputViewProps {
|
||||||
|
voiceName: string
|
||||||
|
voiceDescription: string
|
||||||
|
selectedFile: File | null
|
||||||
|
dragActive: boolean
|
||||||
|
isRecording: boolean
|
||||||
|
recordTime: number
|
||||||
|
recordedBlob: Blob | null
|
||||||
|
errorMessage: string
|
||||||
|
canSubmit: boolean
|
||||||
|
onVoiceNameChange: (value: string) => void
|
||||||
|
onVoiceDescChange: (value: string) => void
|
||||||
|
onDragActiveChange: (active: boolean) => void
|
||||||
|
onFileSelect: (file: File | null, error: string) => void
|
||||||
|
onRecordToggle: () => void
|
||||||
|
onClose: () => void
|
||||||
|
onSubmit: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const INPUT_STEPS = ["上传/录制音频", "填写信息", "提交克隆"]
|
||||||
|
|
||||||
|
const InputView: React.FC<InputViewProps> = ({
|
||||||
|
voiceName,
|
||||||
|
voiceDescription,
|
||||||
|
selectedFile,
|
||||||
|
dragActive,
|
||||||
|
isRecording,
|
||||||
|
recordTime,
|
||||||
|
recordedBlob,
|
||||||
|
errorMessage,
|
||||||
|
canSubmit,
|
||||||
|
onVoiceNameChange,
|
||||||
|
onVoiceDescChange,
|
||||||
|
onDragActiveChange,
|
||||||
|
onFileSelect,
|
||||||
|
onRecordToggle,
|
||||||
|
onClose,
|
||||||
|
onSubmit,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className="xx-clonemodal-body">
|
||||||
|
{/* 步骤引导 */}
|
||||||
|
<StepIndicator currentStep={0} steps={INPUT_STEPS} />
|
||||||
|
|
||||||
|
{/* 音色名称 */}
|
||||||
|
<div className="xx-clonemodal-field">
|
||||||
|
<label className="xx-clonemodal-label">
|
||||||
|
音色名称 <span className="xx-clonemodal-required">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="xx-clonemodal-input"
|
||||||
|
value={voiceName}
|
||||||
|
onChange={(e) => onVoiceNameChange(e.target.value)}
|
||||||
|
placeholder="输入音色名称(2-20字符)"
|
||||||
|
maxLength={MAX_VOICE_NAME_LENGTH}
|
||||||
|
/>
|
||||||
|
<div className="xx-clonemodal-char-count">
|
||||||
|
{voiceName.length}/{MAX_VOICE_NAME_LENGTH}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 上传区域 */}
|
||||||
|
<div className="xx-clonemodal-field">
|
||||||
|
<label className="xx-clonemodal-label">上传音频</label>
|
||||||
|
<UploadZone
|
||||||
|
selectedFile={selectedFile}
|
||||||
|
dragActive={dragActive}
|
||||||
|
onDragActiveChange={onDragActiveChange}
|
||||||
|
onFileSelect={onFileSelect}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 或分隔 */}
|
||||||
|
<div className="xx-clonemodal-divider">
|
||||||
|
<div className="xx-clonemodal-divider-line" />
|
||||||
|
<span className="xx-clonemodal-divider-text">或</span>
|
||||||
|
<div className="xx-clonemodal-divider-line" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 录制区域 */}
|
||||||
|
<div className="xx-clonemodal-field">
|
||||||
|
<label className="xx-clonemodal-label">直接录制</label>
|
||||||
|
<RecordArea
|
||||||
|
isRecording={isRecording}
|
||||||
|
recordTime={recordTime}
|
||||||
|
recordedBlob={recordedBlob}
|
||||||
|
onRecordToggle={onRecordToggle}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 音色描述 */}
|
||||||
|
<div className="xx-clonemodal-field">
|
||||||
|
<label className="xx-clonemodal-label">音色描述</label>
|
||||||
|
<textarea
|
||||||
|
className="xx-clonemodal-textarea"
|
||||||
|
value={voiceDescription}
|
||||||
|
onChange={(e) => onVoiceDescChange(e.target.value)}
|
||||||
|
placeholder="可选,描述这个音色的特点(最多100字符)"
|
||||||
|
maxLength={MAX_VOICE_DESC_LENGTH}
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
<div className="xx-clonemodal-char-count">
|
||||||
|
{voiceDescription.length}/{MAX_VOICE_DESC_LENGTH}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 错误提示 */}
|
||||||
|
{errorMessage && (
|
||||||
|
<div className="xx-clonemodal-error">
|
||||||
|
<span className="xx-clonemodal-error-icon">⚠️</span>
|
||||||
|
<span>{errorMessage}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 提示 */}
|
||||||
|
<div className="xx-clonemodal-tip">
|
||||||
|
<span className="xx-clonemodal-tip-icon">💡</span>
|
||||||
|
<span>建议上传 10 秒 ~ 3 分钟的清晰语音,环境安静、语速均匀效果最佳</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 底部按钮 */}
|
||||||
|
<div className="xx-clonemodal-footer">
|
||||||
|
<Button buttonType="ghost" onClick={onClose}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button buttonType="primary" disabled={!canSubmit} onClick={onSubmit}>
|
||||||
|
🎤 开始克隆
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default InputView
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import React from "react"
|
||||||
|
import { PROGRESS_STEPS } from "../constants/cloneModal"
|
||||||
|
import type { ProgressStep } from "../types/cloneModal"
|
||||||
|
import type { ModalPhase } from "../types/cloneModal"
|
||||||
|
|
||||||
|
interface ProgressViewProps {
|
||||||
|
phase: ModalPhase
|
||||||
|
}
|
||||||
|
|
||||||
|
const getProgressIndex = (phase: ModalPhase): number => {
|
||||||
|
switch (phase) {
|
||||||
|
case "uploading":
|
||||||
|
return 0
|
||||||
|
case "cloning":
|
||||||
|
return 1
|
||||||
|
case "done":
|
||||||
|
return 2
|
||||||
|
default:
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ProgressView: React.FC<ProgressViewProps> = ({ phase }) => {
|
||||||
|
const progressIndex = getProgressIndex(phase)
|
||||||
|
const isDone = phase === "done"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="xx-clonemodal-progress-body">
|
||||||
|
{/* 步骤指示器 */}
|
||||||
|
<div className="xx-clonemodal-steps-progress">
|
||||||
|
{PROGRESS_STEPS.map((step: ProgressStep, idx: number) => {
|
||||||
|
const isActive = idx === progressIndex && !isDone
|
||||||
|
const stepDone = idx < progressIndex || isDone
|
||||||
|
const stepClass = [
|
||||||
|
"xx-clonemodal-step-progress",
|
||||||
|
isActive ? "xx-clonemodal-step-progress--active" : "",
|
||||||
|
stepDone ? "xx-clonemodal-step-progress--done" : "",
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ")
|
||||||
|
|
||||||
|
return (
|
||||||
|
<React.Fragment key={step.key}>
|
||||||
|
{idx > 0 && (
|
||||||
|
<div
|
||||||
|
className={`xx-clonemodal-step-connector${stepDone ? " xx-clonemodal-step-connector--done" : ""}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className={stepClass}>
|
||||||
|
<div className="xx-clonemodal-step-icon">{stepDone ? "✓" : step.icon}</div>
|
||||||
|
<span className="xx-clonemodal-step-label">{step.label}</span>
|
||||||
|
</div>
|
||||||
|
</React.Fragment>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 完成阶段 */}
|
||||||
|
{isDone && (
|
||||||
|
<div className="xx-clonemodal-success">
|
||||||
|
<div className="xx-clonemodal-success-icon">🎉</div>
|
||||||
|
<h3 className="xx-clonemodal-success-title">克隆已提交</h3>
|
||||||
|
<p className="xx-clonemodal-success-desc">
|
||||||
|
音色正在生成中,完成后将出现在「我的克隆」列表中
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 进行中阶段 */}
|
||||||
|
{!isDone && (
|
||||||
|
<div className="xx-clonemodal-progress-info">
|
||||||
|
{phase === "uploading" && (
|
||||||
|
<>
|
||||||
|
<div className="xx-clonemodal-progress-spinner" />
|
||||||
|
<p className="xx-clonemodal-progress-text">正在上传音频文件…</p>
|
||||||
|
<p className="xx-clonemodal-progress-sub">请稍候,正在将音频上传至服务器</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{phase === "cloning" && (
|
||||||
|
<>
|
||||||
|
<div className="xx-clonemodal-progress-spinner xx-clonemodal-progress-spinner--cloning" />
|
||||||
|
<p className="xx-clonemodal-progress-text">AI 正在克隆你的声音…</p>
|
||||||
|
<p className="xx-clonemodal-progress-sub">正在分析声音特征,生成专属音色模型</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ProgressView
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import React from "react"
|
||||||
|
import { formatRecordTime } from "../utils/cloneModal"
|
||||||
|
|
||||||
|
interface RecordAreaProps {
|
||||||
|
isRecording: boolean
|
||||||
|
recordTime: number
|
||||||
|
recordedBlob: Blob | null
|
||||||
|
onRecordToggle: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const RecordArea: React.FC<RecordAreaProps> = ({
|
||||||
|
isRecording,
|
||||||
|
recordTime,
|
||||||
|
recordedBlob,
|
||||||
|
onRecordToggle,
|
||||||
|
}) => {
|
||||||
|
const getHintText = () => {
|
||||||
|
if (isRecording) return `录制中 ${formatRecordTime(recordTime)}`
|
||||||
|
if (recordedBlob) return `已录制 ${formatRecordTime(recordTime)}`
|
||||||
|
return "点击按钮开始录制(最长 5 分钟)"
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="xx-clonemodal-record-area">
|
||||||
|
<div className="xx-clonemodal-record-info">
|
||||||
|
<p className="xx-clonemodal-record-hint">{getHintText()}</p>
|
||||||
|
{isRecording && (
|
||||||
|
<div className="xx-clonemodal-record-wave">
|
||||||
|
<span className="xx-clonemodal-record-wave-bar" />
|
||||||
|
<span className="xx-clonemodal-record-wave-bar" />
|
||||||
|
<span className="xx-clonemodal-record-wave-bar" />
|
||||||
|
<span className="xx-clonemodal-record-wave-bar" />
|
||||||
|
<span className="xx-clonemodal-record-wave-bar" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`xx-clonemodal-record-btn${isRecording ? " xx-clonemodal-record-btn--recording" : ""}`}
|
||||||
|
onClick={onRecordToggle}
|
||||||
|
title={isRecording ? "停止录制" : "开始录制"}
|
||||||
|
>
|
||||||
|
{isRecording ? "⏹" : "🎙️"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default RecordArea
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import React from "react"
|
||||||
|
|
||||||
|
interface StepIndicatorProps {
|
||||||
|
currentStep: number
|
||||||
|
steps: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 输入阶段顶部的步骤引导(数字步骤)
|
||||||
|
*/
|
||||||
|
const StepIndicator: React.FC<StepIndicatorProps> = ({ currentStep, steps }) => {
|
||||||
|
return (
|
||||||
|
<div className="xx-clonemodal-steps">
|
||||||
|
{steps.map((label, idx) => {
|
||||||
|
const isActive = idx <= currentStep
|
||||||
|
return (
|
||||||
|
<React.Fragment key={idx}>
|
||||||
|
{idx > 0 && <div className="xx-clonemodal-step-connector" />}
|
||||||
|
<div className={`xx-clonemodal-step${isActive ? " xx-clonemodal-step--active" : ""}`}>
|
||||||
|
<div className="xx-clonemodal-step-number">{idx + 1}</div>
|
||||||
|
<span className="xx-clonemodal-step-label">{label}</span>
|
||||||
|
</div>
|
||||||
|
</React.Fragment>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default StepIndicator
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import React, { useRef } from "react"
|
||||||
|
import { ACCEPTED_MIME } from "../constants/cloneModal"
|
||||||
|
import { validateFile } from "../utils/cloneModal"
|
||||||
|
|
||||||
|
interface UploadZoneProps {
|
||||||
|
selectedFile: File | null
|
||||||
|
dragActive: boolean
|
||||||
|
onDragActiveChange: (active: boolean) => void
|
||||||
|
onFileSelect: (file: File | null, error: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const UploadZone: React.FC<UploadZoneProps> = ({
|
||||||
|
selectedFile,
|
||||||
|
dragActive,
|
||||||
|
onDragActiveChange,
|
||||||
|
onFileSelect,
|
||||||
|
}) => {
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
const handleUploadClick = () => {
|
||||||
|
fileInputRef.current?.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
if (file) {
|
||||||
|
const error = validateFile(file)
|
||||||
|
onFileSelect(error ? null : file, error || "")
|
||||||
|
}
|
||||||
|
e.target.value = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDrag = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
if (e.type === "dragenter" || e.type === "dragover") {
|
||||||
|
onDragActiveChange(true)
|
||||||
|
} else if (e.type === "dragleave") {
|
||||||
|
onDragActiveChange(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDrop = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
onDragActiveChange(false)
|
||||||
|
const file = e.dataTransfer.files?.[0]
|
||||||
|
if (file) {
|
||||||
|
const error = validateFile(file)
|
||||||
|
onFileSelect(error ? null : file, error || "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`xx-clonemodal-upload-zone${dragActive ? " xx-clonemodal-upload-zone--active" : ""}${selectedFile ? " xx-clonemodal-upload-zone--has-file" : ""}`}
|
||||||
|
onClick={handleUploadClick}
|
||||||
|
onDragEnter={handleDrag}
|
||||||
|
onDragOver={handleDrag}
|
||||||
|
onDragLeave={handleDrag}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
>
|
||||||
|
<div className="xx-clonemodal-upload-icon">{selectedFile ? "📄" : "🎵"}</div>
|
||||||
|
<p className="xx-clonemodal-upload-title">
|
||||||
|
{selectedFile ? selectedFile.name : "拖拽音频文件到此处,或点击上传"}
|
||||||
|
</p>
|
||||||
|
<p className="xx-clonemodal-upload-hint">支持 MP3、WAV、M4A 格式,最大 10MB</p>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept={ACCEPTED_MIME}
|
||||||
|
style={{ display: "none" }}
|
||||||
|
onChange={handleFileChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default UploadZone
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import type { ProgressStep } from "../types/cloneModal"
|
||||||
|
|
||||||
|
/** 进度阶段配置 */
|
||||||
|
export const PROGRESS_STEPS: ProgressStep[] = [
|
||||||
|
{ key: "uploading", label: "上传中", icon: "📤" },
|
||||||
|
{ key: "cloning", label: "克隆中", icon: "🧬" },
|
||||||
|
{ key: "done", label: "完成", icon: "✅" },
|
||||||
|
]
|
||||||
|
|
||||||
|
/** 支持的音频扩展名 */
|
||||||
|
export const ACCEPTED_EXTENSIONS = ["mp3", "wav", "m4a"]
|
||||||
|
|
||||||
|
/** input accept 属性值 */
|
||||||
|
export const ACCEPTED_MIME = ".mp3,.wav,.m4a,audio/mpeg,audio/wav,audio/mp4"
|
||||||
|
|
||||||
|
/** 最大文件大小:10MB */
|
||||||
|
export const MAX_FILE_SIZE = 10 * 1024 * 1024
|
||||||
|
|
||||||
|
/** 最长录制时长(秒):5 分钟 */
|
||||||
|
export const MAX_RECORD_SECONDS = 5 * 60
|
||||||
|
|
||||||
|
/** 音色名称最小长度 */
|
||||||
|
export const MIN_VOICE_NAME_LENGTH = 2
|
||||||
|
|
||||||
|
/** 音色名称最大长度 */
|
||||||
|
export const MAX_VOICE_NAME_LENGTH = 20
|
||||||
|
|
||||||
|
/** 音色描述最大长度 */
|
||||||
|
export const MAX_VOICE_DESC_LENGTH = 100
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { useState, useRef, useCallback, useEffect } from "react"
|
||||||
|
import { MAX_RECORD_SECONDS } from "../constants/cloneModal"
|
||||||
|
|
||||||
|
interface UseAudioRecorderReturn {
|
||||||
|
isRecording: boolean
|
||||||
|
recordTime: number
|
||||||
|
recordedBlob: Blob | null
|
||||||
|
toggleRecording: () => void
|
||||||
|
resetRecording: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 录音 Hook —— 封装 MediaRecorder 录音逻辑
|
||||||
|
*/
|
||||||
|
const useAudioRecorder = (): UseAudioRecorderReturn => {
|
||||||
|
const [isRecording, setIsRecording] = useState(false)
|
||||||
|
const [recordTime, setRecordTime] = useState(0)
|
||||||
|
const [recordedBlob, setRecordedBlob] = useState<Blob | null>(null)
|
||||||
|
|
||||||
|
const recordTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||||
|
const mediaRecorderRef = useRef<MediaRecorder | null>(null)
|
||||||
|
const audioChunksRef = useRef<Blob[]>([])
|
||||||
|
|
||||||
|
const stopRecording = useCallback(() => {
|
||||||
|
setIsRecording(false)
|
||||||
|
if (recordTimerRef.current) {
|
||||||
|
clearInterval(recordTimerRef.current)
|
||||||
|
recordTimerRef.current = null
|
||||||
|
}
|
||||||
|
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== "inactive") {
|
||||||
|
mediaRecorderRef.current.stop()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const startRecording = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
||||||
|
const mediaRecorder = new MediaRecorder(stream)
|
||||||
|
mediaRecorderRef.current = mediaRecorder
|
||||||
|
audioChunksRef.current = []
|
||||||
|
|
||||||
|
mediaRecorder.ondataavailable = (event) => {
|
||||||
|
if (event.data.size > 0) {
|
||||||
|
audioChunksRef.current.push(event.data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mediaRecorder.onstop = () => {
|
||||||
|
const blob = new Blob(audioChunksRef.current, { type: "audio/webm" })
|
||||||
|
setRecordedBlob(blob)
|
||||||
|
stream.getTracks().forEach((track) => track.stop())
|
||||||
|
}
|
||||||
|
|
||||||
|
mediaRecorder.start()
|
||||||
|
setIsRecording(true)
|
||||||
|
setRecordTime(0)
|
||||||
|
setRecordedBlob(null)
|
||||||
|
|
||||||
|
recordTimerRef.current = setInterval(() => {
|
||||||
|
setRecordTime((prev) => {
|
||||||
|
const next = prev + 1
|
||||||
|
if (next >= MAX_RECORD_SECONDS) {
|
||||||
|
setTimeout(() => {
|
||||||
|
stopRecording()
|
||||||
|
}, 0)
|
||||||
|
return MAX_RECORD_SECONDS
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}, 1000)
|
||||||
|
} catch {
|
||||||
|
// 错误由调用方通过其他机制提示
|
||||||
|
setIsRecording(false)
|
||||||
|
}
|
||||||
|
}, [stopRecording])
|
||||||
|
|
||||||
|
const toggleRecording = useCallback(() => {
|
||||||
|
if (isRecording) {
|
||||||
|
stopRecording()
|
||||||
|
} else {
|
||||||
|
startRecording()
|
||||||
|
}
|
||||||
|
}, [isRecording, startRecording, stopRecording])
|
||||||
|
|
||||||
|
const resetRecording = useCallback(() => {
|
||||||
|
setIsRecording(false)
|
||||||
|
setRecordTime(0)
|
||||||
|
setRecordedBlob(null)
|
||||||
|
audioChunksRef.current = []
|
||||||
|
if (recordTimerRef.current) {
|
||||||
|
clearInterval(recordTimerRef.current)
|
||||||
|
recordTimerRef.current = null
|
||||||
|
}
|
||||||
|
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== "inactive") {
|
||||||
|
mediaRecorderRef.current.stop()
|
||||||
|
}
|
||||||
|
mediaRecorderRef.current = null
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// 卸载时清理
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (recordTimerRef.current) clearInterval(recordTimerRef.current)
|
||||||
|
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== "inactive") {
|
||||||
|
mediaRecorderRef.current.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return {
|
||||||
|
isRecording,
|
||||||
|
recordTime,
|
||||||
|
recordedBlob,
|
||||||
|
toggleRecording,
|
||||||
|
resetRecording,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useAudioRecorder
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
import { useState, useRef, useCallback, useEffect } from "react"
|
||||||
|
import { createVoiceClone, toVoiceClone } from "@/api/voice-clone"
|
||||||
|
import { uploadAsset } from "@/api/assets"
|
||||||
|
import type { ModalPhase, CloneModalProps } from "../types/cloneModal"
|
||||||
|
import { MIN_VOICE_NAME_LENGTH, MAX_VOICE_NAME_LENGTH } from "../constants/cloneModal"
|
||||||
|
import useAudioRecorder from "./useAudioRecorder"
|
||||||
|
|
||||||
|
interface UseCloneModalReturn {
|
||||||
|
phase: ModalPhase
|
||||||
|
voiceName: string
|
||||||
|
voiceDescription: string
|
||||||
|
selectedFile: File | null
|
||||||
|
dragActive: boolean
|
||||||
|
errorMessage: string
|
||||||
|
isRecording: boolean
|
||||||
|
recordTime: number
|
||||||
|
recordedBlob: Blob | null
|
||||||
|
canSubmit: boolean
|
||||||
|
isProcessing: boolean
|
||||||
|
setVoiceName: (value: string) => void
|
||||||
|
setVoiceDescription: (value: string) => void
|
||||||
|
setDragActive: (active: boolean) => void
|
||||||
|
handleFileSelect: (file: File | null, error: string) => void
|
||||||
|
handleRecordToggle: () => void
|
||||||
|
handleClose: () => void
|
||||||
|
handleSubmit: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 音色克隆弹窗主业务 Hook
|
||||||
|
*/
|
||||||
|
const useCloneModal = ({ open, onClose, onSuccess }: CloneModalProps): UseCloneModalReturn => {
|
||||||
|
const [phase, setPhase] = useState<ModalPhase>("input")
|
||||||
|
const [voiceName, setVoiceName] = useState("")
|
||||||
|
const [voiceDescription, setVoiceDescription] = useState("")
|
||||||
|
const [selectedFile, setSelectedFile] = useState<File | null>(null)
|
||||||
|
const [dragActive, setDragActive] = useState(false)
|
||||||
|
const [errorMessage, setErrorMessage] = useState("")
|
||||||
|
|
||||||
|
const { isRecording, recordTime, recordedBlob, toggleRecording, resetRecording } =
|
||||||
|
useAudioRecorder()
|
||||||
|
|
||||||
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
/** 默认音色名称计数器 */
|
||||||
|
const cloneCounterRef = useRef(1)
|
||||||
|
|
||||||
|
const getNextDefaultName = useCallback((): string => {
|
||||||
|
const name = `我的声音 ${cloneCounterRef.current}`
|
||||||
|
cloneCounterRef.current += 1
|
||||||
|
return name
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const hasAudio = selectedFile !== null || recordedBlob !== null
|
||||||
|
|
||||||
|
const canSubmit =
|
||||||
|
voiceName.trim().length >= MIN_VOICE_NAME_LENGTH &&
|
||||||
|
voiceName.trim().length <= MAX_VOICE_NAME_LENGTH &&
|
||||||
|
hasAudio
|
||||||
|
|
||||||
|
const isProcessing = phase === "uploading" || phase === "cloning"
|
||||||
|
|
||||||
|
/** 重置弹窗状态 */
|
||||||
|
const resetState = useCallback(() => {
|
||||||
|
setPhase("input")
|
||||||
|
setVoiceName(getNextDefaultName())
|
||||||
|
setVoiceDescription("")
|
||||||
|
setSelectedFile(null)
|
||||||
|
setDragActive(false)
|
||||||
|
setErrorMessage("")
|
||||||
|
resetRecording()
|
||||||
|
}, [getNextDefaultName, resetRecording])
|
||||||
|
|
||||||
|
/** 关闭弹窗 */
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
resetState()
|
||||||
|
onClose()
|
||||||
|
}, [resetState, onClose])
|
||||||
|
|
||||||
|
/** 弹窗打开时重置状态 */
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
resetState()
|
||||||
|
}
|
||||||
|
}, [open, resetState])
|
||||||
|
|
||||||
|
/** 组件卸载时清理定时器 */
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (timerRef.current) clearTimeout(timerRef.current)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
/** 选择文件(来自上传或拖拽) */
|
||||||
|
const handleFileSelect = useCallback(
|
||||||
|
(file: File | null, error: string) => {
|
||||||
|
if (error) {
|
||||||
|
setErrorMessage(error)
|
||||||
|
setSelectedFile(null)
|
||||||
|
} else {
|
||||||
|
setErrorMessage("")
|
||||||
|
setSelectedFile(file)
|
||||||
|
// 清除录音
|
||||||
|
resetRecording()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[resetRecording],
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 录音切换 */
|
||||||
|
const handleRecordToggle = useCallback(() => {
|
||||||
|
setErrorMessage("")
|
||||||
|
if (isRecording) {
|
||||||
|
toggleRecording()
|
||||||
|
} else {
|
||||||
|
// 开始录制前清除已选文件
|
||||||
|
setSelectedFile(null)
|
||||||
|
toggleRecording()
|
||||||
|
}
|
||||||
|
}, [isRecording, toggleRecording])
|
||||||
|
|
||||||
|
/** 表单验证 */
|
||||||
|
const validateForm = useCallback((): string | null => {
|
||||||
|
const name = voiceName.trim()
|
||||||
|
if (!name) {
|
||||||
|
return "请输入音色名称"
|
||||||
|
}
|
||||||
|
if (name.length < MIN_VOICE_NAME_LENGTH || name.length > MAX_VOICE_NAME_LENGTH) {
|
||||||
|
return `音色名称需在 ${MIN_VOICE_NAME_LENGTH}-${MAX_VOICE_NAME_LENGTH} 个字符之间`
|
||||||
|
}
|
||||||
|
if (!hasAudio) {
|
||||||
|
return "请上传音频文件或录制一段声音"
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}, [voiceName, hasAudio])
|
||||||
|
|
||||||
|
/** 提交克隆 */
|
||||||
|
const handleSubmit = useCallback(async () => {
|
||||||
|
const formError = validateForm()
|
||||||
|
if (formError) {
|
||||||
|
setErrorMessage(formError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setErrorMessage("")
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 阶段 1:上传音频
|
||||||
|
setPhase("uploading")
|
||||||
|
|
||||||
|
let fileToUpload: File
|
||||||
|
if (selectedFile) {
|
||||||
|
fileToUpload = selectedFile
|
||||||
|
} else {
|
||||||
|
fileToUpload = new File([recordedBlob!], `recorded-${Date.now()}.webm`, {
|
||||||
|
type: "audio/webm",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append("file", fileToUpload)
|
||||||
|
const uploadResult = await uploadAsset(formData)
|
||||||
|
|
||||||
|
// 阶段 2:克隆
|
||||||
|
setPhase("cloning")
|
||||||
|
const result = await createVoiceClone({
|
||||||
|
name: voiceName.trim(),
|
||||||
|
description: voiceDescription.trim() || undefined,
|
||||||
|
audio_url: uploadResult.url,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 阶段 3:完成
|
||||||
|
setPhase("done")
|
||||||
|
|
||||||
|
// 2秒后自动关闭
|
||||||
|
timerRef.current = setTimeout(() => {
|
||||||
|
onSuccess?.(toVoiceClone(result))
|
||||||
|
handleClose()
|
||||||
|
}, 2000)
|
||||||
|
} catch (err) {
|
||||||
|
setPhase("input")
|
||||||
|
setErrorMessage(err instanceof Error ? err.message : "克隆失败,请重试")
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
validateForm,
|
||||||
|
selectedFile,
|
||||||
|
recordedBlob,
|
||||||
|
voiceName,
|
||||||
|
voiceDescription,
|
||||||
|
onSuccess,
|
||||||
|
handleClose,
|
||||||
|
])
|
||||||
|
|
||||||
|
return {
|
||||||
|
phase,
|
||||||
|
voiceName,
|
||||||
|
voiceDescription,
|
||||||
|
selectedFile,
|
||||||
|
dragActive,
|
||||||
|
errorMessage,
|
||||||
|
isRecording,
|
||||||
|
recordTime,
|
||||||
|
recordedBlob,
|
||||||
|
canSubmit,
|
||||||
|
isProcessing,
|
||||||
|
setVoiceName,
|
||||||
|
setVoiceDescription,
|
||||||
|
setDragActive,
|
||||||
|
handleFileSelect,
|
||||||
|
handleRecordToggle,
|
||||||
|
handleClose,
|
||||||
|
handleSubmit,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useCloneModal
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import type { VoiceClone } from "@/api/voice-clone"
|
||||||
|
|
||||||
|
/** 弹窗阶段 */
|
||||||
|
export type ModalPhase = "input" | "uploading" | "cloning" | "done"
|
||||||
|
|
||||||
|
export interface CloneModalProps {
|
||||||
|
/** 弹窗是否可见 */
|
||||||
|
open: boolean
|
||||||
|
/** 关闭弹窗回调 */
|
||||||
|
onClose: () => void
|
||||||
|
/** 克隆成功回调(返回新创建的音色) */
|
||||||
|
onSuccess?: (voice: VoiceClone) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 进度步骤项 */
|
||||||
|
export interface ProgressStep {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
icon: string
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { ACCEPTED_EXTENSIONS, MAX_FILE_SIZE } from "../constants/cloneModal"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化录制时间 mm:ss
|
||||||
|
*/
|
||||||
|
export const formatRecordTime = (seconds: number): string => {
|
||||||
|
const m = Math.floor(seconds / 60)
|
||||||
|
const s = seconds % 60
|
||||||
|
return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证上传的音频文件
|
||||||
|
* @returns 错误信息,null 表示验证通过
|
||||||
|
*/
|
||||||
|
export const validateFile = (file: File): string | null => {
|
||||||
|
const ext = file.name.split(".").pop()?.toLowerCase()
|
||||||
|
if (!ext || !ACCEPTED_EXTENSIONS.includes(ext)) {
|
||||||
|
return "不支持的音频格式,请上传 MP3、WAV 或 M4A 文件"
|
||||||
|
}
|
||||||
|
if (file.size > MAX_FILE_SIZE) {
|
||||||
|
return "文件大小超过 10MB,请压缩后重试"
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
@@ -11,10 +11,7 @@ export interface AssetUploadZoneProps {
|
|||||||
onUpload: (file: File) => void
|
onUpload: (file: File) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AssetUploadZone: React.FC<AssetUploadZoneProps> = ({
|
export const AssetUploadZone: React.FC<AssetUploadZoneProps> = ({ uploading, onUpload }) => {
|
||||||
uploading,
|
|
||||||
onUpload,
|
|
||||||
}) => {
|
|
||||||
return (
|
return (
|
||||||
<Upload.Dragger
|
<Upload.Dragger
|
||||||
beforeUpload={(file) => {
|
beforeUpload={(file) => {
|
||||||
|
|||||||
@@ -0,0 +1,238 @@
|
|||||||
|
import { useState, useCallback } from "react"
|
||||||
|
import { message } from "antd"
|
||||||
|
import {
|
||||||
|
batchDeleteAssets,
|
||||||
|
batchTagAssets,
|
||||||
|
batchClassifyAssets,
|
||||||
|
batchMarkAssets,
|
||||||
|
type BatchOperationResult,
|
||||||
|
} from "@/api/assets"
|
||||||
|
import type { SmartViewType } from "../../components/BatchMarkModal"
|
||||||
|
import { SMART_VIEW_LABELS } from "./constants"
|
||||||
|
|
||||||
|
/* ── 批量删除 ── */
|
||||||
|
interface UseBatchDeleteOptions {
|
||||||
|
selectedIds: Set<string>
|
||||||
|
invalidateAssets: () => void
|
||||||
|
showResult: (result: BatchOperationResult, title: string, clear?: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useBatchDelete = ({
|
||||||
|
selectedIds,
|
||||||
|
invalidateAssets,
|
||||||
|
showResult,
|
||||||
|
}: UseBatchDeleteOptions) => {
|
||||||
|
const [batchLoading, setBatchLoading] = useState(false)
|
||||||
|
|
||||||
|
const handleBatchDelete = useCallback(async () => {
|
||||||
|
const ids = Array.from(selectedIds)
|
||||||
|
setBatchLoading(true)
|
||||||
|
try {
|
||||||
|
const result = await batchDeleteAssets(ids)
|
||||||
|
invalidateAssets()
|
||||||
|
showResult(result, "批量删除")
|
||||||
|
if (result.failure_count === 0) {
|
||||||
|
message.success(`成功删除 ${result.success_count} 个素材`)
|
||||||
|
} else {
|
||||||
|
message.warning(
|
||||||
|
`删除完成:成功 ${result.success_count} 个,失败 ${result.failure_count} 个`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
message.error("批量删除失败,请重试")
|
||||||
|
} finally {
|
||||||
|
setBatchLoading(false)
|
||||||
|
}
|
||||||
|
}, [selectedIds, invalidateAssets, showResult])
|
||||||
|
|
||||||
|
return { batchLoading, handleBatchDelete }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 批量打标签 ── */
|
||||||
|
interface UseBatchTagOptions {
|
||||||
|
selectedIds: Set<string>
|
||||||
|
queryClient: ReturnType<typeof import("@tanstack/react-query").useQueryClient>
|
||||||
|
showResult: (result: BatchOperationResult, title: string, clear?: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useBatchTag = ({ selectedIds, queryClient, showResult }: UseBatchTagOptions) => {
|
||||||
|
const [tagModalOpen, setTagModalOpen] = useState(false)
|
||||||
|
const [batchTagInput, setBatchTagInput] = useState("")
|
||||||
|
const [batchTags, setBatchTags] = useState<string[]>([])
|
||||||
|
const [tagMode, setTagMode] = useState<"add" | "replace">("add")
|
||||||
|
const [batchLoading, setBatchLoading] = useState(false)
|
||||||
|
|
||||||
|
const handleBatchTag = useCallback(async () => {
|
||||||
|
if (batchTags.length === 0) {
|
||||||
|
message.warning("请至少输入一个标签")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const ids = Array.from(selectedIds)
|
||||||
|
setBatchLoading(true)
|
||||||
|
try {
|
||||||
|
const result = await batchTagAssets({
|
||||||
|
asset_ids: ids,
|
||||||
|
tags: batchTags,
|
||||||
|
mode: tagMode,
|
||||||
|
})
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["assets"] })
|
||||||
|
showResult(result, "批量打标签")
|
||||||
|
setTagModalOpen(false)
|
||||||
|
setBatchTags([])
|
||||||
|
setBatchTagInput("")
|
||||||
|
setTagMode("add")
|
||||||
|
if (result.failure_count === 0) {
|
||||||
|
message.success(`成功为 ${result.success_count} 个素材打标签`)
|
||||||
|
} else {
|
||||||
|
message.warning(
|
||||||
|
`打标签完成:成功 ${result.success_count} 个,失败 ${result.failure_count} 个`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
message.error("批量打标签失败,请重试")
|
||||||
|
} finally {
|
||||||
|
setBatchLoading(false)
|
||||||
|
}
|
||||||
|
}, [batchTags, selectedIds, tagMode, queryClient, showResult])
|
||||||
|
|
||||||
|
const handleTagInputKeyDown = useCallback(
|
||||||
|
(e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === "Enter" && batchTagInput.trim()) {
|
||||||
|
e.preventDefault()
|
||||||
|
const tag = batchTagInput.trim()
|
||||||
|
if (!batchTags.includes(tag)) {
|
||||||
|
setBatchTags([...batchTags, tag])
|
||||||
|
}
|
||||||
|
setBatchTagInput("")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[batchTagInput, batchTags],
|
||||||
|
)
|
||||||
|
|
||||||
|
const removeBatchTag = useCallback(
|
||||||
|
(tag: string) => {
|
||||||
|
setBatchTags(batchTags.filter((t) => t !== tag))
|
||||||
|
},
|
||||||
|
[batchTags],
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
tagModalOpen,
|
||||||
|
setTagModalOpen,
|
||||||
|
batchTagInput,
|
||||||
|
setBatchTagInput,
|
||||||
|
batchTags,
|
||||||
|
setBatchTags,
|
||||||
|
tagMode,
|
||||||
|
setTagMode,
|
||||||
|
batchLoading,
|
||||||
|
handleBatchTag,
|
||||||
|
handleTagInputKeyDown,
|
||||||
|
removeBatchTag,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 批量改分类 ── */
|
||||||
|
interface UseBatchClassifyOptions {
|
||||||
|
selectedIds: Set<string>
|
||||||
|
queryClient: ReturnType<typeof import("@tanstack/react-query").useQueryClient>
|
||||||
|
showResult: (result: BatchOperationResult, title: string, clear?: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useBatchClassify = ({
|
||||||
|
selectedIds,
|
||||||
|
queryClient,
|
||||||
|
showResult,
|
||||||
|
}: UseBatchClassifyOptions) => {
|
||||||
|
const [classifyModalOpen, setClassifyModalOpen] = useState(false)
|
||||||
|
const [batchCategory, setBatchCategory] = useState("")
|
||||||
|
const [batchLoading, setBatchLoading] = useState(false)
|
||||||
|
|
||||||
|
const handleBatchClassify = useCallback(async () => {
|
||||||
|
if (!batchCategory) {
|
||||||
|
message.warning("请选择分类")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const ids = Array.from(selectedIds)
|
||||||
|
setBatchLoading(true)
|
||||||
|
try {
|
||||||
|
const result = await batchClassifyAssets({
|
||||||
|
asset_ids: ids,
|
||||||
|
category: batchCategory,
|
||||||
|
})
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["assets"] })
|
||||||
|
showResult(result, "批量改分类")
|
||||||
|
setClassifyModalOpen(false)
|
||||||
|
setBatchCategory("")
|
||||||
|
if (result.failure_count === 0) {
|
||||||
|
message.success(`成功将 ${result.success_count} 个素材改为「${batchCategory}」`)
|
||||||
|
} else {
|
||||||
|
message.warning(
|
||||||
|
`改分类完成:成功 ${result.success_count} 个,失败 ${result.failure_count} 个`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
message.error("批量改分类失败,请重试")
|
||||||
|
} finally {
|
||||||
|
setBatchLoading(false)
|
||||||
|
}
|
||||||
|
}, [batchCategory, selectedIds, queryClient, showResult])
|
||||||
|
|
||||||
|
return {
|
||||||
|
classifyModalOpen,
|
||||||
|
setClassifyModalOpen,
|
||||||
|
batchCategory,
|
||||||
|
setBatchCategory,
|
||||||
|
batchLoading,
|
||||||
|
handleBatchClassify,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 批量智能标记 ── */
|
||||||
|
interface UseBatchMarkOptions {
|
||||||
|
selectedIds: Set<string>
|
||||||
|
queryClient: ReturnType<typeof import("@tanstack/react-query").useQueryClient>
|
||||||
|
showResult: (result: BatchOperationResult, title: string, clear?: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useBatchMark = ({ selectedIds, queryClient, showResult }: UseBatchMarkOptions) => {
|
||||||
|
const [markModalOpen, setMarkModalOpen] = useState(false)
|
||||||
|
const [batchSmartView, setBatchSmartView] = useState<SmartViewType>("recommended")
|
||||||
|
const [batchLoading, setBatchLoading] = useState(false)
|
||||||
|
|
||||||
|
const handleBatchMark = useCallback(async () => {
|
||||||
|
const ids = Array.from(selectedIds)
|
||||||
|
setBatchLoading(true)
|
||||||
|
try {
|
||||||
|
const result = await batchMarkAssets({
|
||||||
|
asset_ids: ids,
|
||||||
|
smart_view: batchSmartView,
|
||||||
|
})
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["assets"] })
|
||||||
|
showResult(result, "批量智能标记")
|
||||||
|
setMarkModalOpen(false)
|
||||||
|
if (result.failure_count === 0) {
|
||||||
|
message.success(
|
||||||
|
`成功将 ${result.success_count} 个素材标记为「${SMART_VIEW_LABELS[batchSmartView]}」`,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
message.warning(
|
||||||
|
`智能标记完成:成功 ${result.success_count} 个,失败 ${result.failure_count} 个`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
message.error("批量智能标记失败,请重试")
|
||||||
|
} finally {
|
||||||
|
setBatchLoading(false)
|
||||||
|
}
|
||||||
|
}, [batchSmartView, selectedIds, queryClient, showResult])
|
||||||
|
|
||||||
|
return {
|
||||||
|
markModalOpen,
|
||||||
|
setMarkModalOpen,
|
||||||
|
batchSmartView,
|
||||||
|
setBatchSmartView,
|
||||||
|
batchLoading,
|
||||||
|
handleBatchMark,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import type { SmartViewType } from "../../components/BatchMarkModal"
|
||||||
|
|
||||||
|
export const SMART_VIEW_LABELS: Record<SmartViewType, string> = {
|
||||||
|
recommended: "推荐",
|
||||||
|
caution: "慎用",
|
||||||
|
high_risk: "高风险",
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { useCallback } from "react"
|
||||||
|
import { useQueryClient } from "@tanstack/react-query"
|
||||||
|
import { message } from "antd"
|
||||||
|
import { getAssetDiagnosis, deleteAsset, type BatchOperationResult } from "@/api/assets"
|
||||||
|
import type { AssetItem } from "../../types"
|
||||||
|
|
||||||
|
interface UseSingleOperationsOptions {
|
||||||
|
selectedIds: Set<string>
|
||||||
|
setSelectedIds: (ids: Set<string>) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 单个素材操作 Hook
|
||||||
|
* 诊断、单个删除
|
||||||
|
*/
|
||||||
|
export const useSingleOperations = ({
|
||||||
|
selectedIds,
|
||||||
|
setSelectedIds,
|
||||||
|
}: UseSingleOperationsOptions) => {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
/* ── 诊断 ── */
|
||||||
|
const handleDiagnose = useCallback(
|
||||||
|
async (asset: AssetItem) => {
|
||||||
|
// 模拟 loading 状态
|
||||||
|
try {
|
||||||
|
const result = await getAssetDiagnosis(asset.id)
|
||||||
|
const score = result.readiness_score ?? "-"
|
||||||
|
message.success(`"${asset.name}" 诊断完成,就绪分:${score}`)
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["assets"] })
|
||||||
|
} catch {
|
||||||
|
message.error(`"${asset.name}" 诊断失败`)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[queryClient],
|
||||||
|
)
|
||||||
|
|
||||||
|
/* ── 单个素材删除 ── */
|
||||||
|
const handleSingleDelete = useCallback(
|
||||||
|
async (assetId: string) => {
|
||||||
|
try {
|
||||||
|
await deleteAsset(assetId)
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["assets"] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] })
|
||||||
|
// 从选中集合中移除
|
||||||
|
setSelectedIds(
|
||||||
|
(() => {
|
||||||
|
const next = new Set(selectedIds)
|
||||||
|
next.delete(assetId)
|
||||||
|
return next
|
||||||
|
})(),
|
||||||
|
)
|
||||||
|
message.success("素材已删除")
|
||||||
|
} catch {
|
||||||
|
message.error("删除失败,请重试")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[queryClient, selectedIds, setSelectedIds],
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
handleDiagnose,
|
||||||
|
handleSingleDelete,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseBatchHelpersOptions {
|
||||||
|
queryClient: ReturnType<typeof useQueryClient>
|
||||||
|
setSelectedIds: (ids: Set<string>) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量操作辅助函数
|
||||||
|
* 刷新数据、显示操作结果
|
||||||
|
*/
|
||||||
|
export const useBatchHelpers = ({ queryClient, setSelectedIds }: UseBatchHelpersOptions) => {
|
||||||
|
const invalidateAssets = useCallback(() => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["assets"] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] })
|
||||||
|
}, [queryClient])
|
||||||
|
|
||||||
|
const showOperationResult = useCallback(
|
||||||
|
(
|
||||||
|
setResult: (r: BatchOperationResult | null) => void,
|
||||||
|
setTitle: (t: string) => void,
|
||||||
|
setDrawerOpen: (v: boolean) => void,
|
||||||
|
result: BatchOperationResult,
|
||||||
|
title: string,
|
||||||
|
clearSelection = true,
|
||||||
|
) => {
|
||||||
|
setResult(result)
|
||||||
|
setTitle(title)
|
||||||
|
setDrawerOpen(true)
|
||||||
|
if (clearSelection) setSelectedIds(new Set())
|
||||||
|
},
|
||||||
|
[setSelectedIds],
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
invalidateAssets,
|
||||||
|
showOperationResult,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,23 +1,21 @@
|
|||||||
|
/**
|
||||||
|
* 素材操作 Hook(入口)
|
||||||
|
* 组合各子模块,保持导出不变
|
||||||
|
*
|
||||||
|
* 子模块位于 ./asset-operations/
|
||||||
|
*/
|
||||||
import { useState, useCallback } from "react"
|
import { useState, useCallback } from "react"
|
||||||
import { useQueryClient } from "@tanstack/react-query"
|
import { useQueryClient } from "@tanstack/react-query"
|
||||||
import { message } from "antd"
|
import { useSingleOperations, useBatchHelpers } from "./asset-operations/useSingleOperations"
|
||||||
import {
|
import {
|
||||||
deleteAsset,
|
useBatchDelete,
|
||||||
getAssetDiagnosis,
|
useBatchTag,
|
||||||
batchDeleteAssets,
|
useBatchClassify,
|
||||||
batchTagAssets,
|
useBatchMark,
|
||||||
batchClassifyAssets,
|
} from "./asset-operations/batchOperations"
|
||||||
batchMarkAssets,
|
import type { BatchOperationResult } from "@/api/assets"
|
||||||
type BatchOperationResult,
|
|
||||||
} from "@/api/assets"
|
|
||||||
import type { AssetItem } from "../types"
|
|
||||||
import type { SmartViewType } from "../components/BatchMarkModal"
|
import type { SmartViewType } from "../components/BatchMarkModal"
|
||||||
|
|
||||||
/**
|
|
||||||
* 素材操作 Hook
|
|
||||||
* 封装素材的诊断、删除、批量打标签、批量改分类、批量智能标记等操作,
|
|
||||||
* 以及相关弹窗和结果展示的状态管理
|
|
||||||
*/
|
|
||||||
interface UseAssetOperationsProps {
|
interface UseAssetOperationsProps {
|
||||||
selectedIds: Set<string>
|
selectedIds: Set<string>
|
||||||
setSelectedIds: (ids: Set<string>) => void
|
setSelectedIds: (ids: Set<string>) => void
|
||||||
@@ -29,78 +27,35 @@ export function useAssetOperations({ selectedIds, setSelectedIds }: UseAssetOper
|
|||||||
/* ── 诊断状态 ── */
|
/* ── 诊断状态 ── */
|
||||||
const [diagnosingId, setDiagnosingId] = useState<string | null>(null)
|
const [diagnosingId, setDiagnosingId] = useState<string | null>(null)
|
||||||
|
|
||||||
/* ── 批量操作弹窗状态 ── */
|
|
||||||
const [tagModalOpen, setTagModalOpen] = useState(false)
|
|
||||||
const [classifyModalOpen, setClassifyModalOpen] = useState(false)
|
|
||||||
const [markModalOpen, setMarkModalOpen] = useState(false)
|
|
||||||
const [resultDrawerOpen, setResultDrawerOpen] = useState(false)
|
|
||||||
|
|
||||||
/* ── 批量打标签表单 ── */
|
|
||||||
const [batchTagInput, setBatchTagInput] = useState("")
|
|
||||||
const [batchTags, setBatchTags] = useState<string[]>([])
|
|
||||||
const [tagMode, setTagMode] = useState<"add" | "replace">("add")
|
|
||||||
|
|
||||||
/* ── 批量改分类表单 ── */
|
|
||||||
const [batchCategory, setBatchCategory] = useState("")
|
|
||||||
|
|
||||||
/* ── 批量智能标记表单 ── */
|
|
||||||
const [batchSmartView, setBatchSmartView] = useState<SmartViewType>("recommended")
|
|
||||||
|
|
||||||
/* ── 操作结果 ── */
|
/* ── 操作结果 ── */
|
||||||
|
const [resultDrawerOpen, setResultDrawerOpen] = useState(false)
|
||||||
const [operationResult, setOperationResult] = useState<BatchOperationResult | null>(null)
|
const [operationResult, setOperationResult] = useState<BatchOperationResult | null>(null)
|
||||||
const [operationTitle, setOperationTitle] = useState("")
|
const [operationTitle, setOperationTitle] = useState("")
|
||||||
|
|
||||||
/* ── 批量操作 loading ── */
|
/* ── 单个操作 ── */
|
||||||
const [batchLoading, setBatchLoading] = useState(false)
|
const { handleDiagnose: handleDiagnoseRaw, handleSingleDelete } = useSingleOperations({
|
||||||
|
selectedIds,
|
||||||
|
setSelectedIds,
|
||||||
|
})
|
||||||
|
|
||||||
/* ── 刷新数据辅助函数 ── */
|
// 包装一下,加上 diagnosingId 状态
|
||||||
const invalidateAssets = useCallback(() => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["assets"] })
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] })
|
|
||||||
}, [queryClient])
|
|
||||||
|
|
||||||
/* ── 诊断 ── */
|
|
||||||
const handleDiagnose = useCallback(
|
const handleDiagnose = useCallback(
|
||||||
async (asset: AssetItem) => {
|
async (asset: Parameters<typeof handleDiagnoseRaw>[0]) => {
|
||||||
setDiagnosingId(asset.id)
|
setDiagnosingId(asset.id)
|
||||||
try {
|
try {
|
||||||
const result = await getAssetDiagnosis(asset.id)
|
await handleDiagnoseRaw(asset)
|
||||||
const score = result.readiness_score ?? "-"
|
|
||||||
message.success(`"${asset.name}" 诊断完成,就绪分:${score}`)
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["assets"] })
|
|
||||||
} catch {
|
|
||||||
message.error(`"${asset.name}" 诊断失败`)
|
|
||||||
} finally {
|
} finally {
|
||||||
setDiagnosingId(null)
|
setDiagnosingId(null)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[queryClient],
|
[handleDiagnoseRaw],
|
||||||
)
|
)
|
||||||
|
|
||||||
/* ── 单个素材删除 ── */
|
/* ── 批量操作辅助 ── */
|
||||||
const handleSingleDelete = useCallback(
|
const { invalidateAssets } = useBatchHelpers({ queryClient, setSelectedIds })
|
||||||
async (assetId: string) => {
|
|
||||||
try {
|
|
||||||
await deleteAsset(assetId)
|
|
||||||
invalidateAssets()
|
|
||||||
// 从选中集合中移除
|
|
||||||
setSelectedIds(
|
|
||||||
(() => {
|
|
||||||
const next = new Set(selectedIds)
|
|
||||||
next.delete(assetId)
|
|
||||||
return next
|
|
||||||
})(),
|
|
||||||
)
|
|
||||||
message.success("素材已删除")
|
|
||||||
} catch {
|
|
||||||
message.error("删除失败,请重试")
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[invalidateAssets, selectedIds, setSelectedIds],
|
|
||||||
)
|
|
||||||
|
|
||||||
/* ── 显示操作结果 ── */
|
// 包装 showResult 适配子模块的接口
|
||||||
const showOperationResult = useCallback(
|
const showResult = useCallback(
|
||||||
(result: BatchOperationResult, title: string, clearSelection = true) => {
|
(result: BatchOperationResult, title: string, clearSelection = true) => {
|
||||||
setOperationResult(result)
|
setOperationResult(result)
|
||||||
setOperationTitle(title)
|
setOperationTitle(title)
|
||||||
@@ -110,147 +65,23 @@ export function useAssetOperations({ selectedIds, setSelectedIds }: UseAssetOper
|
|||||||
[setSelectedIds],
|
[setSelectedIds],
|
||||||
)
|
)
|
||||||
|
|
||||||
/* ── 批量删除 ── */
|
/* ── 批量操作 ── */
|
||||||
const handleBatchDelete = useCallback(async () => {
|
const { batchLoading: deleteLoading, handleBatchDelete } = useBatchDelete({
|
||||||
const ids = Array.from(selectedIds)
|
selectedIds,
|
||||||
setBatchLoading(true)
|
invalidateAssets,
|
||||||
try {
|
showResult,
|
||||||
const result = await batchDeleteAssets(ids)
|
})
|
||||||
invalidateAssets()
|
|
||||||
showOperationResult(result, "批量删除")
|
|
||||||
if (result.failure_count === 0) {
|
|
||||||
message.success(`成功删除 ${result.success_count} 个素材`)
|
|
||||||
} else {
|
|
||||||
message.warning(
|
|
||||||
`删除完成:成功 ${result.success_count} 个,失败 ${result.failure_count} 个`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
message.error("批量删除失败,请重试")
|
|
||||||
} finally {
|
|
||||||
setBatchLoading(false)
|
|
||||||
}
|
|
||||||
}, [selectedIds, invalidateAssets, showOperationResult])
|
|
||||||
|
|
||||||
/* ── 批量打标签 ── */
|
const tagResult = useBatchTag({ selectedIds, queryClient, showResult })
|
||||||
const handleBatchTag = useCallback(async () => {
|
const classifyResult = useBatchClassify({ selectedIds, queryClient, showResult })
|
||||||
if (batchTags.length === 0) {
|
const markResult = useBatchMark({ selectedIds, queryClient, showResult })
|
||||||
message.warning("请至少输入一个标签")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const ids = Array.from(selectedIds)
|
|
||||||
setBatchLoading(true)
|
|
||||||
try {
|
|
||||||
const result = await batchTagAssets({
|
|
||||||
asset_ids: ids,
|
|
||||||
tags: batchTags,
|
|
||||||
mode: tagMode,
|
|
||||||
})
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["assets"] })
|
|
||||||
showOperationResult(result, "批量打标签")
|
|
||||||
setTagModalOpen(false)
|
|
||||||
setBatchTags([])
|
|
||||||
setBatchTagInput("")
|
|
||||||
setTagMode("add")
|
|
||||||
if (result.failure_count === 0) {
|
|
||||||
message.success(`成功为 ${result.success_count} 个素材打标签`)
|
|
||||||
} else {
|
|
||||||
message.warning(
|
|
||||||
`打标签完成:成功 ${result.success_count} 个,失败 ${result.failure_count} 个`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
message.error("批量打标签失败,请重试")
|
|
||||||
} finally {
|
|
||||||
setBatchLoading(false)
|
|
||||||
}
|
|
||||||
}, [batchTags, selectedIds, tagMode, queryClient, showOperationResult])
|
|
||||||
|
|
||||||
/* ── 标签输入处理 ── */
|
// 取任一批量操作的 loading 状态(任意一个在加载都算加载中)
|
||||||
const handleTagInputKeyDown = useCallback(
|
const batchLoading =
|
||||||
(e: React.KeyboardEvent) => {
|
deleteLoading ||
|
||||||
if (e.key === "Enter" && batchTagInput.trim()) {
|
tagResult.batchLoading ||
|
||||||
e.preventDefault()
|
classifyResult.batchLoading ||
|
||||||
const tag = batchTagInput.trim()
|
markResult.batchLoading
|
||||||
if (!batchTags.includes(tag)) {
|
|
||||||
setBatchTags([...batchTags, tag])
|
|
||||||
}
|
|
||||||
setBatchTagInput("")
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[batchTagInput, batchTags],
|
|
||||||
)
|
|
||||||
|
|
||||||
const removeBatchTag = useCallback(
|
|
||||||
(tag: string) => {
|
|
||||||
setBatchTags(batchTags.filter((t) => t !== tag))
|
|
||||||
},
|
|
||||||
[batchTags],
|
|
||||||
)
|
|
||||||
|
|
||||||
/* ── 批量改分类 ── */
|
|
||||||
const handleBatchClassify = useCallback(async () => {
|
|
||||||
if (!batchCategory) {
|
|
||||||
message.warning("请选择分类")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const ids = Array.from(selectedIds)
|
|
||||||
setBatchLoading(true)
|
|
||||||
try {
|
|
||||||
const result = await batchClassifyAssets({
|
|
||||||
asset_ids: ids,
|
|
||||||
category: batchCategory,
|
|
||||||
})
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["assets"] })
|
|
||||||
showOperationResult(result, "批量改分类")
|
|
||||||
setClassifyModalOpen(false)
|
|
||||||
setBatchCategory("")
|
|
||||||
if (result.failure_count === 0) {
|
|
||||||
message.success(`成功将 ${result.success_count} 个素材改为「${batchCategory}」`)
|
|
||||||
} else {
|
|
||||||
message.warning(
|
|
||||||
`改分类完成:成功 ${result.success_count} 个,失败 ${result.failure_count} 个`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
message.error("批量改分类失败,请重试")
|
|
||||||
} finally {
|
|
||||||
setBatchLoading(false)
|
|
||||||
}
|
|
||||||
}, [batchCategory, selectedIds, queryClient, showOperationResult])
|
|
||||||
|
|
||||||
/* ── 批量智能标记 ── */
|
|
||||||
const handleBatchMark = useCallback(async () => {
|
|
||||||
const ids = Array.from(selectedIds)
|
|
||||||
setBatchLoading(true)
|
|
||||||
try {
|
|
||||||
const result = await batchMarkAssets({
|
|
||||||
asset_ids: ids,
|
|
||||||
smart_view: batchSmartView,
|
|
||||||
})
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["assets"] })
|
|
||||||
showOperationResult(result, "批量智能标记")
|
|
||||||
setMarkModalOpen(false)
|
|
||||||
const labelMap: Record<SmartViewType, string> = {
|
|
||||||
recommended: "推荐",
|
|
||||||
caution: "慎用",
|
|
||||||
high_risk: "高风险",
|
|
||||||
}
|
|
||||||
if (result.failure_count === 0) {
|
|
||||||
message.success(
|
|
||||||
`成功将 ${result.success_count} 个素材标记为「${labelMap[batchSmartView]}」`,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
message.warning(
|
|
||||||
`智能标记完成:成功 ${result.success_count} 个,失败 ${result.failure_count} 个`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
message.error("批量智能标记失败,请重试")
|
|
||||||
} finally {
|
|
||||||
setBatchLoading(false)
|
|
||||||
}
|
|
||||||
}, [batchSmartView, selectedIds, queryClient, showOperationResult])
|
|
||||||
|
|
||||||
/* ── 关闭结果 Drawer ── */
|
/* ── 关闭结果 Drawer ── */
|
||||||
const handleResultDrawerClose = useCallback(() => {
|
const handleResultDrawerClose = useCallback(() => {
|
||||||
@@ -267,29 +98,29 @@ export function useAssetOperations({ selectedIds, setSelectedIds }: UseAssetOper
|
|||||||
// 批量操作 loading
|
// 批量操作 loading
|
||||||
batchLoading,
|
batchLoading,
|
||||||
// 批量打标签
|
// 批量打标签
|
||||||
tagModalOpen,
|
tagModalOpen: tagResult.tagModalOpen,
|
||||||
setTagModalOpen,
|
setTagModalOpen: tagResult.setTagModalOpen,
|
||||||
batchTagInput,
|
batchTagInput: tagResult.batchTagInput,
|
||||||
setBatchTagInput,
|
setBatchTagInput: tagResult.setBatchTagInput,
|
||||||
batchTags,
|
batchTags: tagResult.batchTags,
|
||||||
setBatchTags,
|
setBatchTags: tagResult.setBatchTags,
|
||||||
tagMode,
|
tagMode: tagResult.tagMode,
|
||||||
setTagMode,
|
setTagMode: tagResult.setTagMode,
|
||||||
handleBatchTag,
|
handleBatchTag: tagResult.handleBatchTag,
|
||||||
handleTagInputKeyDown,
|
handleTagInputKeyDown: tagResult.handleTagInputKeyDown,
|
||||||
removeBatchTag,
|
removeBatchTag: tagResult.removeBatchTag,
|
||||||
// 批量改分类
|
// 批量改分类
|
||||||
classifyModalOpen,
|
classifyModalOpen: classifyResult.classifyModalOpen,
|
||||||
setClassifyModalOpen,
|
setClassifyModalOpen: classifyResult.setClassifyModalOpen,
|
||||||
batchCategory,
|
batchCategory: classifyResult.batchCategory,
|
||||||
setBatchCategory,
|
setBatchCategory: classifyResult.setBatchCategory,
|
||||||
handleBatchClassify,
|
handleBatchClassify: classifyResult.handleBatchClassify,
|
||||||
// 批量智能标记
|
// 批量智能标记
|
||||||
markModalOpen,
|
markModalOpen: markResult.markModalOpen,
|
||||||
setMarkModalOpen,
|
setMarkModalOpen: markResult.setMarkModalOpen,
|
||||||
batchSmartView,
|
batchSmartView: markResult.batchSmartView as SmartViewType,
|
||||||
setBatchSmartView,
|
setBatchSmartView: markResult.setBatchSmartView,
|
||||||
handleBatchMark,
|
handleBatchMark: markResult.handleBatchMark,
|
||||||
// 批量删除
|
// 批量删除
|
||||||
handleBatchDelete,
|
handleBatchDelete,
|
||||||
// 操作结果
|
// 操作结果
|
||||||
|
|||||||
Regular → Executable
+23
-239
@@ -3,137 +3,29 @@
|
|||||||
* 风险评估 + 基本信息 + 检测项列表 + 匹配片段
|
* 风险评估 + 基本信息 + 检测项列表 + 匹配片段
|
||||||
* 零 antd 依赖
|
* 零 antd 依赖
|
||||||
*/
|
*/
|
||||||
import React, { useState, useCallback } from "react"
|
import React from "react"
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
import { Button, Tag } from "@/components/ui"
|
||||||
import { Button, Tag, Tooltip } from "@/components/ui"
|
|
||||||
import { useParams, useNavigate } from "react-router-dom"
|
|
||||||
import { getDuplicationDetail, retryDuplication, type DuplicateSegment } from "@/api/duplication"
|
|
||||||
import "./duplication.css"
|
|
||||||
import PageHead from "@/components/layout/PageHead"
|
import PageHead from "@/components/layout/PageHead"
|
||||||
|
import { RiskCard } from "./components/RiskCard"
|
||||||
/** 格式化时间(秒 → mm:ss) */
|
import { InfoCard } from "./components/InfoCard"
|
||||||
const formatTime = (seconds: number) => {
|
import { SegmentsSection } from "./components/SegmentsSection"
|
||||||
const m = Math.floor(seconds / 60)
|
import { useDuplicationDetail } from "./hooks/useDuplicationDetail"
|
||||||
const s = Math.floor(seconds % 60)
|
import { RISK_TAG_VARIANT, RISK_LABEL } from "./constants"
|
||||||
return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`
|
import { formatSize, formatDuration } from "./utils"
|
||||||
}
|
import "./duplication.css"
|
||||||
|
|
||||||
/** 格式化文件大小 */
|
|
||||||
const formatSize = (bytes: number) => {
|
|
||||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`
|
|
||||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
|
||||||
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 格式化时长 */
|
|
||||||
const formatDuration = (seconds?: number) => {
|
|
||||||
if (!seconds) return "-"
|
|
||||||
const totalSec = Math.round(seconds)
|
|
||||||
const m = Math.floor(totalSec / 60)
|
|
||||||
const s = totalSec % 60
|
|
||||||
return m > 0 ? `${m}分${s}秒` : `${s}秒`
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 根据查重率获取风险等级 */
|
|
||||||
const getRiskLevel = (rate?: number): "low" | "medium" | "high" => {
|
|
||||||
if (rate === undefined) return "low"
|
|
||||||
if (rate <= 10) return "low"
|
|
||||||
if (rate <= 30) return "medium"
|
|
||||||
return "high"
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 风险等级描述 */
|
|
||||||
const RISK_DESC: Record<string, string> = {
|
|
||||||
low: "查重率较低,内容原创度高",
|
|
||||||
medium: "存在一定重复,建议修改部分片段",
|
|
||||||
high: "重复率较高,建议大幅修改或替换",
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 风险等级标签变体 */
|
|
||||||
const RISK_TAG_VARIANT: Record<string, "success" | "warning" | "error"> = {
|
|
||||||
low: "success",
|
|
||||||
medium: "warning",
|
|
||||||
high: "error",
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 风险等级文字 */
|
|
||||||
const RISK_LABEL: Record<string, string> = {
|
|
||||||
low: "低风险",
|
|
||||||
medium: "中风险",
|
|
||||||
high: "高风险",
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 简易 toast */
|
|
||||||
interface ToastState {
|
|
||||||
message: string
|
|
||||||
type: "success" | "error" | "warning"
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 单个重复片段卡片 */
|
|
||||||
const SegmentCard: React.FC<{ segment: DuplicateSegment; index: number }> = ({
|
|
||||||
segment,
|
|
||||||
index,
|
|
||||||
}) => {
|
|
||||||
const sourceDuration = segment.source_end - segment.source_start
|
|
||||||
const matchedDuration = segment.matched_end - segment.matched_start
|
|
||||||
const riskLevel = segment.similarity >= 90 ? "high" : segment.similarity >= 70 ? "medium" : "low"
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="dup-check-item">
|
|
||||||
<div className="dup-check-icon">🎬</div>
|
|
||||||
<div className="dup-check-body">
|
|
||||||
<h4>
|
|
||||||
片段 {index + 1}:{segment.matched_video_name}
|
|
||||||
</h4>
|
|
||||||
<p>
|
|
||||||
原始 {formatTime(segment.source_start)} - {formatTime(segment.source_end)}(
|
|
||||||
{sourceDuration.toFixed(0)}s)→ 匹配 {formatTime(segment.matched_start)} -{" "}
|
|
||||||
{formatTime(segment.matched_end)}({matchedDuration.toFixed(0)}s)
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className={`dup-check-bar`}>
|
|
||||||
<div
|
|
||||||
className={`dup-check-bar-fill ${riskLevel}`}
|
|
||||||
style={{ width: `${Math.min(segment.similarity, 100)}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<span className={`dup-check-value ${riskLevel}`}>{segment.similarity.toFixed(1)}%</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const DuplicationDetail: React.FC = () => {
|
const DuplicationDetail: React.FC = () => {
|
||||||
const { id } = useParams<{ id: string }>()
|
|
||||||
const navigate = useNavigate()
|
|
||||||
const queryClient = useQueryClient()
|
|
||||||
const [toast, setToast] = useState<ToastState | null>(null)
|
|
||||||
|
|
||||||
const showToast = useCallback((message: string, type: "success" | "error" | "warning") => {
|
|
||||||
setToast({ message, type })
|
|
||||||
setTimeout(() => setToast(null), 3000)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: detail,
|
detail,
|
||||||
isLoading,
|
isLoading,
|
||||||
isError,
|
isError,
|
||||||
} = useQuery({
|
toast,
|
||||||
queryKey: ["duplication-detail", id],
|
riskLevel,
|
||||||
queryFn: () => getDuplicationDetail(id!),
|
similarityPercent,
|
||||||
enabled: !!id,
|
handleRetry,
|
||||||
})
|
handleDownloadReport,
|
||||||
|
handleBack,
|
||||||
// 重新查重
|
} = useDuplicationDetail()
|
||||||
const retryMutation = useMutation({
|
|
||||||
mutationFn: retryDuplication,
|
|
||||||
onSuccess: () => {
|
|
||||||
showToast("已重新提交查重", "success")
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["duplication-detail", id] })
|
|
||||||
},
|
|
||||||
onError: () => {
|
|
||||||
showToast("重新查重失败", "error")
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -155,7 +47,7 @@ const DuplicationDetail: React.FC = () => {
|
|||||||
<Button
|
<Button
|
||||||
buttonType="primary"
|
buttonType="primary"
|
||||||
buttonSize="md"
|
buttonSize="md"
|
||||||
onClick={() => navigate("/app/duplication/results")}
|
onClick={handleBack}
|
||||||
style={{ marginTop: 16 }}
|
style={{ marginTop: 16 }}
|
||||||
>
|
>
|
||||||
返回列表
|
返回列表
|
||||||
@@ -165,10 +57,6 @@ const DuplicationDetail: React.FC = () => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const riskLevel = getRiskLevel(detail.duplicate_rate)
|
|
||||||
const similarityPercent =
|
|
||||||
detail.duplicate_rate !== undefined ? detail.duplicate_rate.toFixed(1) : "—"
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="dup-page">
|
<div className="dup-page">
|
||||||
{/* Toast */}
|
{/* Toast */}
|
||||||
@@ -194,21 +82,11 @@ const DuplicationDetail: React.FC = () => {
|
|||||||
}
|
}
|
||||||
actions={
|
actions={
|
||||||
<div className="dup-detail-actions" style={{ display: "flex", gap: 8 }}>
|
<div className="dup-detail-actions" style={{ display: "flex", gap: 8 }}>
|
||||||
<Button
|
<Button buttonType="secondary" buttonSize="md" onClick={handleDownloadReport}>
|
||||||
buttonType="secondary"
|
|
||||||
buttonSize="md"
|
|
||||||
onClick={() => {
|
|
||||||
showToast("报告下载功能开发中", "warning")
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
📥 下载报告
|
📥 下载报告
|
||||||
</Button>
|
</Button>
|
||||||
{detail.status === "failed" && (
|
{detail.status === "failed" && (
|
||||||
<Button
|
<Button buttonType="primary" buttonSize="md" onClick={handleRetry}>
|
||||||
buttonType="primary"
|
|
||||||
buttonSize="md"
|
|
||||||
onClick={() => retryMutation.mutate(detail.id)}
|
|
||||||
>
|
|
||||||
🔄 重新查重
|
🔄 重新查重
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
@@ -218,103 +96,9 @@ const DuplicationDetail: React.FC = () => {
|
|||||||
|
|
||||||
{/* 内容网格 */}
|
{/* 内容网格 */}
|
||||||
<div className="dup-detail-grid">
|
<div className="dup-detail-grid">
|
||||||
{/* 风险评估卡片 */}
|
<RiskCard riskLevel={riskLevel} similarityPercent={similarityPercent} />
|
||||||
<div className="dup-risk-card">
|
<InfoCard detail={detail} />
|
||||||
<h3>📊 风险评估</h3>
|
<SegmentsSection segments={detail.segments} />
|
||||||
<div className={`dup-risk-circle ${riskLevel}`}>
|
|
||||||
<span className="dup-risk-value">{similarityPercent}%</span>
|
|
||||||
<span className="dup-risk-label">查重率</span>
|
|
||||||
</div>
|
|
||||||
<p className="dup-risk-desc">{RISK_DESC[riskLevel]}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 基本信息卡片 */}
|
|
||||||
<div className="dup-info-detail-card">
|
|
||||||
<h3>📋 基本信息</h3>
|
|
||||||
<div className="dup-info-rows">
|
|
||||||
<div className="dup-info-row">
|
|
||||||
<span className="dup-info-row-label">文件名</span>
|
|
||||||
<Tooltip title={detail.filename}>
|
|
||||||
<span
|
|
||||||
className="dup-info-row-value"
|
|
||||||
style={{
|
|
||||||
maxWidth: 200,
|
|
||||||
overflow: "hidden",
|
|
||||||
textOverflow: "ellipsis",
|
|
||||||
whiteSpace: "nowrap",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{detail.filename}
|
|
||||||
</span>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
<div className="dup-info-row">
|
|
||||||
<span className="dup-info-row-label">文件大小</span>
|
|
||||||
<span className="dup-info-row-value">{formatSize(detail.file_size)}</span>
|
|
||||||
</div>
|
|
||||||
<div className="dup-info-row">
|
|
||||||
<span className="dup-info-row-label">视频时长</span>
|
|
||||||
<span className="dup-info-row-value">{formatDuration(detail.duration_seconds)}</span>
|
|
||||||
</div>
|
|
||||||
<div className="dup-info-row">
|
|
||||||
<span className="dup-info-row-label">查重状态</span>
|
|
||||||
<span className="dup-info-row-value">
|
|
||||||
<Tag
|
|
||||||
variant={
|
|
||||||
detail.status === "completed"
|
|
||||||
? "success"
|
|
||||||
: detail.status === "failed"
|
|
||||||
? "error"
|
|
||||||
: detail.status === "processing"
|
|
||||||
? "warning"
|
|
||||||
: "info"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{detail.status === "completed"
|
|
||||||
? "✅ 已完成"
|
|
||||||
: detail.status === "failed"
|
|
||||||
? "❌ 失败"
|
|
||||||
: detail.status === "processing"
|
|
||||||
? "🔄 查重中"
|
|
||||||
: "⏳ 等待中"}
|
|
||||||
</Tag>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="dup-info-row">
|
|
||||||
<span className="dup-info-row-label">重复片段数</span>
|
|
||||||
<span className="dup-info-row-value">{detail.duplicate_count ?? 0} 个</span>
|
|
||||||
</div>
|
|
||||||
<div className="dup-info-row">
|
|
||||||
<span className="dup-info-row-label">提交时间</span>
|
|
||||||
<span className="dup-info-row-value">
|
|
||||||
{new Date(detail.created_at).toLocaleString("zh-CN")}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 检测项列表 */}
|
|
||||||
<div className="dup-checks-section">
|
|
||||||
<h3>
|
|
||||||
🔍 重复片段详情
|
|
||||||
<Tag variant="primary" style={{ marginLeft: 8 }}>
|
|
||||||
{detail.segments?.length ?? 0} 个片段
|
|
||||||
</Tag>
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
{detail.segments && detail.segments.length > 0 ? (
|
|
||||||
<div className="dup-checks-list">
|
|
||||||
{detail.segments.map((segment, index) => (
|
|
||||||
<SegmentCard key={segment.id} segment={segment} index={index} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="dup-results-empty" style={{ padding: "32px 0" }}>
|
|
||||||
<div className="dup-results-empty-icon">🎉</div>
|
|
||||||
<p>未发现重复片段,内容原创度很高</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import React from "react"
|
||||||
|
import { Tag, Tooltip } from "@/components/ui"
|
||||||
|
import { formatSize, formatDuration } from "../utils"
|
||||||
|
import type { DuplicationDetail } from "@/api/duplication"
|
||||||
|
|
||||||
|
interface InfoCardProps {
|
||||||
|
detail: DuplicationDetail
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 基本信息卡片
|
||||||
|
*/
|
||||||
|
export const InfoCard: React.FC<InfoCardProps> = ({ detail }) => {
|
||||||
|
const statusMap: Record<string, { text: string; variant: string }> = {
|
||||||
|
completed: { text: "✅ 已完成", variant: "success" },
|
||||||
|
failed: { text: "❌ 失败", variant: "error" },
|
||||||
|
processing: { text: "🔄 查重中", variant: "warning" },
|
||||||
|
pending: { text: "⏳ 等待中", variant: "info" },
|
||||||
|
}
|
||||||
|
const status = statusMap[detail.status] || {
|
||||||
|
text: detail.status,
|
||||||
|
variant: "info",
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="dup-info-detail-card">
|
||||||
|
<h3>📋 基本信息</h3>
|
||||||
|
<div className="dup-info-rows">
|
||||||
|
<div className="dup-info-row">
|
||||||
|
<span className="dup-info-row-label">文件名</span>
|
||||||
|
<Tooltip title={detail.filename}>
|
||||||
|
<span
|
||||||
|
className="dup-info-row-value"
|
||||||
|
style={{
|
||||||
|
maxWidth: 200,
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{detail.filename}
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
<div className="dup-info-row">
|
||||||
|
<span className="dup-info-row-label">文件大小</span>
|
||||||
|
<span className="dup-info-row-value">{formatSize(detail.file_size)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="dup-info-row">
|
||||||
|
<span className="dup-info-row-label">视频时长</span>
|
||||||
|
<span className="dup-info-row-value">{formatDuration(detail.duration_seconds)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="dup-info-row">
|
||||||
|
<span className="dup-info-row-label">查重状态</span>
|
||||||
|
<span className="dup-info-row-value">
|
||||||
|
<Tag variant={status.variant as "success"}>{status.text}</Tag>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="dup-info-row">
|
||||||
|
<span className="dup-info-row-label">重复片段数</span>
|
||||||
|
<span className="dup-info-row-value">{detail.duplicate_count ?? 0} 个</span>
|
||||||
|
</div>
|
||||||
|
<div className="dup-info-row">
|
||||||
|
<span className="dup-info-row-label">提交时间</span>
|
||||||
|
<span className="dup-info-row-value">
|
||||||
|
{new Date(detail.created_at).toLocaleString("zh-CN")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import React from "react"
|
||||||
|
import { RISK_DESC } from "../constants"
|
||||||
|
|
||||||
|
interface RiskCardProps {
|
||||||
|
riskLevel: string
|
||||||
|
similarityPercent: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 风险评估卡片
|
||||||
|
*/
|
||||||
|
export const RiskCard: React.FC<RiskCardProps> = ({ riskLevel, similarityPercent }) => (
|
||||||
|
<div className="dup-risk-card">
|
||||||
|
<h3>📊 风险评估</h3>
|
||||||
|
<div className={`dup-risk-circle ${riskLevel}`}>
|
||||||
|
<span className="dup-risk-value">{similarityPercent}%</span>
|
||||||
|
<span className="dup-risk-label">查重率</span>
|
||||||
|
</div>
|
||||||
|
<p className="dup-risk-desc">{RISK_DESC[riskLevel]}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import React from "react"
|
||||||
|
import type { DuplicateSegment } from "@/api/duplication"
|
||||||
|
import { formatTime } from "../utils"
|
||||||
|
|
||||||
|
interface SegmentCardProps {
|
||||||
|
segment: DuplicateSegment
|
||||||
|
index: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 单个重复片段卡片
|
||||||
|
*/
|
||||||
|
export const SegmentCard: React.FC<SegmentCardProps> = ({ segment, index }) => {
|
||||||
|
const sourceDuration = segment.source_end - segment.source_start
|
||||||
|
const matchedDuration = segment.matched_end - segment.matched_start
|
||||||
|
const riskLevel = segment.similarity >= 90 ? "high" : segment.similarity >= 70 ? "medium" : "low"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="dup-check-item">
|
||||||
|
<div className="dup-check-icon">🎬</div>
|
||||||
|
<div className="dup-check-body">
|
||||||
|
<h4>
|
||||||
|
片段 {index + 1}:{segment.matched_video_name}
|
||||||
|
</h4>
|
||||||
|
<p>
|
||||||
|
原始 {formatTime(segment.source_start)} - {formatTime(segment.source_end)}(
|
||||||
|
{sourceDuration.toFixed(0)}s)→ 匹配 {formatTime(segment.matched_start)} -{" "}
|
||||||
|
{formatTime(segment.matched_end)}({matchedDuration.toFixed(0)}s)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className={`dup-check-bar`}>
|
||||||
|
<div
|
||||||
|
className={`dup-check-bar-fill ${riskLevel}`}
|
||||||
|
style={{ width: `${Math.min(segment.similarity, 100)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className={`dup-check-value ${riskLevel}`}>{segment.similarity.toFixed(1)}%</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import React from "react"
|
||||||
|
import { Tag } from "@/components/ui"
|
||||||
|
import type { DuplicateSegment } from "@/api/duplication"
|
||||||
|
import { SegmentCard } from "./SegmentCard"
|
||||||
|
|
||||||
|
interface SegmentsSectionProps {
|
||||||
|
segments?: DuplicateSegment[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重复片段列表区域
|
||||||
|
*/
|
||||||
|
export const SegmentsSection: React.FC<SegmentsSectionProps> = ({ segments = [] }) => (
|
||||||
|
<div className="dup-checks-section">
|
||||||
|
<h3>
|
||||||
|
🔍 重复片段详情
|
||||||
|
<Tag variant="primary" style={{ marginLeft: 8 }}>
|
||||||
|
{segments.length} 个片段
|
||||||
|
</Tag>
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{segments.length > 0 ? (
|
||||||
|
<div className="dup-checks-list">
|
||||||
|
{segments.map((segment, index) => (
|
||||||
|
<SegmentCard key={segment.id} segment={segment} index={index} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="dup-results-empty" style={{ padding: "32px 0" }}>
|
||||||
|
<div className="dup-results-empty-icon">🎉</div>
|
||||||
|
<p>未发现重复片段,内容原创度很高</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
@@ -16,7 +16,28 @@ export const STATUS_CONFIG: Record<
|
|||||||
failed: { variant: "error", text: "失败", icon: "❌" },
|
failed: { variant: "error", text: "失败", icon: "❌" },
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 风险等级标签 */
|
/** 风险等级描述 */
|
||||||
|
export const RISK_DESC: Record<string, string> = {
|
||||||
|
low: "查重率较低,内容原创度高",
|
||||||
|
medium: "存在一定重复,建议修改部分片段",
|
||||||
|
high: "重复率较高,建议大幅修改或替换",
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 风险等级标签变体 */
|
||||||
|
export const RISK_TAG_VARIANT: Record<string, "success" | "warning" | "error"> = {
|
||||||
|
low: "success",
|
||||||
|
medium: "warning",
|
||||||
|
high: "error",
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 风险等级文字(详情页用) */
|
||||||
|
export const RISK_LABEL: Record<string, string> = {
|
||||||
|
low: "低风险",
|
||||||
|
medium: "中风险",
|
||||||
|
high: "高风险",
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 风险等级标签(列表页用) */
|
||||||
export const RISK_LABELS: Record<string, string> = {
|
export const RISK_LABELS: Record<string, string> = {
|
||||||
low: "低风险",
|
low: "低风险",
|
||||||
medium: "中风险",
|
medium: "中风险",
|
||||||
@@ -29,3 +50,9 @@ export const FILTER_OPTIONS: { key: RiskFilter; label: string }[] = [
|
|||||||
{ key: "medium", label: "中风险" },
|
{ key: "medium", label: "中风险" },
|
||||||
{ key: "high", label: "高风险" },
|
{ key: "high", label: "高风险" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
/** Toast 类型 */
|
||||||
|
export interface ToastState {
|
||||||
|
message: string
|
||||||
|
type: "success" | "error" | "warning"
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { useState, useCallback } from "react"
|
||||||
|
import { useParams, useNavigate } from "react-router-dom"
|
||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
||||||
|
import { getDuplicationDetail, retryDuplication } from "@/api/duplication"
|
||||||
|
import type { ToastState } from "../constants"
|
||||||
|
import { getRiskLevel } from "../utils"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查重详情业务 Hook
|
||||||
|
*/
|
||||||
|
export const useDuplicationDetail = () => {
|
||||||
|
const { id } = useParams<{ id: string }>()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const [toast, setToast] = useState<ToastState | null>(null)
|
||||||
|
|
||||||
|
const showToast = useCallback((message: string, type: "success" | "error" | "warning") => {
|
||||||
|
setToast({ message, type })
|
||||||
|
setTimeout(() => setToast(null), 3000)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: detail,
|
||||||
|
isLoading,
|
||||||
|
isError,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ["duplication-detail", id],
|
||||||
|
queryFn: () => getDuplicationDetail(id!),
|
||||||
|
enabled: !!id,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 重新查重
|
||||||
|
const retryMutation = useMutation({
|
||||||
|
mutationFn: retryDuplication,
|
||||||
|
onSuccess: () => {
|
||||||
|
showToast("已重新提交查重", "success")
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["duplication-detail", id] })
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
showToast("重新查重失败", "error")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleRetry = useCallback(() => {
|
||||||
|
if (!detail) return
|
||||||
|
retryMutation.mutate(detail.id)
|
||||||
|
}, [detail, retryMutation])
|
||||||
|
|
||||||
|
const handleDownloadReport = useCallback(() => {
|
||||||
|
showToast("报告下载功能开发中", "warning")
|
||||||
|
}, [showToast])
|
||||||
|
|
||||||
|
const handleBack = useCallback(() => {
|
||||||
|
navigate("/app/duplication/results")
|
||||||
|
}, [navigate])
|
||||||
|
|
||||||
|
const riskLevel = detail ? getRiskLevel(detail.duplicate_rate) : "low"
|
||||||
|
const similarityPercent =
|
||||||
|
detail?.duplicate_rate !== undefined ? detail.duplicate_rate.toFixed(1) : "—"
|
||||||
|
|
||||||
|
return {
|
||||||
|
// 数据
|
||||||
|
detail,
|
||||||
|
isLoading,
|
||||||
|
isError,
|
||||||
|
// 状态
|
||||||
|
toast,
|
||||||
|
riskLevel,
|
||||||
|
similarityPercent,
|
||||||
|
retryLoading: retryMutation.isPending,
|
||||||
|
// 操作
|
||||||
|
showToast,
|
||||||
|
handleRetry,
|
||||||
|
handleDownloadReport,
|
||||||
|
handleBack,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,13 @@ export const getRiskLevel = (rate?: number): "low" | "medium" | "high" => {
|
|||||||
return "high"
|
return "high"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 格式化时间(秒 → mm:ss) */
|
||||||
|
export const formatTime = (seconds: number) => {
|
||||||
|
const m = Math.floor(seconds / 60)
|
||||||
|
const s = Math.floor(seconds % 60)
|
||||||
|
return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`
|
||||||
|
}
|
||||||
|
|
||||||
/** 格式化文件大小 */
|
/** 格式化文件大小 */
|
||||||
export const formatSize = (bytes: number) => {
|
export const formatSize = (bytes: number) => {
|
||||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`
|
||||||
|
|||||||
@@ -1,238 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* 生成进度弹窗 — 任务 2.17
|
* 生成进度弹窗 — 入口文件(向后兼容)
|
||||||
* 三阶段 UI:setup(配置)→ progress(进度轮询)→ completed / failed(结果)
|
* 实际实现已移至 ./generation-progress-modal/ 目录
|
||||||
* V21 设计系统,CSS 类名前缀 ep-gen-
|
|
||||||
*/
|
*/
|
||||||
import React, { useEffect, useRef } from "react"
|
export { default } from "./generation-progress-modal"
|
||||||
import { Modal, Button } from "@/components/ui"
|
export type { GenPhase, GenerationProgressModalProps } from "./generation-progress-modal"
|
||||||
import type { TaskItem } from "@/api/tasks"
|
|
||||||
|
|
||||||
/* ──────────── 类型 ──────────── */
|
|
||||||
|
|
||||||
export type GenPhase = "setup" | "progress" | "completed" | "failed"
|
|
||||||
|
|
||||||
export interface GenerationProgressModalProps {
|
|
||||||
open: boolean
|
|
||||||
phase: GenPhase
|
|
||||||
|
|
||||||
/* setup 阶段 */
|
|
||||||
voiceoverDuration: number | null
|
|
||||||
estimatedDuration: number
|
|
||||||
onDurationChange: (v: number | null) => void
|
|
||||||
onGenerate: () => void
|
|
||||||
|
|
||||||
/* progress / 结果阶段 */
|
|
||||||
task: TaskItem | null
|
|
||||||
|
|
||||||
/* 通用 */
|
|
||||||
submitting: boolean
|
|
||||||
onCancel: () => void
|
|
||||||
onRetry?: () => void
|
|
||||||
onClose?: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ──────────── 步骤文案映射 ──────────── */
|
|
||||||
|
|
||||||
const STEP_LABELS: Record<string, string> = {
|
|
||||||
queued: "排队中…",
|
|
||||||
preparing: "准备素材…",
|
|
||||||
generating_video: "渲染视频中…",
|
|
||||||
adding_effects: "添加特效…",
|
|
||||||
composing: "合成中…",
|
|
||||||
encoding: "编码输出中…",
|
|
||||||
completed: "生成完成!",
|
|
||||||
failed: "生成失败",
|
|
||||||
}
|
|
||||||
|
|
||||||
const getStepLabel = (step: string) => STEP_LABELS[step] || step.replace(/_/g, " ")
|
|
||||||
|
|
||||||
/* ──────────── 状态徽标颜色 ──────────── */
|
|
||||||
|
|
||||||
const STATUS_COLOR: Record<string, string> = {
|
|
||||||
queued: "#6b7280",
|
|
||||||
pending: "#6b7280",
|
|
||||||
preparing: "#f59e0b",
|
|
||||||
generating_video: "#4f46e5",
|
|
||||||
adding_effects: "#7c3aed",
|
|
||||||
composing: "#2563eb",
|
|
||||||
encoding: "#0891b2",
|
|
||||||
completed: "#10b981",
|
|
||||||
failed: "#ef4444",
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ──────────── 组件 ──────────── */
|
|
||||||
|
|
||||||
const GenerationProgressModal: React.FC<GenerationProgressModalProps> = ({
|
|
||||||
open,
|
|
||||||
phase,
|
|
||||||
voiceoverDuration,
|
|
||||||
estimatedDuration,
|
|
||||||
onDurationChange,
|
|
||||||
onGenerate,
|
|
||||||
task,
|
|
||||||
submitting,
|
|
||||||
onCancel,
|
|
||||||
onRetry,
|
|
||||||
onClose,
|
|
||||||
}) => {
|
|
||||||
/* 关闭弹窗时重置(避免下次打开残留旧状态) */
|
|
||||||
const prevOpen = useRef(false)
|
|
||||||
useEffect(() => {
|
|
||||||
if (prevOpen.current && !open) {
|
|
||||||
/* modal just closed — parent handles reset */
|
|
||||||
}
|
|
||||||
prevOpen.current = open
|
|
||||||
}, [open])
|
|
||||||
|
|
||||||
const progress = task?.progress ?? 0
|
|
||||||
const status = task?.status ?? ""
|
|
||||||
const currentStep = task?.current_step ?? ""
|
|
||||||
const userMessage = task?.user_message ?? ""
|
|
||||||
const errorMessage = task?.error_message ?? ""
|
|
||||||
const retryable = task?.retryable ?? false
|
|
||||||
|
|
||||||
/* ── setup 阶段 ── */
|
|
||||||
if (phase === "setup") {
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
open={open}
|
|
||||||
title="使用模板生成视频"
|
|
||||||
confirmLoading={submitting}
|
|
||||||
onOk={onGenerate}
|
|
||||||
onCancel={onCancel}
|
|
||||||
okText="开始生成"
|
|
||||||
cancelText="取消"
|
|
||||||
width={440}
|
|
||||||
>
|
|
||||||
<div className="ep-gen-setup">
|
|
||||||
<label className="ep-gen-field-label">配音时长(秒)</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
className="ep-gen-duration-input"
|
|
||||||
placeholder="请输入配音时长"
|
|
||||||
value={voiceoverDuration ?? ""}
|
|
||||||
onChange={(e) => {
|
|
||||||
const v = e.target.value ? Number(e.target.value) : null
|
|
||||||
onDurationChange(v)
|
|
||||||
}}
|
|
||||||
min={1}
|
|
||||||
max={600}
|
|
||||||
/>
|
|
||||||
<div className="ep-gen-estimate">
|
|
||||||
预估总时长:<strong>{estimatedDuration}s</strong>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── progress 阶段 ── */
|
|
||||||
if (phase === "progress") {
|
|
||||||
const stepColor = STATUS_COLOR[status] || STATUS_COLOR[currentStep] || "#4f46e5"
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal open={open} title="视频生成中" footer={null} onCancel={onCancel} closable width={480}>
|
|
||||||
<div className="ep-gen-progress">
|
|
||||||
{/* 进度环 */}
|
|
||||||
<div className="ep-gen-progress-ring-wrap">
|
|
||||||
<svg className="ep-gen-progress-ring" viewBox="0 0 120 120">
|
|
||||||
<circle className="ep-gen-progress-ring-bg" cx="60" cy="60" r="52" />
|
|
||||||
<circle
|
|
||||||
className="ep-gen-progress-ring-fill"
|
|
||||||
cx="60"
|
|
||||||
cy="60"
|
|
||||||
r="52"
|
|
||||||
style={{
|
|
||||||
strokeDasharray: `${2 * Math.PI * 52}`,
|
|
||||||
strokeDashoffset: `${2 * Math.PI * 52 * (1 - progress / 100)}`,
|
|
||||||
stroke: stepColor,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<span className="ep-gen-progress-pct" style={{ color: stepColor }}>
|
|
||||||
{progress}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 当前步骤 */}
|
|
||||||
<div className="ep-gen-step-text">
|
|
||||||
{userMessage || getStepLabel(currentStep) || "处理中…"}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 进度条 */}
|
|
||||||
<div className="ep-gen-progress-bar">
|
|
||||||
<div
|
|
||||||
className="ep-gen-progress-bar-fill"
|
|
||||||
style={{
|
|
||||||
width: `${progress}%`,
|
|
||||||
backgroundColor: stepColor,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 任务 ID */}
|
|
||||||
{task?.id && <div className="ep-gen-task-id">任务 ID: {task.id}</div>}
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── completed 阶段 ── */
|
|
||||||
if (phase === "completed") {
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
open={open}
|
|
||||||
title="✅ 生成完成"
|
|
||||||
footer={null}
|
|
||||||
onCancel={onClose || onCancel}
|
|
||||||
closable
|
|
||||||
width={440}
|
|
||||||
>
|
|
||||||
<div className="ep-gen-result">
|
|
||||||
<div className="ep-gen-result-icon">🎉</div>
|
|
||||||
<div className="ep-gen-result-title">视频生成完成!</div>
|
|
||||||
{userMessage && <div className="ep-gen-result-msg">{userMessage}</div>}
|
|
||||||
<div className="ep-gen-result-actions">
|
|
||||||
<Button buttonType="primary" onClick={onClose || onCancel}>
|
|
||||||
查看结果
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── failed 阶段 ── */
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
open={open}
|
|
||||||
title="❌ 生成失败"
|
|
||||||
footer={null}
|
|
||||||
onCancel={onClose || onCancel}
|
|
||||||
closable
|
|
||||||
width={440}
|
|
||||||
>
|
|
||||||
<div className="ep-gen-result ep-gen-result--error">
|
|
||||||
<div className="ep-gen-result-icon">😥</div>
|
|
||||||
<div className="ep-gen-result-title">视频生成失败</div>
|
|
||||||
{(errorMessage || userMessage) && (
|
|
||||||
<div className="ep-gen-result-msg ep-gen-result-msg--error">
|
|
||||||
{errorMessage || userMessage}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="ep-gen-result-actions">
|
|
||||||
{retryable && onRetry && (
|
|
||||||
<Button buttonType="primary" onClick={onRetry}>
|
|
||||||
🔄 重试
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button buttonType="secondary" onClick={onClose || onCancel}>
|
|
||||||
关闭
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default GenerationProgressModal
|
|
||||||
|
|||||||
@@ -8,21 +8,13 @@
|
|||||||
* - 拖动时实时显示裁剪预览(入点/出点/时长)
|
* - 拖动时实时显示裁剪预览(入点/出点/时长)
|
||||||
* - 右键片段弹出菜单:分割 / 恢复原始长度 / 删除
|
* - 右键片段弹出菜单:分割 / 恢复原始长度 / 删除
|
||||||
*/
|
*/
|
||||||
import React, { useState, useRef, useCallback, useEffect, useLayoutEffect, useMemo } from "react"
|
import React, { useState, useRef, useCallback, useEffect } from "react"
|
||||||
import type { ClipData, ClipType, TrimConfig } from "../types"
|
import type { ClipData, ClipType, TrimConfig } from "../types"
|
||||||
import {
|
import { DEFAULT_PIXELS_PER_SECOND } from "../constants/timeline"
|
||||||
DEFAULT_PIXELS_PER_SECOND,
|
|
||||||
MIN_PIXELS_PER_SECOND,
|
|
||||||
MAX_PIXELS_PER_SECOND,
|
|
||||||
ZOOM_STEP,
|
|
||||||
MIN_TRIM_DURATION,
|
|
||||||
DEFAULT_ADD_DURATION,
|
|
||||||
MIN_ADD_DURATION,
|
|
||||||
MAX_ADD_DURATION,
|
|
||||||
TRACK_GAP,
|
|
||||||
ADD_PICKER_WIDTH,
|
|
||||||
} from "../constants/timeline"
|
|
||||||
import { formatTime } from "../utils/timeline"
|
import { formatTime } from "../utils/timeline"
|
||||||
|
import { useClipDrag } from "../hooks/useClipDrag"
|
||||||
|
import { useTrimDrag } from "../hooks/useTrimDrag"
|
||||||
|
import { useTimelineMenus } from "../hooks/useTimelineMenus"
|
||||||
import { ClipCard } from "./timeline/ClipCard"
|
import { ClipCard } from "./timeline/ClipCard"
|
||||||
import { TimeRuler } from "./timeline/TimeRuler"
|
import { TimeRuler } from "./timeline/TimeRuler"
|
||||||
import { AddClipPicker } from "./timeline/AddClipPicker"
|
import { AddClipPicker } from "./timeline/AddClipPicker"
|
||||||
@@ -55,25 +47,6 @@ interface TimelinePanelProps {
|
|||||||
totalDuration?: number
|
totalDuration?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 裁剪拖拽方向 */
|
|
||||||
type TrimDirection = "left" | "right"
|
|
||||||
|
|
||||||
/** 裁剪拖拽状态 */
|
|
||||||
interface TrimDragState {
|
|
||||||
clipId: string
|
|
||||||
direction: TrimDirection
|
|
||||||
startX: number
|
|
||||||
originalTrim: TrimConfig
|
|
||||||
originalDuration: number
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 右键菜单状态 */
|
|
||||||
interface ContextMenuState {
|
|
||||||
x: number
|
|
||||||
y: number
|
|
||||||
clipId: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const TimelinePanel: React.FC<TimelinePanelProps> = ({
|
const TimelinePanel: React.FC<TimelinePanelProps> = ({
|
||||||
clips,
|
clips,
|
||||||
selectedClipId,
|
selectedClipId,
|
||||||
@@ -91,143 +64,51 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
|
|||||||
onSeek,
|
onSeek,
|
||||||
totalDuration: totalDurationProp,
|
totalDuration: totalDurationProp,
|
||||||
}) => {
|
}) => {
|
||||||
const [dragIdx, setDragIdx] = useState<number | null>(null)
|
/* ── 缩放 & 时长 ── */
|
||||||
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null)
|
const pps = pixelsPerSecond ?? DEFAULT_PIXELS_PER_SECOND
|
||||||
const dragRef = useRef<number | null>(null)
|
const totalDuration = totalDurationProp ?? clips.reduce((s, c) => s + c.duration, 0)
|
||||||
const [showAddPicker, setShowAddPicker] = useState(false)
|
|
||||||
const pickerRef = useRef<HTMLDivElement>(null)
|
|
||||||
const addCardRef = useRef<HTMLDivElement>(null)
|
|
||||||
const [pickerPos, setPickerPos] = useState<{ top: number; right: number }>({
|
|
||||||
top: 0,
|
|
||||||
right: 0,
|
|
||||||
})
|
|
||||||
|
|
||||||
/* ── 裁剪拖拽状态 ── */
|
/* ── 裁剪拖拽 ── */
|
||||||
const [trimDrag, setTrimDrag] = useState<TrimDragState | null>(null)
|
const { trimDrag, trimPreview, handleTrimHandleMouseDown } = useTrimDrag(clips, pps, onClipTrim)
|
||||||
const [trimPreview, setTrimPreview] = useState<{
|
|
||||||
clipId: string
|
|
||||||
startTime: number
|
|
||||||
endTime: number
|
|
||||||
duration: number
|
|
||||||
x: number
|
|
||||||
y: number
|
|
||||||
} | null>(null)
|
|
||||||
|
|
||||||
/* ── 右键菜单 ── */
|
/* ── 片段拖拽排序 ── */
|
||||||
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null)
|
const {
|
||||||
const contextMenuRef = useRef<HTMLDivElement>(null)
|
dragIdx,
|
||||||
|
dragOverIdx,
|
||||||
|
handleDragStart,
|
||||||
|
handleDragOver,
|
||||||
|
handleDragEnd,
|
||||||
|
handleDrop,
|
||||||
|
handleEmptyDragOver,
|
||||||
|
} = useClipDrag(onClipReorder, !!trimDrag)
|
||||||
|
|
||||||
/* ── 悬停的片段 ID(显示裁剪手柄) ── */
|
/* ── 菜单 & 面板 ── */
|
||||||
const [hoveredClipId, setHoveredClipId] = useState<string | null>(null)
|
const {
|
||||||
|
contextMenu,
|
||||||
|
contextMenuRef,
|
||||||
|
handleContextMenu,
|
||||||
|
handleContextSplit,
|
||||||
|
handleContextResetTrim,
|
||||||
|
handleContextDelete,
|
||||||
|
showAddPicker,
|
||||||
|
pickerRef,
|
||||||
|
addCardRef,
|
||||||
|
pickerPos,
|
||||||
|
availableTypes,
|
||||||
|
addType,
|
||||||
|
addDuration,
|
||||||
|
setAddType,
|
||||||
|
setAddDuration,
|
||||||
|
handleTogglePicker,
|
||||||
|
handleConfirmAdd,
|
||||||
|
hoveredClipId,
|
||||||
|
setHoveredClipId,
|
||||||
|
} = useTimelineMenus(clips, currentMode, onAddClip, onClipSplit, onClipResetTrim, onClipRemove)
|
||||||
|
|
||||||
/* ── 播放头拖拽状态 ── */
|
/* ── 播放头拖拽状态 ── */
|
||||||
const [playheadDragging, setPlayheadDragging] = useState(false)
|
const [playheadDragging, setPlayheadDragging] = useState(false)
|
||||||
const trackRef = useRef<HTMLDivElement>(null)
|
const trackRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
/* ── 根据模式决定可选类型 ── */
|
|
||||||
const availableTypes: ClipType[] = useMemo(
|
|
||||||
() =>
|
|
||||||
currentMode === "voice_over" ? ["voice"] : currentMode === "pip" ? ["pip"] : ["voice", "pip"], // voice_pip / one_take / 默认
|
|
||||||
[currentMode],
|
|
||||||
)
|
|
||||||
|
|
||||||
/* ── 默认添加类型:跟随模式 ── */
|
|
||||||
const defaultAddType: ClipType = useMemo(() => {
|
|
||||||
if (currentMode === "voice_over") return "voice"
|
|
||||||
if (currentMode === "pip") return "pip"
|
|
||||||
return "voice"
|
|
||||||
}, [currentMode])
|
|
||||||
|
|
||||||
/* ── "+" 卡片:类型+时长选择状态 ── */
|
|
||||||
const [addType, setAddType] = useState<ClipType>(defaultAddType)
|
|
||||||
const [addDuration, setAddDuration] = useState<number>(DEFAULT_ADD_DURATION)
|
|
||||||
|
|
||||||
/* ── 模式切换时自动同步默认添加类型 ── */
|
|
||||||
useEffect(() => {
|
|
||||||
if (!availableTypes.includes(addType)) {
|
|
||||||
setAddType(defaultAddType)
|
|
||||||
}
|
|
||||||
}, [currentMode, addType, availableTypes, defaultAddType])
|
|
||||||
|
|
||||||
/* ── 计算 picker 初始位置 ── */
|
|
||||||
const updatePickerPosition = useCallback(() => {
|
|
||||||
if (!addCardRef.current) return
|
|
||||||
const rect = addCardRef.current.getBoundingClientRect()
|
|
||||||
const vw = window.innerWidth
|
|
||||||
const roughHeight = 180
|
|
||||||
let top = rect.top - TRACK_GAP - roughHeight
|
|
||||||
if (top < 8) top = 8
|
|
||||||
let right = vw - rect.right
|
|
||||||
if (rect.right - ADD_PICKER_WIDTH < 8) {
|
|
||||||
right = vw - ADD_PICKER_WIDTH - 8
|
|
||||||
}
|
|
||||||
setPickerPos({ top, right })
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleTogglePicker = () => {
|
|
||||||
if (!showAddPicker) {
|
|
||||||
const defaultType =
|
|
||||||
currentMode === "pip" ? "pip" : currentMode === "voice_over" ? "voice" : "voice"
|
|
||||||
setAddType(defaultType)
|
|
||||||
updatePickerPosition()
|
|
||||||
}
|
|
||||||
setShowAddPicker((v) => !v)
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── 渲染后精确边界校正 ── */
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
if (!showAddPicker || !pickerRef.current || !addCardRef.current) return
|
|
||||||
const pickerEl = pickerRef.current
|
|
||||||
const addRect = addCardRef.current.getBoundingClientRect()
|
|
||||||
const pickerH = pickerEl.offsetHeight
|
|
||||||
const vh = window.innerHeight
|
|
||||||
const vw = window.innerWidth
|
|
||||||
let top = addRect.top - TRACK_GAP - pickerH
|
|
||||||
if (top < 8) {
|
|
||||||
top = addRect.bottom + TRACK_GAP
|
|
||||||
if (top + pickerH > vh - 8) {
|
|
||||||
top = vh - 8 - pickerH
|
|
||||||
if (top < 8) top = 8
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let right = vw - addRect.right
|
|
||||||
const pickerRect = pickerEl.getBoundingClientRect()
|
|
||||||
if (pickerRect.left < 8) {
|
|
||||||
right = vw - ADD_PICKER_WIDTH - 8
|
|
||||||
}
|
|
||||||
setPickerPos({ top, right })
|
|
||||||
}, [showAddPicker])
|
|
||||||
|
|
||||||
/* ── 点击外部关闭添加面板 ── */
|
|
||||||
useEffect(() => {
|
|
||||||
const handleClickOutside = (e: MouseEvent) => {
|
|
||||||
if (pickerRef.current && !pickerRef.current.contains(e.target as Node)) {
|
|
||||||
setShowAddPicker(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (showAddPicker) {
|
|
||||||
document.addEventListener("mousedown", handleClickOutside)
|
|
||||||
}
|
|
||||||
return () => document.removeEventListener("mousedown", handleClickOutside)
|
|
||||||
}, [showAddPicker])
|
|
||||||
|
|
||||||
/* ── 点击外部关闭右键菜单 ── */
|
|
||||||
useEffect(() => {
|
|
||||||
const handleClickOutside = (e: MouseEvent) => {
|
|
||||||
if (contextMenuRef.current && !contextMenuRef.current.contains(e.target as Node)) {
|
|
||||||
setContextMenu(null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (contextMenu) {
|
|
||||||
document.addEventListener("mousedown", handleClickOutside)
|
|
||||||
}
|
|
||||||
return () => document.removeEventListener("mousedown", handleClickOutside)
|
|
||||||
}, [contextMenu])
|
|
||||||
|
|
||||||
/* ── 缩放 & 时长 ── */
|
|
||||||
const pps = pixelsPerSecond ?? DEFAULT_PIXELS_PER_SECOND
|
|
||||||
const totalDuration = totalDurationProp ?? clips.reduce((s, c) => s + c.duration, 0)
|
|
||||||
|
|
||||||
/* ── 播放头拖拽全局 mousemove/mouseup ── */
|
/* ── 播放头拖拽全局 mousemove/mouseup ── */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!playheadDragging) return
|
if (!playheadDragging) return
|
||||||
@@ -271,165 +152,6 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
|
|||||||
setPlayheadDragging(true)
|
setPlayheadDragging(true)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
/* ── 确认添加片段 ── */
|
|
||||||
const handleConfirmAdd = () => {
|
|
||||||
onAddClip(addType, addDuration)
|
|
||||||
setShowAddPicker(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── 片段拖拽排序 ── */
|
|
||||||
const handleDragStart = (e: React.DragEvent, idx: number) => {
|
|
||||||
// 如果正在裁剪拖拽,不允许排序拖拽
|
|
||||||
if (trimDrag) return
|
|
||||||
dragRef.current = idx
|
|
||||||
setDragIdx(idx)
|
|
||||||
e.dataTransfer.setData("application/x-clip-drag", String(idx))
|
|
||||||
e.dataTransfer.effectAllowed = "move"
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDragOver = (e: React.DragEvent, idx: number) => {
|
|
||||||
e.preventDefault()
|
|
||||||
e.dataTransfer.dropEffect = "move"
|
|
||||||
setDragOverIdx(idx)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDragEnd = () => {
|
|
||||||
dragRef.current = null
|
|
||||||
setDragIdx(null)
|
|
||||||
setDragOverIdx(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDrop = (e: React.DragEvent, toIdx: number) => {
|
|
||||||
e.preventDefault()
|
|
||||||
setDragOverIdx(null)
|
|
||||||
const fromStr = e.dataTransfer.getData("application/x-clip-drag")
|
|
||||||
if (fromStr !== "") {
|
|
||||||
const fromIdx = Number(fromStr)
|
|
||||||
if (fromIdx !== toIdx) {
|
|
||||||
onClipReorder(fromIdx, toIdx)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── 空轨道区域不接受素材拖入 ── */
|
|
||||||
const handleEmptyDragOver = (e: React.DragEvent) => {
|
|
||||||
e.preventDefault()
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── 裁剪手柄拖拽 ── */
|
|
||||||
const handleTrimHandleMouseDown = useCallback(
|
|
||||||
(e: React.MouseEvent, clipId: string, direction: TrimDirection) => {
|
|
||||||
e.preventDefault()
|
|
||||||
e.stopPropagation()
|
|
||||||
const clip = clips.find((c) => c.id === clipId)
|
|
||||||
if (!clip) return
|
|
||||||
|
|
||||||
const trim: TrimConfig = clip.trim_config ?? {
|
|
||||||
start_time: 0,
|
|
||||||
end_time: clip.duration,
|
|
||||||
original_duration: clip.duration,
|
|
||||||
}
|
|
||||||
|
|
||||||
setTrimDrag({
|
|
||||||
clipId,
|
|
||||||
direction,
|
|
||||||
startX: e.clientX,
|
|
||||||
originalTrim: { ...trim },
|
|
||||||
originalDuration: clip.duration,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
[clips],
|
|
||||||
)
|
|
||||||
|
|
||||||
/* ── 裁剪拖拽全局 mousemove/mouseup ── */
|
|
||||||
useEffect(() => {
|
|
||||||
if (!trimDrag) return
|
|
||||||
|
|
||||||
const handleMouseMove = (e: MouseEvent) => {
|
|
||||||
const dx = e.clientX - trimDrag.startX
|
|
||||||
const dtSec = dx / pps
|
|
||||||
const clip = clips.find((c) => c.id === trimDrag.clipId)
|
|
||||||
if (!clip) return
|
|
||||||
|
|
||||||
const origTrim = trimDrag.originalTrim
|
|
||||||
const origDur = origTrim.original_duration ?? trimDrag.originalDuration
|
|
||||||
let newStart = origTrim.start_time
|
|
||||||
let newEnd = origTrim.end_time
|
|
||||||
|
|
||||||
if (trimDrag.direction === "left") {
|
|
||||||
// 左手柄:调整入点
|
|
||||||
newStart = Math.max(0, Math.min(origTrim.start_time + dtSec, newEnd - MIN_TRIM_DURATION))
|
|
||||||
} else {
|
|
||||||
// 右手柄:调整出点
|
|
||||||
newEnd = Math.max(
|
|
||||||
origTrim.start_time + MIN_TRIM_DURATION,
|
|
||||||
Math.min(origTrim.end_time + dtSec, origDur),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const newDuration = Math.round((newEnd - newStart) * 10) / 10
|
|
||||||
|
|
||||||
setTrimPreview({
|
|
||||||
clipId: trimDrag.clipId,
|
|
||||||
startTime: Math.round(newStart * 10) / 10,
|
|
||||||
endTime: Math.round(newEnd * 10) / 10,
|
|
||||||
duration: newDuration,
|
|
||||||
x: e.clientX,
|
|
||||||
y: e.clientY,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleMouseUp = () => {
|
|
||||||
if (trimPreview && trimPreview.clipId === trimDrag.clipId && onClipTrim) {
|
|
||||||
const newTrim: TrimConfig = {
|
|
||||||
start_time: trimPreview.startTime,
|
|
||||||
end_time: trimPreview.endTime,
|
|
||||||
original_duration: trimDrag.originalTrim.original_duration ?? trimDrag.originalDuration,
|
|
||||||
}
|
|
||||||
onClipTrim(trimDrag.clipId, newTrim, trimPreview.duration)
|
|
||||||
}
|
|
||||||
setTrimDrag(null)
|
|
||||||
setTrimPreview(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener("mousemove", handleMouseMove)
|
|
||||||
document.addEventListener("mouseup", handleMouseUp)
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener("mousemove", handleMouseMove)
|
|
||||||
document.removeEventListener("mouseup", handleMouseUp)
|
|
||||||
}
|
|
||||||
}, [trimDrag, trimPreview, clips, onClipTrim, pps])
|
|
||||||
|
|
||||||
/* ── 右键菜单 ── */
|
|
||||||
const handleContextMenu = useCallback((e: React.MouseEvent, clipId: string) => {
|
|
||||||
e.preventDefault()
|
|
||||||
e.stopPropagation()
|
|
||||||
setContextMenu({ x: e.clientX, y: e.clientY, clipId })
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
/* ── 右键菜单操作 ── */
|
|
||||||
const handleContextSplit = useCallback(() => {
|
|
||||||
if (!contextMenu) return
|
|
||||||
if (onClipSplit) {
|
|
||||||
onClipSplit(contextMenu.clipId, 0.5) // 在中间分割
|
|
||||||
}
|
|
||||||
setContextMenu(null)
|
|
||||||
}, [contextMenu, onClipSplit])
|
|
||||||
|
|
||||||
const handleContextResetTrim = useCallback(() => {
|
|
||||||
if (!contextMenu) return
|
|
||||||
if (onClipResetTrim) {
|
|
||||||
onClipResetTrim(contextMenu.clipId)
|
|
||||||
}
|
|
||||||
setContextMenu(null)
|
|
||||||
}, [contextMenu, onClipResetTrim])
|
|
||||||
|
|
||||||
const handleContextDelete = useCallback(() => {
|
|
||||||
if (!contextMenu) return
|
|
||||||
onClipRemove(contextMenu.clipId)
|
|
||||||
setContextMenu(null)
|
|
||||||
}, [contextMenu, onClipRemove])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="ep-timeline-area">
|
<div className="ep-timeline-area">
|
||||||
{/* 时间线头部 */}
|
{/* 时间线头部 */}
|
||||||
@@ -443,7 +165,7 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
|
|||||||
<div className="ep-timeline-zoom">
|
<div className="ep-timeline-zoom">
|
||||||
<button
|
<button
|
||||||
className="ep-zoom-btn"
|
className="ep-zoom-btn"
|
||||||
onClick={() => onZoomChange?.(Math.max(MIN_PIXELS_PER_SECOND, pps - ZOOM_STEP))}
|
onClick={() => onZoomChange?.(Math.max(10, pps - 10))}
|
||||||
title="缩小"
|
title="缩小"
|
||||||
>
|
>
|
||||||
−
|
−
|
||||||
@@ -451,15 +173,15 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
|
|||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
className="ep-zoom-slider"
|
className="ep-zoom-slider"
|
||||||
min={MIN_PIXELS_PER_SECOND}
|
min={10}
|
||||||
max={MAX_PIXELS_PER_SECOND}
|
max={120}
|
||||||
step={5}
|
step={5}
|
||||||
value={pps}
|
value={pps}
|
||||||
onChange={(e) => onZoomChange?.(Number(e.target.value))}
|
onChange={(e) => onZoomChange?.(Number(e.target.value))}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
className="ep-zoom-btn"
|
className="ep-zoom-btn"
|
||||||
onClick={() => onZoomChange?.(Math.min(MAX_PIXELS_PER_SECOND, pps + ZOOM_STEP))}
|
onClick={() => onZoomChange?.(Math.min(120, pps + 10))}
|
||||||
title="放大"
|
title="放大"
|
||||||
>
|
>
|
||||||
+
|
+
|
||||||
@@ -583,8 +305,6 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
|
|||||||
availableTypes={availableTypes}
|
availableTypes={availableTypes}
|
||||||
addType={addType}
|
addType={addType}
|
||||||
addDuration={addDuration}
|
addDuration={addDuration}
|
||||||
minDuration={MIN_ADD_DURATION}
|
|
||||||
maxDuration={MAX_ADD_DURATION}
|
|
||||||
onTypeChange={setAddType}
|
onTypeChange={setAddType}
|
||||||
onDurationChange={setAddDuration}
|
onDurationChange={setAddDuration}
|
||||||
onConfirm={handleConfirmAdd}
|
onConfirm={handleConfirmAdd}
|
||||||
|
|||||||
+65
@@ -0,0 +1,65 @@
|
|||||||
|
import React from "react"
|
||||||
|
import { Modal } from "@/components/ui"
|
||||||
|
import type { TaskItem } from "@/api/tasks"
|
||||||
|
import { getStepLabel, getStatusColor } from "./constants"
|
||||||
|
|
||||||
|
interface ProgressPhaseProps {
|
||||||
|
open: boolean
|
||||||
|
task: TaskItem | null
|
||||||
|
onCancel: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/** progress(进度轮询)阶段弹窗 */
|
||||||
|
export const ProgressPhase: React.FC<ProgressPhaseProps> = ({ open, task, onCancel }) => {
|
||||||
|
const progress = task?.progress ?? 0
|
||||||
|
const status = task?.status ?? ""
|
||||||
|
const currentStep = task?.current_step ?? ""
|
||||||
|
const userMessage = task?.user_message ?? ""
|
||||||
|
const stepColor = getStatusColor(status, currentStep)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal open={open} title="视频生成中" footer={null} onCancel={onCancel} closable width={480}>
|
||||||
|
<div className="ep-gen-progress">
|
||||||
|
{/* 进度环 */}
|
||||||
|
<div className="ep-gen-progress-ring-wrap">
|
||||||
|
<svg className="ep-gen-progress-ring" viewBox="0 0 120 120">
|
||||||
|
<circle className="ep-gen-progress-ring-bg" cx="60" cy="60" r="52" />
|
||||||
|
<circle
|
||||||
|
className="ep-gen-progress-ring-fill"
|
||||||
|
cx="60"
|
||||||
|
cy="60"
|
||||||
|
r="52"
|
||||||
|
style={{
|
||||||
|
strokeDasharray: `${2 * Math.PI * 52}`,
|
||||||
|
strokeDashoffset: `${2 * Math.PI * 52 * (1 - progress / 100)}`,
|
||||||
|
stroke: stepColor,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span className="ep-gen-progress-pct" style={{ color: stepColor }}>
|
||||||
|
{progress}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 当前步骤 */}
|
||||||
|
<div className="ep-gen-step-text">
|
||||||
|
{userMessage || getStepLabel(currentStep) || "处理中…"}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 进度条 */}
|
||||||
|
<div className="ep-gen-progress-bar">
|
||||||
|
<div
|
||||||
|
className="ep-gen-progress-bar-fill"
|
||||||
|
style={{
|
||||||
|
width: `${progress}%`,
|
||||||
|
backgroundColor: stepColor,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 任务 ID */}
|
||||||
|
{task?.id && <div className="ep-gen-task-id">任务 ID: {task.id}</div>}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
+82
@@ -0,0 +1,82 @@
|
|||||||
|
import React from "react"
|
||||||
|
import { Modal, Button } from "@/components/ui"
|
||||||
|
import type { TaskItem } from "@/api/tasks"
|
||||||
|
|
||||||
|
interface ResultPhaseProps {
|
||||||
|
open: boolean
|
||||||
|
phase: "completed" | "failed"
|
||||||
|
task: TaskItem | null
|
||||||
|
onCancel: () => void
|
||||||
|
onRetry?: () => void
|
||||||
|
onClose?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/** completed / failed(结果)阶段弹窗 */
|
||||||
|
export const ResultPhase: React.FC<ResultPhaseProps> = ({
|
||||||
|
open,
|
||||||
|
phase,
|
||||||
|
task,
|
||||||
|
onCancel,
|
||||||
|
onRetry,
|
||||||
|
onClose,
|
||||||
|
}) => {
|
||||||
|
const userMessage = task?.user_message ?? ""
|
||||||
|
const errorMessage = task?.error_message ?? ""
|
||||||
|
const retryable = task?.retryable ?? false
|
||||||
|
const handleClose = onClose || onCancel
|
||||||
|
|
||||||
|
if (phase === "completed") {
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
title="✅ 生成完成"
|
||||||
|
footer={null}
|
||||||
|
onCancel={handleClose}
|
||||||
|
closable
|
||||||
|
width={440}
|
||||||
|
>
|
||||||
|
<div className="ep-gen-result">
|
||||||
|
<div className="ep-gen-result-icon">🎉</div>
|
||||||
|
<div className="ep-gen-result-title">视频生成完成!</div>
|
||||||
|
{userMessage && <div className="ep-gen-result-msg">{userMessage}</div>}
|
||||||
|
<div className="ep-gen-result-actions">
|
||||||
|
<Button buttonType="primary" onClick={handleClose}>
|
||||||
|
查看结果
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
title="❌ 生成失败"
|
||||||
|
footer={null}
|
||||||
|
onCancel={handleClose}
|
||||||
|
closable
|
||||||
|
width={440}
|
||||||
|
>
|
||||||
|
<div className="ep-gen-result ep-gen-result--error">
|
||||||
|
<div className="ep-gen-result-icon">😥</div>
|
||||||
|
<div className="ep-gen-result-title">视频生成失败</div>
|
||||||
|
{(errorMessage || userMessage) && (
|
||||||
|
<div className="ep-gen-result-msg ep-gen-result-msg--error">
|
||||||
|
{errorMessage || userMessage}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="ep-gen-result-actions">
|
||||||
|
{retryable && onRetry && (
|
||||||
|
<Button buttonType="primary" onClick={onRetry}>
|
||||||
|
🔄 重试
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button buttonType="secondary" onClick={handleClose}>
|
||||||
|
关闭
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
+53
@@ -0,0 +1,53 @@
|
|||||||
|
import React from "react"
|
||||||
|
import { Modal } from "@/components/ui"
|
||||||
|
|
||||||
|
interface SetupPhaseProps {
|
||||||
|
open: boolean
|
||||||
|
voiceoverDuration: number | null
|
||||||
|
estimatedDuration: number
|
||||||
|
submitting: boolean
|
||||||
|
onDurationChange: (v: number | null) => void
|
||||||
|
onGenerate: () => void
|
||||||
|
onCancel: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/** setup(配置)阶段弹窗 */
|
||||||
|
export const SetupPhase: React.FC<SetupPhaseProps> = ({
|
||||||
|
open,
|
||||||
|
voiceoverDuration,
|
||||||
|
estimatedDuration,
|
||||||
|
submitting,
|
||||||
|
onDurationChange,
|
||||||
|
onGenerate,
|
||||||
|
onCancel,
|
||||||
|
}) => (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
title="使用模板生成视频"
|
||||||
|
confirmLoading={submitting}
|
||||||
|
onOk={onGenerate}
|
||||||
|
onCancel={onCancel}
|
||||||
|
okText="开始生成"
|
||||||
|
cancelText="取消"
|
||||||
|
width={440}
|
||||||
|
>
|
||||||
|
<div className="ep-gen-setup">
|
||||||
|
<label className="ep-gen-field-label">配音时长(秒)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="ep-gen-duration-input"
|
||||||
|
placeholder="请输入配音时长"
|
||||||
|
value={voiceoverDuration ?? ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = e.target.value ? Number(e.target.value) : null
|
||||||
|
onDurationChange(v)
|
||||||
|
}}
|
||||||
|
min={1}
|
||||||
|
max={600}
|
||||||
|
/>
|
||||||
|
<div className="ep-gen-estimate">
|
||||||
|
预估总时长:<strong>{estimatedDuration}s</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
/* ──────────── 步骤文案映射 ──────────── */
|
||||||
|
|
||||||
|
export const STEP_LABELS: Record<string, string> = {
|
||||||
|
queued: "排队中…",
|
||||||
|
preparing: "准备素材…",
|
||||||
|
generating_video: "渲染视频中…",
|
||||||
|
adding_effects: "添加特效…",
|
||||||
|
composing: "合成中…",
|
||||||
|
encoding: "编码输出中…",
|
||||||
|
completed: "生成完成!",
|
||||||
|
failed: "生成失败",
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getStepLabel = (step: string) => STEP_LABELS[step] || step.replace(/_/g, " ")
|
||||||
|
|
||||||
|
/* ──────────── 状态徽标颜色 ──────────── */
|
||||||
|
|
||||||
|
export const STATUS_COLOR: Record<string, string> = {
|
||||||
|
queued: "#6b7280",
|
||||||
|
pending: "#6b7280",
|
||||||
|
preparing: "#f59e0b",
|
||||||
|
generating_video: "#4f46e5",
|
||||||
|
adding_effects: "#7c3aed",
|
||||||
|
composing: "#2563eb",
|
||||||
|
encoding: "#0891b2",
|
||||||
|
completed: "#10b981",
|
||||||
|
failed: "#ef4444",
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getStatusColor = (status: string, currentStep: string) =>
|
||||||
|
STATUS_COLOR[status] || STATUS_COLOR[currentStep] || "#4f46e5"
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
/**
|
||||||
|
* 生成进度弹窗 — 任务 2.17
|
||||||
|
* 三阶段 UI:setup(配置)→ progress(进度轮询)→ completed / failed(结果)
|
||||||
|
* V21 设计系统,CSS 类名前缀 ep-gen-
|
||||||
|
*/
|
||||||
|
import React, { useEffect, useRef } from "react"
|
||||||
|
import type { GenPhase, GenerationProgressModalProps } from "./types"
|
||||||
|
import { SetupPhase } from "./SetupPhase"
|
||||||
|
import { ProgressPhase } from "./ProgressPhase"
|
||||||
|
import { ResultPhase } from "./ResultPhase"
|
||||||
|
|
||||||
|
/* 重新导出类型,保持向后兼容 */
|
||||||
|
export type { GenPhase, GenerationProgressModalProps }
|
||||||
|
|
||||||
|
const GenerationProgressModal: React.FC<GenerationProgressModalProps> = ({
|
||||||
|
open,
|
||||||
|
phase,
|
||||||
|
voiceoverDuration,
|
||||||
|
estimatedDuration,
|
||||||
|
onDurationChange,
|
||||||
|
onGenerate,
|
||||||
|
task,
|
||||||
|
submitting,
|
||||||
|
onCancel,
|
||||||
|
onRetry,
|
||||||
|
onClose,
|
||||||
|
}) => {
|
||||||
|
/* 关闭弹窗时重置(避免下次打开残留旧状态) */
|
||||||
|
const prevOpen = useRef(false)
|
||||||
|
useEffect(() => {
|
||||||
|
if (prevOpen.current && !open) {
|
||||||
|
/* modal just closed — parent handles reset */
|
||||||
|
}
|
||||||
|
prevOpen.current = open
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
/* setup 阶段 */
|
||||||
|
if (phase === "setup") {
|
||||||
|
return (
|
||||||
|
<SetupPhase
|
||||||
|
open={open}
|
||||||
|
voiceoverDuration={voiceoverDuration}
|
||||||
|
estimatedDuration={estimatedDuration}
|
||||||
|
submitting={submitting}
|
||||||
|
onDurationChange={onDurationChange}
|
||||||
|
onGenerate={onGenerate}
|
||||||
|
onCancel={onCancel}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* progress 阶段 */
|
||||||
|
if (phase === "progress") {
|
||||||
|
return <ProgressPhase open={open} task={task} onCancel={onCancel} />
|
||||||
|
}
|
||||||
|
|
||||||
|
/* completed / failed 阶段 */
|
||||||
|
return (
|
||||||
|
<ResultPhase
|
||||||
|
open={open}
|
||||||
|
phase={phase as "completed" | "failed"}
|
||||||
|
task={task}
|
||||||
|
onCancel={onCancel}
|
||||||
|
onRetry={onRetry}
|
||||||
|
onClose={onClose}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default GenerationProgressModal
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
import type { TaskItem } from "@/api/tasks"
|
||||||
|
|
||||||
|
export type GenPhase = "setup" | "progress" | "completed" | "failed"
|
||||||
|
|
||||||
|
export interface GenerationProgressModalProps {
|
||||||
|
open: boolean
|
||||||
|
phase: GenPhase
|
||||||
|
|
||||||
|
/* setup 阶段 */
|
||||||
|
voiceoverDuration: number | null
|
||||||
|
estimatedDuration: number
|
||||||
|
onDurationChange: (v: number | null) => void
|
||||||
|
onGenerate: () => void
|
||||||
|
|
||||||
|
/* progress / 结果阶段 */
|
||||||
|
task: TaskItem | null
|
||||||
|
|
||||||
|
/* 通用 */
|
||||||
|
submitting: boolean
|
||||||
|
onCancel: () => void
|
||||||
|
onRetry?: () => void
|
||||||
|
onClose?: () => void
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { useState, useCallback } from "react"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 片段拖拽排序 Hook
|
||||||
|
* 支持 HTML5 原生拖拽,实时高亮拖拽位置
|
||||||
|
*/
|
||||||
|
export const useClipDrag = (
|
||||||
|
onClipReorder: (fromIdx: number, toIdx: number) => void,
|
||||||
|
disabled?: boolean,
|
||||||
|
) => {
|
||||||
|
const [dragIdx, setDragIdx] = useState<number | null>(null)
|
||||||
|
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null)
|
||||||
|
|
||||||
|
const handleDragStart = useCallback(
|
||||||
|
(e: React.DragEvent, idx: number) => {
|
||||||
|
if (disabled) return
|
||||||
|
setDragIdx(idx)
|
||||||
|
e.dataTransfer.setData("application/x-clip-drag", String(idx))
|
||||||
|
e.dataTransfer.effectAllowed = "move"
|
||||||
|
},
|
||||||
|
[disabled],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleDragOver = useCallback((e: React.DragEvent, idx: number) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.dataTransfer.dropEffect = "move"
|
||||||
|
setDragOverIdx(idx)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleDragEnd = useCallback(() => {
|
||||||
|
setDragIdx(null)
|
||||||
|
setDragOverIdx(null)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleDrop = useCallback(
|
||||||
|
(e: React.DragEvent, toIdx: number) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setDragOverIdx(null)
|
||||||
|
const fromStr = e.dataTransfer.getData("application/x-clip-drag")
|
||||||
|
if (fromStr !== "") {
|
||||||
|
const fromIdx = Number(fromStr)
|
||||||
|
if (fromIdx !== toIdx) {
|
||||||
|
onClipReorder(fromIdx, toIdx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setDragIdx(null)
|
||||||
|
},
|
||||||
|
[onClipReorder],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleEmptyDragOver = useCallback((e: React.DragEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return {
|
||||||
|
dragIdx,
|
||||||
|
dragOverIdx,
|
||||||
|
handleDragStart,
|
||||||
|
handleDragOver,
|
||||||
|
handleDragEnd,
|
||||||
|
handleDrop,
|
||||||
|
handleEmptyDragOver,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
import { useState, useRef, useCallback, useEffect, useMemo, useLayoutEffect } from "react"
|
||||||
|
import type { ClipData, ClipType } from "../types"
|
||||||
|
import { DEFAULT_ADD_DURATION, ADD_PICKER_WIDTH, TRACK_GAP } from "../constants/timeline"
|
||||||
|
|
||||||
|
interface ContextMenuState {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
clipId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 时间线菜单 Hook
|
||||||
|
* 管理右键菜单和添加片段面板的状态与交互
|
||||||
|
*/
|
||||||
|
export const useTimelineMenus = (
|
||||||
|
_clips: ClipData[],
|
||||||
|
currentMode: string,
|
||||||
|
onAddClip: (type: ClipType, duration: number) => void,
|
||||||
|
onClipSplit?: (clipId: string, splitRatio: number) => void,
|
||||||
|
onClipResetTrim?: (clipId: string) => void,
|
||||||
|
onClipRemove?: (clipId: string) => void,
|
||||||
|
) => {
|
||||||
|
/* ── 右键菜单 ── */
|
||||||
|
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null)
|
||||||
|
const contextMenuRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
/* ── 添加片段面板 ── */
|
||||||
|
const [showAddPicker, setShowAddPicker] = useState(false)
|
||||||
|
const pickerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const addCardRef = useRef<HTMLDivElement>(null)
|
||||||
|
const [pickerPos, setPickerPos] = useState<{ top: number; right: number }>({ top: 0, right: 0 })
|
||||||
|
|
||||||
|
/* ── 悬停的片段 ID(显示裁剪手柄) ── */
|
||||||
|
const [hoveredClipId, setHoveredClipId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
/* ── 根据模式决定可选类型 ── */
|
||||||
|
const availableTypes: ClipType[] = useMemo(
|
||||||
|
() =>
|
||||||
|
currentMode === "voice_over" ? ["voice"] : currentMode === "pip" ? ["pip"] : ["voice", "pip"],
|
||||||
|
[currentMode],
|
||||||
|
)
|
||||||
|
|
||||||
|
/* ── 默认添加类型:跟随模式 ── */
|
||||||
|
const defaultAddType: ClipType = useMemo(() => {
|
||||||
|
if (currentMode === "voice_over") return "voice"
|
||||||
|
if (currentMode === "pip") return "pip"
|
||||||
|
return "voice"
|
||||||
|
}, [currentMode])
|
||||||
|
|
||||||
|
/* ── "+" 卡片:类型+时长选择状态 ── */
|
||||||
|
const [addType, setAddType] = useState<ClipType>(defaultAddType)
|
||||||
|
const [addDuration, setAddDuration] = useState<number>(DEFAULT_ADD_DURATION)
|
||||||
|
|
||||||
|
/* ── 模式切换时自动同步默认添加类型 ── */
|
||||||
|
useEffect(() => {
|
||||||
|
if (!availableTypes.includes(addType)) {
|
||||||
|
setAddType(defaultAddType)
|
||||||
|
}
|
||||||
|
}, [currentMode, addType, availableTypes, defaultAddType])
|
||||||
|
|
||||||
|
/* ── 计算 picker 初始位置 ── */
|
||||||
|
const updatePickerPosition = useCallback(() => {
|
||||||
|
if (!addCardRef.current) return
|
||||||
|
const rect = addCardRef.current.getBoundingClientRect()
|
||||||
|
const vw = window.innerWidth
|
||||||
|
const roughHeight = 180
|
||||||
|
let top = rect.top - TRACK_GAP - roughHeight
|
||||||
|
if (top < 8) top = 8
|
||||||
|
let right = vw - rect.right
|
||||||
|
if (rect.right - ADD_PICKER_WIDTH < 8) {
|
||||||
|
right = vw - ADD_PICKER_WIDTH - 8
|
||||||
|
}
|
||||||
|
setPickerPos({ top, right })
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleTogglePicker = useCallback(() => {
|
||||||
|
if (!showAddPicker) {
|
||||||
|
setAddType(defaultAddType)
|
||||||
|
updatePickerPosition()
|
||||||
|
}
|
||||||
|
setShowAddPicker((v) => !v)
|
||||||
|
}, [showAddPicker, defaultAddType, updatePickerPosition])
|
||||||
|
|
||||||
|
/* ── 渲染后精确边界校正 ── */
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!showAddPicker || !pickerRef.current || !addCardRef.current) return
|
||||||
|
const pickerEl = pickerRef.current
|
||||||
|
const addRect = addCardRef.current.getBoundingClientRect()
|
||||||
|
const pickerH = pickerEl.offsetHeight
|
||||||
|
const vh = window.innerHeight
|
||||||
|
const vw = window.innerWidth
|
||||||
|
|
||||||
|
let top = addRect.top - TRACK_GAP - pickerH
|
||||||
|
if (top < 8) {
|
||||||
|
top = addRect.bottom + TRACK_GAP
|
||||||
|
if (top + pickerH > vh - 8) {
|
||||||
|
top = vh - 8 - pickerH
|
||||||
|
if (top < 8) top = 8
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let right = vw - addRect.right
|
||||||
|
const pickerRect = pickerEl.getBoundingClientRect()
|
||||||
|
if (pickerRect.left < 8) {
|
||||||
|
right = vw - ADD_PICKER_WIDTH - 8
|
||||||
|
}
|
||||||
|
|
||||||
|
setPickerPos({ top, right })
|
||||||
|
}, [showAddPicker])
|
||||||
|
|
||||||
|
/* ── 点击外部关闭添加面板 ── */
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (e: MouseEvent) => {
|
||||||
|
if (pickerRef.current && !pickerRef.current.contains(e.target as Node)) {
|
||||||
|
setShowAddPicker(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (showAddPicker) {
|
||||||
|
document.addEventListener("mousedown", handleClickOutside)
|
||||||
|
}
|
||||||
|
return () => document.removeEventListener("mousedown", handleClickOutside)
|
||||||
|
}, [showAddPicker])
|
||||||
|
|
||||||
|
/* ── 点击外部关闭右键菜单 ── */
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (e: MouseEvent) => {
|
||||||
|
if (contextMenuRef.current && !contextMenuRef.current.contains(e.target as Node)) {
|
||||||
|
setContextMenu(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (contextMenu) {
|
||||||
|
document.addEventListener("mousedown", handleClickOutside)
|
||||||
|
}
|
||||||
|
return () => document.removeEventListener("mousedown", handleClickOutside)
|
||||||
|
}, [contextMenu])
|
||||||
|
|
||||||
|
/* ── 确认添加片段 ── */
|
||||||
|
const handleConfirmAdd = useCallback(() => {
|
||||||
|
onAddClip(addType, addDuration)
|
||||||
|
setShowAddPicker(false)
|
||||||
|
}, [onAddClip, addType, addDuration])
|
||||||
|
|
||||||
|
/* ── 右键菜单操作 ── */
|
||||||
|
const handleContextMenu = useCallback((e: React.MouseEvent, clipId: string) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
setContextMenu({ x: e.clientX, y: e.clientY, clipId })
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleContextSplit = useCallback(() => {
|
||||||
|
if (!contextMenu) return
|
||||||
|
onClipSplit?.(contextMenu.clipId, 0.5)
|
||||||
|
setContextMenu(null)
|
||||||
|
}, [contextMenu, onClipSplit])
|
||||||
|
|
||||||
|
const handleContextResetTrim = useCallback(() => {
|
||||||
|
if (!contextMenu) return
|
||||||
|
onClipResetTrim?.(contextMenu.clipId)
|
||||||
|
setContextMenu(null)
|
||||||
|
}, [contextMenu, onClipResetTrim])
|
||||||
|
|
||||||
|
const handleContextDelete = useCallback(() => {
|
||||||
|
if (!contextMenu) return
|
||||||
|
onClipRemove?.(contextMenu.clipId)
|
||||||
|
setContextMenu(null)
|
||||||
|
}, [contextMenu, onClipRemove])
|
||||||
|
|
||||||
|
return {
|
||||||
|
// 右键菜单
|
||||||
|
contextMenu,
|
||||||
|
contextMenuRef,
|
||||||
|
handleContextMenu,
|
||||||
|
handleContextSplit,
|
||||||
|
handleContextResetTrim,
|
||||||
|
handleContextDelete,
|
||||||
|
// 添加面板
|
||||||
|
showAddPicker,
|
||||||
|
pickerRef,
|
||||||
|
addCardRef,
|
||||||
|
pickerPos,
|
||||||
|
availableTypes,
|
||||||
|
addType,
|
||||||
|
addDuration,
|
||||||
|
setAddType,
|
||||||
|
setAddDuration,
|
||||||
|
handleTogglePicker,
|
||||||
|
handleConfirmAdd,
|
||||||
|
// 悬停状态
|
||||||
|
hoveredClipId,
|
||||||
|
setHoveredClipId,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { useState, useCallback, useEffect } from "react"
|
||||||
|
import type { ClipData, TrimConfig } from "../types"
|
||||||
|
|
||||||
|
interface TrimDragState {
|
||||||
|
clipId: string
|
||||||
|
direction: "left" | "right"
|
||||||
|
startX: number
|
||||||
|
originalTrim: TrimConfig
|
||||||
|
originalDuration: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TrimPreviewState {
|
||||||
|
clipId: string
|
||||||
|
startTime: number
|
||||||
|
endTime: number
|
||||||
|
duration: number
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const MIN_TRIM_DURATION = 1
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 裁剪拖拽 Hook
|
||||||
|
* 拖拽片段两端手柄调整入点/出点,实时显示预览
|
||||||
|
*/
|
||||||
|
export const useTrimDrag = (
|
||||||
|
clips: ClipData[],
|
||||||
|
pps: number,
|
||||||
|
onClipTrim?: (clipId: string, trimConfig: TrimConfig, newDuration: number) => void,
|
||||||
|
) => {
|
||||||
|
const [trimDrag, setTrimDrag] = useState<TrimDragState | null>(null)
|
||||||
|
const [trimPreview, setTrimPreview] = useState<TrimPreviewState | null>(null)
|
||||||
|
|
||||||
|
const handleTrimHandleMouseDown = useCallback(
|
||||||
|
(e: React.MouseEvent, clipId: string, direction: "left" | "right") => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
const clip = clips.find((c) => c.id === clipId)
|
||||||
|
if (!clip) return
|
||||||
|
|
||||||
|
const trim: TrimConfig = clip.trim_config ?? {
|
||||||
|
start_time: 0,
|
||||||
|
end_time: clip.duration,
|
||||||
|
original_duration: clip.duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
setTrimDrag({
|
||||||
|
clipId,
|
||||||
|
direction,
|
||||||
|
startX: e.clientX,
|
||||||
|
originalTrim: { ...trim },
|
||||||
|
originalDuration: clip.duration,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[clips],
|
||||||
|
)
|
||||||
|
|
||||||
|
/* ── 裁剪拖拽全局 mousemove/mouseup ── */
|
||||||
|
useEffect(() => {
|
||||||
|
if (!trimDrag) return
|
||||||
|
|
||||||
|
const handleMouseMove = (e: MouseEvent) => {
|
||||||
|
const dx = e.clientX - trimDrag.startX
|
||||||
|
const dtSec = dx / pps
|
||||||
|
const clip = clips.find((c) => c.id === trimDrag.clipId)
|
||||||
|
if (!clip) return
|
||||||
|
|
||||||
|
const origTrim = trimDrag.originalTrim
|
||||||
|
const origDur = origTrim.original_duration ?? trimDrag.originalDuration
|
||||||
|
let newStart = origTrim.start_time
|
||||||
|
let newEnd = origTrim.end_time
|
||||||
|
|
||||||
|
if (trimDrag.direction === "left") {
|
||||||
|
newStart = Math.max(0, Math.min(origTrim.start_time + dtSec, newEnd - MIN_TRIM_DURATION))
|
||||||
|
} else {
|
||||||
|
newEnd = Math.max(
|
||||||
|
origTrim.start_time + MIN_TRIM_DURATION,
|
||||||
|
Math.min(origTrim.end_time + dtSec, origDur),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const newDuration = Math.round((newEnd - newStart) * 10) / 10
|
||||||
|
|
||||||
|
setTrimPreview({
|
||||||
|
clipId: trimDrag.clipId,
|
||||||
|
startTime: Math.round(newStart * 10) / 10,
|
||||||
|
endTime: Math.round(newEnd * 10) / 10,
|
||||||
|
duration: newDuration,
|
||||||
|
x: e.clientX,
|
||||||
|
y: e.clientY,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMouseUp = () => {
|
||||||
|
if (trimPreview && trimPreview.clipId === trimDrag.clipId && onClipTrim) {
|
||||||
|
const newTrim: TrimConfig = {
|
||||||
|
start_time: trimPreview.startTime,
|
||||||
|
end_time: trimPreview.endTime,
|
||||||
|
original_duration: trimDrag.originalTrim.original_duration ?? trimDrag.originalDuration,
|
||||||
|
}
|
||||||
|
onClipTrim(trimDrag.clipId, newTrim, trimPreview.duration)
|
||||||
|
}
|
||||||
|
setTrimDrag(null)
|
||||||
|
setTrimPreview(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("mousemove", handleMouseMove)
|
||||||
|
document.addEventListener("mouseup", handleMouseUp)
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousemove", handleMouseMove)
|
||||||
|
document.removeEventListener("mouseup", handleMouseUp)
|
||||||
|
}
|
||||||
|
}, [trimDrag, trimPreview, clips, pps, onClipTrim])
|
||||||
|
|
||||||
|
return {
|
||||||
|
trimDrag,
|
||||||
|
trimPreview,
|
||||||
|
handleTrimHandleMouseDown,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,230 +2,93 @@
|
|||||||
* 智能剪辑页面 — V21 原型 1:1 还原
|
* 智能剪辑页面 — V21 原型 1:1 还原
|
||||||
* 7 步向导:选择模板 → 选择素材 → 生成预览 → 选择标题 → 选择配音 → 选择封面 → 确认生成
|
* 7 步向导:选择模板 → 选择素材 → 生成预览 → 选择标题 → 选择配音 → 选择封面 → 确认生成
|
||||||
* 左右布局:左侧 generate-form + 右侧 generate-preview
|
* 左右布局:左侧 generate-form + 右侧 generate-preview
|
||||||
* 主组件仅保留共享状态、步骤切换、整体布局
|
* 主组件仅保留整体布局与事件编排
|
||||||
* 各 Step 的 UI 与业务逻辑拆分至 components/ + hooks/
|
* 状态管理 → hooks/useGenerateFormState
|
||||||
* 生成核心逻辑封装在 useGenerateVideo hook
|
* 步骤导航 → hooks/useStepNavigation
|
||||||
|
* 步骤内容 → components/GenerateStepContent
|
||||||
|
* 底部按钮 → components/GenerateStepActions
|
||||||
|
* 生成核心逻辑 → hooks/useGenerateVideo
|
||||||
*/
|
*/
|
||||||
import React, { useState, useEffect, useMemo } from "react"
|
import React from "react"
|
||||||
import { useQuery } from "@tanstack/react-query"
|
import { Modal, message } from "antd"
|
||||||
import { message, Modal } from "antd"
|
import { useNavigate } from "react-router-dom"
|
||||||
import { ThunderboltOutlined } from "@ant-design/icons"
|
|
||||||
import type { GeneratedVideo, TitleConfig } from "@/api/template-editor"
|
|
||||||
import { getEditPlan } from "@/api/template-editor"
|
|
||||||
import type { CoverConfig } from "../editing-planner/types"
|
|
||||||
import { getEditingTemplates } from "@/api/editing-planner"
|
|
||||||
import type { PresetVoiceItem } from "@/api/voices"
|
|
||||||
import { fetchPresetVoices } from "@/api/voices"
|
|
||||||
import type { VoiceClone } from "@/api/voice-clone"
|
import type { VoiceClone } from "@/api/voice-clone"
|
||||||
import { useCloneProgress } from "@/hooks/useCloneProgress"
|
import { useCloneProgress } from "@/hooks/useCloneProgress"
|
||||||
import CloneModal from "@/components/voice/CloneModal"
|
import CloneModal from "@/components/voice/CloneModal"
|
||||||
import { useSearchParams, useNavigate } from "react-router-dom"
|
|
||||||
import GenerateHeader from "./components/GenerateHeader"
|
import GenerateHeader from "./components/GenerateHeader"
|
||||||
import GenerateStepsBar from "./components/GenerateStepsBar"
|
import GenerateStepsBar from "./components/GenerateStepsBar"
|
||||||
import GenerateResultPanel from "./components/GenerateResultPanel"
|
import GenerateResultPanel from "./components/GenerateResultPanel"
|
||||||
import Step1TemplateSelect from "./components/Step1TemplateSelect"
|
import GenerateStepContent from "./components/GenerateStepContent"
|
||||||
import Step2MaterialSelect from "./components/Step2MaterialSelect"
|
import GenerateStepActions from "./components/GenerateStepActions"
|
||||||
import Step3GeneratePreview from "./components/Step3GeneratePreview"
|
import { useGenerateFormState } from "./hooks/useGenerateFormState"
|
||||||
import Step4TitleSettings from "./components/Step4TitleSettings"
|
import { useStepNavigation } from "./hooks/useStepNavigation"
|
||||||
import Step5VoiceSelect from "./components/Step5VoiceSelect"
|
|
||||||
import Step6CoverSettings from "./components/Step6CoverSettings"
|
|
||||||
import Step7ConfirmGenerate from "./components/Step7ConfirmGenerate"
|
|
||||||
import { DEFAULT_COVER_SETTINGS } from "./constants"
|
|
||||||
import type { TitleSettings } from "./types"
|
|
||||||
import { useGenerateVideo } from "./hooks/useGenerateVideo"
|
import { useGenerateVideo } from "./hooks/useGenerateVideo"
|
||||||
import "./generate.css"
|
import "./generate.css"
|
||||||
|
|
||||||
const DEFAULT_TITLE_SETTINGS: TitleSettings = {
|
|
||||||
aiAutoSelect: false,
|
|
||||||
title: "",
|
|
||||||
position: "bottom",
|
|
||||||
font: "思源黑体",
|
|
||||||
size: 28,
|
|
||||||
bold: true,
|
|
||||||
italic: false,
|
|
||||||
stroke: true,
|
|
||||||
shadow: false,
|
|
||||||
color: "#ffffff",
|
|
||||||
}
|
|
||||||
|
|
||||||
const GeneratePage: React.FC = () => {
|
const GeneratePage: React.FC = () => {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
/* ── 步骤状态 ── */
|
/* ── 表单状态 ── */
|
||||||
const [currentStep, setCurrentStep] = useState(1)
|
const formState = useGenerateFormState()
|
||||||
|
const {
|
||||||
|
currentStep,
|
||||||
|
setCurrentStep,
|
||||||
|
selectedTemplate,
|
||||||
|
setSelectedTemplate,
|
||||||
|
userTemplates,
|
||||||
|
selectedMaterials,
|
||||||
|
setSelectedMaterials,
|
||||||
|
materialMode,
|
||||||
|
setMaterialMode,
|
||||||
|
smartSelectedIds,
|
||||||
|
setSmartSelectedIds,
|
||||||
|
titleSettings,
|
||||||
|
setTitleSettings,
|
||||||
|
coverSettings,
|
||||||
|
setCoverSettings,
|
||||||
|
selectedVoice,
|
||||||
|
setSelectedVoice,
|
||||||
|
voiceMode,
|
||||||
|
setVoiceMode,
|
||||||
|
selectedClonedVoice,
|
||||||
|
setSelectedClonedVoice,
|
||||||
|
presetVoices,
|
||||||
|
cloneModalOpen,
|
||||||
|
setCloneModalOpen,
|
||||||
|
generateCount,
|
||||||
|
setGenerateCount,
|
||||||
|
videoRatio,
|
||||||
|
duration,
|
||||||
|
style,
|
||||||
|
autoSubtitles,
|
||||||
|
bgm,
|
||||||
|
editPlanId,
|
||||||
|
previewVideo,
|
||||||
|
setPreviewVideo,
|
||||||
|
previewModalOpen,
|
||||||
|
setPreviewModalOpen,
|
||||||
|
} = formState
|
||||||
|
|
||||||
/* ── 模板(从 API 加载) ── */
|
/* ── 克隆声音 ── */
|
||||||
const [selectedTemplate, setSelectedTemplate] = useState("")
|
|
||||||
const { data: userTemplates = [] } = useQuery({
|
|
||||||
queryKey: ["generate-templates"],
|
|
||||||
queryFn: () => getEditingTemplates(),
|
|
||||||
staleTime: 60_000,
|
|
||||||
})
|
|
||||||
/* 模板加载完成后自动选中第一个 */
|
|
||||||
useEffect(() => {
|
|
||||||
if (userTemplates.length > 0 && !selectedTemplate) {
|
|
||||||
setSelectedTemplate(userTemplates[0].id)
|
|
||||||
}
|
|
||||||
}, [userTemplates, selectedTemplate])
|
|
||||||
|
|
||||||
/* ── 素材(共享:step2 选择、step7 展示、生成使用) ── */
|
|
||||||
const [selectedMaterials, setSelectedMaterials] = useState<string[]>([])
|
|
||||||
const [materialMode, setMaterialMode] = useState<"manual" | "auto">("manual")
|
|
||||||
const [smartSelectedIds, setSmartSelectedIds] = useState<string[]>([])
|
|
||||||
|
|
||||||
/* ── 标题设置(共享:step4 编辑、step7 展示、生成使用) ── */
|
|
||||||
const [titleSettings, setTitleSettings] = useState<TitleSettings>(DEFAULT_TITLE_SETTINGS)
|
|
||||||
|
|
||||||
/* ── 封面设置(共享:step6 编辑、step7 展示、生成使用) ── */
|
|
||||||
const [coverSettings, setCoverSettings] = useState<CoverConfig>(DEFAULT_COVER_SETTINGS)
|
|
||||||
|
|
||||||
/* 当选中模板开启了「AI自动匹配标题」,自动填入模板预设标题 / 封面 */
|
|
||||||
useEffect(() => {
|
|
||||||
const tpl = userTemplates.find((t) => t.id === selectedTemplate)
|
|
||||||
if (tpl?.title_config) {
|
|
||||||
setTitleSettings((prev) => ({
|
|
||||||
...prev,
|
|
||||||
aiAutoSelect: tpl.title_config!.ai_auto_select,
|
|
||||||
title: tpl.title_config!.content || prev.title,
|
|
||||||
position: tpl.title_config!.position || prev.position,
|
|
||||||
font: tpl.title_config!.font_preset || prev.font,
|
|
||||||
size: tpl.title_config!.font_size || prev.size,
|
|
||||||
color: tpl.title_config!.font_color || prev.color,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
if (tpl?.cover_config) {
|
|
||||||
setCoverSettings((prev) => ({
|
|
||||||
...prev,
|
|
||||||
enabled: tpl.cover_config!.enabled ?? prev.enabled,
|
|
||||||
mode: (tpl.cover_config!.mode as CoverConfig["mode"]) || prev.mode,
|
|
||||||
frame_time: tpl.cover_config!.frame_time ?? prev.frame_time,
|
|
||||||
upload_url: tpl.cover_config!.upload_url || prev.upload_url,
|
|
||||||
ai_suggested_time: tpl.cover_config!.ai_suggested_time ?? prev.ai_suggested_time,
|
|
||||||
thumbnail_url: tpl.cover_config!.thumbnail_url || prev.thumbnail_url,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
}, [selectedTemplate, userTemplates])
|
|
||||||
|
|
||||||
/* ── 配音(共享:step5 选择、step7 展示、生成使用) ── */
|
|
||||||
const [selectedVoice, setSelectedVoice] = useState<string>("")
|
|
||||||
const [voiceMode, setVoiceMode] = useState<"preset" | "custom" | "clone">("preset")
|
|
||||||
const [selectedClonedVoice, setSelectedClonedVoice] = useState<string>("")
|
|
||||||
|
|
||||||
/* ── 预置音色 API(共享:step5 选择、step7 展示) ── */
|
|
||||||
const { data: presetVoicesData } = useQuery({
|
|
||||||
queryKey: ["preset-voices"],
|
|
||||||
queryFn: fetchPresetVoices,
|
|
||||||
})
|
|
||||||
const presetVoices: PresetVoiceItem[] = useMemo(
|
|
||||||
() => presetVoicesData?.items ?? [],
|
|
||||||
[presetVoicesData],
|
|
||||||
)
|
|
||||||
|
|
||||||
/* ── 克隆声音(共享:step5 管理、step7 展示) ── */
|
|
||||||
const [cloneModalOpen, setCloneModalOpen] = useState(false)
|
|
||||||
const { clones: clonedVoices, addClone, hasProcessing } = useCloneProgress()
|
const { clones: clonedVoices, addClone, hasProcessing } = useCloneProgress()
|
||||||
|
|
||||||
/* ── 生成数量 ── */
|
|
||||||
const [generateCount, setGenerateCount] = useState(1)
|
|
||||||
|
|
||||||
/* ── 高级设置(隐藏但保留) ── */
|
|
||||||
const [videoRatio] = useState("16:9")
|
|
||||||
const [duration] = useState(30)
|
|
||||||
const [style] = useState("business")
|
|
||||||
const [autoSubtitles] = useState(true)
|
|
||||||
const [bgm] = useState(true)
|
|
||||||
|
|
||||||
/* ── URL 参数:从模板编辑器跳转过来时携带 edit_plan_id + plan_config ── */
|
|
||||||
const [searchParams] = useSearchParams()
|
|
||||||
const editPlanId = searchParams.get("edit_plan_id")
|
|
||||||
const planConfigStr = searchParams.get("plan_config")
|
|
||||||
|
|
||||||
/** 解析 plan_config 并自动填充表单 */
|
|
||||||
useEffect(() => {
|
|
||||||
if (!planConfigStr) return
|
|
||||||
try {
|
|
||||||
const config = JSON.parse(planConfigStr) as {
|
|
||||||
title_config?: { content?: string; ai_auto_select?: boolean }
|
|
||||||
subtitle_config?: { enabled?: boolean }
|
|
||||||
bgm_config?: { enabled?: boolean; music_id?: string }
|
|
||||||
mode?: string
|
|
||||||
total_duration?: number
|
|
||||||
segments?: Array<{ media_asset_id?: string; material_type?: string }>
|
|
||||||
}
|
|
||||||
|
|
||||||
if (config.title_config) {
|
|
||||||
const tc = config.title_config as TitleConfig
|
|
||||||
setTitleSettings((prev) => ({
|
|
||||||
...prev,
|
|
||||||
title: tc.content || "",
|
|
||||||
aiAutoSelect: tc.ai_auto_select || false,
|
|
||||||
position: tc.position || prev.position,
|
|
||||||
font: tc.font_preset || prev.font,
|
|
||||||
size: tc.font_size || prev.size,
|
|
||||||
color: tc.font_color || prev.color,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
if (config.segments && config.segments.length > 0) {
|
|
||||||
const assetIds = config.segments
|
|
||||||
.map((s) => s.media_asset_id)
|
|
||||||
.filter((id): id is string => !!id)
|
|
||||||
if (assetIds.length > 0) {
|
|
||||||
setSelectedMaterials(assetIds)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.warn("解析 plan_config 失败:", err)
|
|
||||||
}
|
|
||||||
}, [planConfigStr])
|
|
||||||
|
|
||||||
/** 如果没有 plan_config,尝试通过 edit_plan_id 从后端拉取配置 */
|
|
||||||
useEffect(() => {
|
|
||||||
if (!editPlanId || planConfigStr) return
|
|
||||||
const loadPlanConfig = async () => {
|
|
||||||
try {
|
|
||||||
const plan = await getEditPlan(editPlanId)
|
|
||||||
if (plan.name) setTitleSettings((prev) => ({ ...prev, title: plan.name }))
|
|
||||||
const cfg = plan.config
|
|
||||||
if (cfg?.title_config) {
|
|
||||||
setTitleSettings((prev) => ({
|
|
||||||
...prev,
|
|
||||||
aiAutoSelect: cfg.title_config!.ai_auto_select,
|
|
||||||
title: cfg.title_config!.content || prev.title,
|
|
||||||
position: cfg.title_config!.position || prev.position,
|
|
||||||
font: cfg.title_config!.font_preset || prev.font,
|
|
||||||
size: cfg.title_config!.font_size || prev.size,
|
|
||||||
color: cfg.title_config!.font_color || prev.color,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
if (cfg?.cover_config) {
|
|
||||||
const cc = cfg.cover_config as CoverConfig
|
|
||||||
setCoverSettings((prev) => ({
|
|
||||||
...prev,
|
|
||||||
enabled: cc.enabled ?? prev.enabled,
|
|
||||||
mode: cc.mode || prev.mode,
|
|
||||||
frame_time: cc.frame_time ?? prev.frame_time,
|
|
||||||
upload_url: cc.upload_url || prev.upload_url,
|
|
||||||
ai_suggested_time: cc.ai_suggested_time ?? prev.ai_suggested_time,
|
|
||||||
thumbnail_url: cc.thumbnail_url || prev.thumbnail_url,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
if (cfg?.asset_ids) {
|
|
||||||
setSelectedMaterials(cfg.asset_ids.filter((v): v is string => typeof v === "string"))
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.warn("加载模板草稿配置失败:", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
loadPlanConfig()
|
|
||||||
}, [editPlanId, planConfigStr])
|
|
||||||
|
|
||||||
/* ── 克隆成功回调 ── */
|
|
||||||
const handleCloneSuccess = (voice: VoiceClone) => {
|
const handleCloneSuccess = (voice: VoiceClone) => {
|
||||||
addClone(voice)
|
addClone(voice)
|
||||||
setCloneModalOpen(false)
|
setCloneModalOpen(false)
|
||||||
message.success("音色克隆成功!")
|
message.success("音色克隆成功!")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── 步骤导航 ── */
|
||||||
|
const { goNext, goPrev } = useStepNavigation({
|
||||||
|
currentStep,
|
||||||
|
setCurrentStep,
|
||||||
|
selectedTemplate,
|
||||||
|
materialMode,
|
||||||
|
selectedMaterials,
|
||||||
|
smartSelectedIds,
|
||||||
|
titleSettings,
|
||||||
|
})
|
||||||
|
|
||||||
/* ── 视频生成核心逻辑 ── */
|
/* ── 视频生成核心逻辑 ── */
|
||||||
const {
|
const {
|
||||||
generating,
|
generating,
|
||||||
@@ -256,136 +119,6 @@ const GeneratePage: React.FC = () => {
|
|||||||
generateCount,
|
generateCount,
|
||||||
})
|
})
|
||||||
|
|
||||||
/* ── 预览弹窗状态 ── */
|
|
||||||
const [previewVideo, setPreviewVideo] = useState<GeneratedVideo | null>(null)
|
|
||||||
const [previewModalOpen, setPreviewModalOpen] = useState(false)
|
|
||||||
|
|
||||||
/* ── 步骤导航 ── */
|
|
||||||
const goNext = () => {
|
|
||||||
if (currentStep === 1 && !selectedTemplate) {
|
|
||||||
message.warning("请先选择一个模板")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (currentStep === 2 && materialMode === "manual" && selectedMaterials.length === 0) {
|
|
||||||
message.warning("请至少选择一个素材")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (currentStep === 2 && materialMode === "auto" && smartSelectedIds.length === 0) {
|
|
||||||
message.warning("请先进行智能匹配并选择素材")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (currentStep === 4 && !titleSettings.title.trim()) {
|
|
||||||
message.warning("请选择或输入标题")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (currentStep < 7) {
|
|
||||||
setCurrentStep((s) => s + 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const goPrev = () => {
|
|
||||||
if (currentStep > 1) {
|
|
||||||
setCurrentStep((s) => s - 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── 渲染当前步骤 ── */
|
|
||||||
const renderCurrentStep = () => {
|
|
||||||
switch (currentStep) {
|
|
||||||
case 1:
|
|
||||||
return (
|
|
||||||
<Step1TemplateSelect
|
|
||||||
templates={userTemplates}
|
|
||||||
selectedTemplate={selectedTemplate}
|
|
||||||
onSelectTemplate={setSelectedTemplate}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
case 2:
|
|
||||||
return (
|
|
||||||
<Step2MaterialSelect
|
|
||||||
materialMode={materialMode}
|
|
||||||
onMaterialModeChange={setMaterialMode}
|
|
||||||
selectedMaterials={selectedMaterials}
|
|
||||||
onSelectedMaterialsChange={setSelectedMaterials}
|
|
||||||
smartSelectedIds={smartSelectedIds}
|
|
||||||
onSmartSelectedIdsChange={setSmartSelectedIds}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
case 3:
|
|
||||||
return (
|
|
||||||
<Step3GeneratePreview
|
|
||||||
templates={userTemplates}
|
|
||||||
selectedTemplate={selectedTemplate}
|
|
||||||
materialMode={materialMode}
|
|
||||||
selectedMaterials={selectedMaterials}
|
|
||||||
smartSelectedIds={smartSelectedIds}
|
|
||||||
duration={duration}
|
|
||||||
videoRatio={videoRatio}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
case 4:
|
|
||||||
return (
|
|
||||||
<Step4TitleSettings
|
|
||||||
titleSettings={titleSettings}
|
|
||||||
onTitleSettingsChange={setTitleSettings}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
case 5:
|
|
||||||
return (
|
|
||||||
<Step5VoiceSelect
|
|
||||||
selectedVoice={selectedVoice}
|
|
||||||
onSelectedVoiceChange={setSelectedVoice}
|
|
||||||
voiceMode={voiceMode}
|
|
||||||
onVoiceModeChange={setVoiceMode}
|
|
||||||
selectedClonedVoice={selectedClonedVoice}
|
|
||||||
onSelectedClonedVoiceChange={setSelectedClonedVoice}
|
|
||||||
clonedVoices={clonedVoices}
|
|
||||||
addClone={addClone}
|
|
||||||
hasProcessing={hasProcessing}
|
|
||||||
cloneModalOpen={cloneModalOpen}
|
|
||||||
onCloneModalOpenChange={setCloneModalOpen}
|
|
||||||
titleText={titleSettings.title}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
case 6:
|
|
||||||
return (
|
|
||||||
<Step6CoverSettings
|
|
||||||
coverSettings={coverSettings}
|
|
||||||
onCoverSettingsChange={setCoverSettings}
|
|
||||||
duration={duration}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
case 7:
|
|
||||||
return (
|
|
||||||
<Step7ConfirmGenerate
|
|
||||||
templates={userTemplates}
|
|
||||||
selectedTemplate={selectedTemplate}
|
|
||||||
materialMode={materialMode}
|
|
||||||
selectedMaterials={selectedMaterials}
|
|
||||||
smartSelectedIds={smartSelectedIds}
|
|
||||||
title={titleSettings.title}
|
|
||||||
voiceMode={voiceMode}
|
|
||||||
selectedVoice={selectedVoice}
|
|
||||||
selectedClonedVoice={selectedClonedVoice}
|
|
||||||
presetVoices={presetVoices}
|
|
||||||
clonedVoices={clonedVoices}
|
|
||||||
coverSettings={coverSettings}
|
|
||||||
generateCount={generateCount}
|
|
||||||
onGenerateCountChange={setGenerateCount}
|
|
||||||
generating={generating}
|
|
||||||
generated={generated}
|
|
||||||
generateError={generateError}
|
|
||||||
progress={progress}
|
|
||||||
generatedVideos={generatedVideos}
|
|
||||||
onRetry={handleRetryGenerate}
|
|
||||||
onDismissError={handleDismissError}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
default:
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ================================================================
|
/* ================================================================
|
||||||
渲染 — 主页面
|
渲染 — 主页面
|
||||||
================================================================ */
|
================================================================ */
|
||||||
@@ -402,35 +135,55 @@ const GeneratePage: React.FC = () => {
|
|||||||
<div className="xx-generate-layout">
|
<div className="xx-generate-layout">
|
||||||
{/* ════ 左侧:表单区 ════ */}
|
{/* ════ 左侧:表单区 ════ */}
|
||||||
<div className="xx-generate-form">
|
<div className="xx-generate-form">
|
||||||
{/* 当前步骤 */}
|
<GenerateStepContent
|
||||||
{renderCurrentStep()}
|
currentStep={currentStep}
|
||||||
|
userTemplates={userTemplates}
|
||||||
|
selectedTemplate={selectedTemplate}
|
||||||
|
onSelectTemplate={setSelectedTemplate}
|
||||||
|
materialMode={materialMode}
|
||||||
|
onMaterialModeChange={setMaterialMode}
|
||||||
|
selectedMaterials={selectedMaterials}
|
||||||
|
onSelectedMaterialsChange={setSelectedMaterials}
|
||||||
|
smartSelectedIds={smartSelectedIds}
|
||||||
|
onSmartSelectedIdsChange={setSmartSelectedIds}
|
||||||
|
titleSettings={titleSettings}
|
||||||
|
onTitleSettingsChange={setTitleSettings}
|
||||||
|
coverSettings={coverSettings}
|
||||||
|
onCoverSettingsChange={setCoverSettings}
|
||||||
|
duration={duration}
|
||||||
|
selectedVoice={selectedVoice}
|
||||||
|
onSelectedVoiceChange={setSelectedVoice}
|
||||||
|
voiceMode={voiceMode}
|
||||||
|
onVoiceModeChange={setVoiceMode}
|
||||||
|
selectedClonedVoice={selectedClonedVoice}
|
||||||
|
onSelectedClonedVoiceChange={setSelectedClonedVoice}
|
||||||
|
clonedVoices={clonedVoices}
|
||||||
|
addClone={addClone}
|
||||||
|
hasProcessing={hasProcessing}
|
||||||
|
cloneModalOpen={cloneModalOpen}
|
||||||
|
onCloneModalOpenChange={setCloneModalOpen}
|
||||||
|
generateCount={generateCount}
|
||||||
|
onGenerateCountChange={setGenerateCount}
|
||||||
|
generating={generating}
|
||||||
|
generated={generated}
|
||||||
|
generateError={generateError}
|
||||||
|
progress={progress}
|
||||||
|
generatedVideos={generatedVideos}
|
||||||
|
onRetry={handleRetryGenerate}
|
||||||
|
onDismissError={handleDismissError}
|
||||||
|
presetVoices={presetVoices}
|
||||||
|
videoRatio={videoRatio}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* 底部操作按钮 */}
|
<GenerateStepActions
|
||||||
<div className="xx-step-actions">
|
currentStep={currentStep}
|
||||||
<button className="xx-btn xx-btn-ghost" onClick={goPrev} disabled={currentStep === 1}>
|
onPrev={goPrev}
|
||||||
← 上一步
|
onNext={goNext}
|
||||||
</button>
|
onGenerate={handleGenerate}
|
||||||
{currentStep < 7 ? (
|
generating={generating}
|
||||||
<button className="xx-btn xx-btn-primary" onClick={goNext}>
|
generated={generated}
|
||||||
下一步 →
|
generateError={generateError}
|
||||||
</button>
|
/>
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
className="xx-btn xx-btn-primary"
|
|
||||||
onClick={handleGenerate}
|
|
||||||
disabled={generating || (generated && !generateError)}
|
|
||||||
>
|
|
||||||
<ThunderboltOutlined />
|
|
||||||
{generating
|
|
||||||
? "生成中…"
|
|
||||||
: generated && !generateError
|
|
||||||
? "已生成"
|
|
||||||
: generateError
|
|
||||||
? "🔄 重新生成"
|
|
||||||
: "✨ 确认生成"}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ════ 右侧:生成结果 ════ */}
|
{/* ════ 右侧:生成结果 ════ */}
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
/**
|
||||||
|
* GeneratePage 步骤底部操作按钮
|
||||||
|
*/
|
||||||
|
import React from "react"
|
||||||
|
import { ThunderboltOutlined } from "@ant-design/icons"
|
||||||
|
|
||||||
|
export interface GenerateStepActionsProps {
|
||||||
|
currentStep: number
|
||||||
|
onPrev: () => void
|
||||||
|
onNext: () => void
|
||||||
|
onGenerate: () => void
|
||||||
|
generating: boolean
|
||||||
|
generated: boolean
|
||||||
|
generateError: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GenerateStepActions: React.FC<GenerateStepActionsProps> = ({
|
||||||
|
currentStep,
|
||||||
|
onPrev,
|
||||||
|
onNext,
|
||||||
|
onGenerate,
|
||||||
|
generating,
|
||||||
|
generated,
|
||||||
|
generateError,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className="xx-step-actions">
|
||||||
|
<button className="xx-btn xx-btn-ghost" onClick={onPrev} disabled={currentStep === 1}>
|
||||||
|
← 上一步
|
||||||
|
</button>
|
||||||
|
{currentStep < 7 ? (
|
||||||
|
<button className="xx-btn xx-btn-primary" onClick={onNext}>
|
||||||
|
下一步 →
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="xx-btn xx-btn-primary"
|
||||||
|
onClick={onGenerate}
|
||||||
|
disabled={generating || (generated && !generateError)}
|
||||||
|
>
|
||||||
|
<ThunderboltOutlined />
|
||||||
|
{generating
|
||||||
|
? "生成中…"
|
||||||
|
: generated && !generateError
|
||||||
|
? "已生成"
|
||||||
|
: generateError
|
||||||
|
? "🔄 重新生成"
|
||||||
|
: "✨ 确认生成"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default GenerateStepActions
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
/**
|
||||||
|
* GeneratePage 步骤内容渲染
|
||||||
|
* 根据当前步骤渲染对应的 Step 组件
|
||||||
|
*/
|
||||||
|
import React from "react"
|
||||||
|
import type { EditingTemplate } from "@/api/editing-planner"
|
||||||
|
import type { PresetVoiceItem } from "@/api/voices"
|
||||||
|
import type { VoiceClone } from "@/api/voice-clone"
|
||||||
|
import type { CoverConfig } from "../../editing-planner/types"
|
||||||
|
import type { TitleSettings } from "../types"
|
||||||
|
import Step1TemplateSelect from "../components/Step1TemplateSelect"
|
||||||
|
import Step2MaterialSelect from "../components/Step2MaterialSelect"
|
||||||
|
import Step3GeneratePreview from "../components/Step3GeneratePreview"
|
||||||
|
import Step4TitleSettings from "../components/Step4TitleSettings"
|
||||||
|
import Step5VoiceSelect from "../components/Step5VoiceSelect"
|
||||||
|
import Step6CoverSettings from "../components/Step6CoverSettings"
|
||||||
|
import Step7ConfirmGenerate from "../components/Step7ConfirmGenerate"
|
||||||
|
import type { GeneratedVideo } from "@/api/template-editor"
|
||||||
|
|
||||||
|
export interface GenerateStepContentProps {
|
||||||
|
currentStep: number
|
||||||
|
/* 模板 */
|
||||||
|
userTemplates: EditingTemplate[]
|
||||||
|
selectedTemplate: string
|
||||||
|
onSelectTemplate: (id: string) => void
|
||||||
|
/* 素材 */
|
||||||
|
materialMode: "manual" | "auto"
|
||||||
|
onMaterialModeChange: (mode: "manual" | "auto") => void
|
||||||
|
selectedMaterials: string[]
|
||||||
|
onSelectedMaterialsChange: (ids: string[]) => void
|
||||||
|
smartSelectedIds: string[]
|
||||||
|
onSmartSelectedIdsChange: (ids: string[]) => void
|
||||||
|
/* 标题 */
|
||||||
|
titleSettings: TitleSettings
|
||||||
|
onTitleSettingsChange: (settings: TitleSettings) => void
|
||||||
|
/* 封面 */
|
||||||
|
coverSettings: CoverConfig
|
||||||
|
onCoverSettingsChange: (settings: CoverConfig) => void
|
||||||
|
duration: number
|
||||||
|
/* 配音 */
|
||||||
|
selectedVoice: string
|
||||||
|
onSelectedVoiceChange: (id: string) => void
|
||||||
|
voiceMode: "preset" | "custom" | "clone"
|
||||||
|
onVoiceModeChange: (mode: "preset" | "custom" | "clone") => void
|
||||||
|
selectedClonedVoice: string
|
||||||
|
onSelectedClonedVoiceChange: (id: string) => void
|
||||||
|
clonedVoices: VoiceClone[]
|
||||||
|
addClone: (voice: VoiceClone) => void
|
||||||
|
hasProcessing: boolean
|
||||||
|
cloneModalOpen: boolean
|
||||||
|
onCloneModalOpenChange: (open: boolean) => void
|
||||||
|
/* 生成 */
|
||||||
|
generateCount: number
|
||||||
|
onGenerateCountChange: (n: number) => void
|
||||||
|
generating: boolean
|
||||||
|
generated: boolean
|
||||||
|
generateError: string | null
|
||||||
|
progress: number
|
||||||
|
generatedVideos: GeneratedVideo[]
|
||||||
|
onRetry: () => void
|
||||||
|
onDismissError: () => void
|
||||||
|
/* 其他 */
|
||||||
|
presetVoices: PresetVoiceItem[]
|
||||||
|
videoRatio: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) => {
|
||||||
|
const {
|
||||||
|
currentStep,
|
||||||
|
userTemplates,
|
||||||
|
selectedTemplate,
|
||||||
|
onSelectTemplate,
|
||||||
|
materialMode,
|
||||||
|
onMaterialModeChange,
|
||||||
|
selectedMaterials,
|
||||||
|
onSelectedMaterialsChange,
|
||||||
|
smartSelectedIds,
|
||||||
|
onSmartSelectedIdsChange,
|
||||||
|
titleSettings,
|
||||||
|
onTitleSettingsChange,
|
||||||
|
coverSettings,
|
||||||
|
onCoverSettingsChange,
|
||||||
|
duration,
|
||||||
|
selectedVoice,
|
||||||
|
onSelectedVoiceChange,
|
||||||
|
voiceMode,
|
||||||
|
onVoiceModeChange,
|
||||||
|
selectedClonedVoice,
|
||||||
|
onSelectedClonedVoiceChange,
|
||||||
|
clonedVoices,
|
||||||
|
addClone,
|
||||||
|
hasProcessing,
|
||||||
|
cloneModalOpen,
|
||||||
|
onCloneModalOpenChange,
|
||||||
|
generateCount,
|
||||||
|
onGenerateCountChange,
|
||||||
|
generating,
|
||||||
|
generated,
|
||||||
|
generateError,
|
||||||
|
progress,
|
||||||
|
generatedVideos,
|
||||||
|
onRetry,
|
||||||
|
onDismissError,
|
||||||
|
presetVoices,
|
||||||
|
videoRatio,
|
||||||
|
} = props
|
||||||
|
|
||||||
|
switch (currentStep) {
|
||||||
|
case 1:
|
||||||
|
return (
|
||||||
|
<Step1TemplateSelect
|
||||||
|
templates={userTemplates}
|
||||||
|
selectedTemplate={selectedTemplate}
|
||||||
|
onSelectTemplate={onSelectTemplate}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
case 2:
|
||||||
|
return (
|
||||||
|
<Step2MaterialSelect
|
||||||
|
materialMode={materialMode}
|
||||||
|
onMaterialModeChange={onMaterialModeChange}
|
||||||
|
selectedMaterials={selectedMaterials}
|
||||||
|
onSelectedMaterialsChange={onSelectedMaterialsChange}
|
||||||
|
smartSelectedIds={smartSelectedIds}
|
||||||
|
onSmartSelectedIdsChange={onSmartSelectedIdsChange}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
case 3:
|
||||||
|
return (
|
||||||
|
<Step3GeneratePreview
|
||||||
|
templates={userTemplates}
|
||||||
|
selectedTemplate={selectedTemplate}
|
||||||
|
materialMode={materialMode}
|
||||||
|
selectedMaterials={selectedMaterials}
|
||||||
|
smartSelectedIds={smartSelectedIds}
|
||||||
|
duration={duration}
|
||||||
|
videoRatio={videoRatio}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
case 4:
|
||||||
|
return (
|
||||||
|
<Step4TitleSettings
|
||||||
|
titleSettings={titleSettings}
|
||||||
|
onTitleSettingsChange={onTitleSettingsChange}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
case 5:
|
||||||
|
return (
|
||||||
|
<Step5VoiceSelect
|
||||||
|
selectedVoice={selectedVoice}
|
||||||
|
onSelectedVoiceChange={onSelectedVoiceChange}
|
||||||
|
voiceMode={voiceMode}
|
||||||
|
onVoiceModeChange={onVoiceModeChange}
|
||||||
|
selectedClonedVoice={selectedClonedVoice}
|
||||||
|
onSelectedClonedVoiceChange={onSelectedClonedVoiceChange}
|
||||||
|
clonedVoices={clonedVoices}
|
||||||
|
addClone={addClone}
|
||||||
|
hasProcessing={hasProcessing}
|
||||||
|
cloneModalOpen={cloneModalOpen}
|
||||||
|
onCloneModalOpenChange={onCloneModalOpenChange}
|
||||||
|
titleText={titleSettings.title}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
case 6:
|
||||||
|
return (
|
||||||
|
<Step6CoverSettings
|
||||||
|
coverSettings={coverSettings}
|
||||||
|
onCoverSettingsChange={onCoverSettingsChange}
|
||||||
|
duration={duration}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
case 7:
|
||||||
|
return (
|
||||||
|
<Step7ConfirmGenerate
|
||||||
|
templates={userTemplates}
|
||||||
|
selectedTemplate={selectedTemplate}
|
||||||
|
materialMode={materialMode}
|
||||||
|
selectedMaterials={selectedMaterials}
|
||||||
|
smartSelectedIds={smartSelectedIds}
|
||||||
|
title={titleSettings.title}
|
||||||
|
voiceMode={voiceMode}
|
||||||
|
selectedVoice={selectedVoice}
|
||||||
|
selectedClonedVoice={selectedClonedVoice}
|
||||||
|
presetVoices={presetVoices}
|
||||||
|
clonedVoices={clonedVoices}
|
||||||
|
coverSettings={coverSettings}
|
||||||
|
generateCount={generateCount}
|
||||||
|
onGenerateCountChange={onGenerateCountChange}
|
||||||
|
generating={generating}
|
||||||
|
generated={generated}
|
||||||
|
generateError={generateError}
|
||||||
|
progress={progress}
|
||||||
|
generatedVideos={generatedVideos}
|
||||||
|
onRetry={onRetry}
|
||||||
|
onDismissError={onDismissError}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default GenerateStepContent
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
/**
|
||||||
|
* 错误提取工具
|
||||||
|
* 从各种响应格式中安全提取错误消息
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 安全提取字符串错误信息 */
|
||||||
|
export const safeExtractError = (val: unknown): string => {
|
||||||
|
if (typeof val === "string") return val
|
||||||
|
if (typeof val === "object" && val !== null) {
|
||||||
|
const obj = val as Record<string, unknown>
|
||||||
|
if (typeof obj.message === "string") return obj.message
|
||||||
|
if (typeof obj.msg === "string") return obj.msg
|
||||||
|
if (typeof obj.detail === "string") return obj.detail
|
||||||
|
if (typeof obj.message === "object" && obj.message !== null)
|
||||||
|
return safeExtractError(obj.message)
|
||||||
|
return JSON.stringify(val)
|
||||||
|
}
|
||||||
|
return String(val ?? "")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 错误信息翻译/友好化 */
|
||||||
|
export const translateError = (msg: string): string => {
|
||||||
|
if (!msg) return "生成失败,请检查网络后重试或联系管理员"
|
||||||
|
if (msg.includes("editing") || msg.includes("draft") || msg.includes("状态")) {
|
||||||
|
return "正在准备生成,请稍候再试"
|
||||||
|
}
|
||||||
|
if (msg.includes("template_id") || msg.includes("not found") || msg.includes("不存在")) {
|
||||||
|
return "所选模板或素材不可用,请重新选择"
|
||||||
|
}
|
||||||
|
if (msg.includes("asset") && (msg.includes("not found") || msg.includes("missing"))) {
|
||||||
|
return "素材数据异常,请返回视频库重新检查"
|
||||||
|
}
|
||||||
|
if (msg.includes("timeout") || msg.includes("network") || msg.includes("ECONN")) {
|
||||||
|
return "网络连接超时,请检查网络后重试"
|
||||||
|
}
|
||||||
|
if (msg.includes("quota") || msg.includes("limit") || msg.includes("exceed")) {
|
||||||
|
return "已达到生成次数上限,请稍后再试或联系客服"
|
||||||
|
}
|
||||||
|
if (msg.length > 0 && msg.length < 100 && !msg.includes("{")) return msg
|
||||||
|
return "生成失败,请稍后重试或联系管理员"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从 axios 错误对象中提取后端消息 */
|
||||||
|
export const extractBackendError = (err: unknown): string => {
|
||||||
|
const axiosErr = err as {
|
||||||
|
response?: {
|
||||||
|
data?: {
|
||||||
|
message?: string | object
|
||||||
|
error?: string | object
|
||||||
|
detail?: string | object
|
||||||
|
msg?: string | object
|
||||||
|
}
|
||||||
|
}
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
safeExtractError(axiosErr.response?.data?.message) ||
|
||||||
|
safeExtractError(axiosErr.response?.data?.error) ||
|
||||||
|
safeExtractError(axiosErr.response?.data?.detail) ||
|
||||||
|
safeExtractError(axiosErr.response?.data?.msg) ||
|
||||||
|
axiosErr.message ||
|
||||||
|
""
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import type { GenerationPhase } from "./types"
|
||||||
|
|
||||||
|
/** 生成阶段映射 */
|
||||||
|
export const getGenerationPhase = (p: number): GenerationPhase => {
|
||||||
|
if (p < 20) return { label: "分析素材与配置", icon: "🔍" }
|
||||||
|
if (p < 50) return { label: "智能剪辑合成", icon: "🎬" }
|
||||||
|
if (p < 80) return { label: "渲染视频中", icon: "⚡" }
|
||||||
|
return { label: "即将完成", icon: "✨" }
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import type { GeneratedVideo, EditPlanConfig } from "@/api/template-editor"
|
||||||
|
import type { CoverConfig } from "../../../editing-planner/types"
|
||||||
|
import type { TitleSettings } from "../../types"
|
||||||
|
|
||||||
|
/** useGenerateVideo 入参 */
|
||||||
|
export interface UseGenerateVideoProps {
|
||||||
|
titleSettings: TitleSettings
|
||||||
|
selectedTemplate: string
|
||||||
|
selectedMaterials: string[]
|
||||||
|
materialMode: "manual" | "auto"
|
||||||
|
smartSelectedIds: string[]
|
||||||
|
voiceMode: "preset" | "custom" | "clone"
|
||||||
|
selectedVoice: string
|
||||||
|
selectedClonedVoice: string
|
||||||
|
coverSettings: CoverConfig
|
||||||
|
videoRatio: string
|
||||||
|
style: string
|
||||||
|
duration: number
|
||||||
|
autoSubtitles: boolean
|
||||||
|
bgm: boolean
|
||||||
|
generateCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 生成阶段 */
|
||||||
|
export interface GenerationPhase {
|
||||||
|
label: string
|
||||||
|
icon: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** useGenerateVideo 返回值 */
|
||||||
|
export interface UseGenerateVideoResult {
|
||||||
|
generating: boolean
|
||||||
|
progress: number
|
||||||
|
generated: boolean
|
||||||
|
generateError: string | null
|
||||||
|
generatedVideos: GeneratedVideo[]
|
||||||
|
generate: () => Promise<void>
|
||||||
|
retry: () => void
|
||||||
|
dismissError: () => void
|
||||||
|
download: () => Promise<void>
|
||||||
|
share: () => Promise<void>
|
||||||
|
getGenerationPhase: (p: number) => GenerationPhase
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EditPlanVoiceConfig = Pick<
|
||||||
|
EditPlanConfig,
|
||||||
|
"voice_id" | "voice_clone_profile_id" | "custom_audio_url" | "custom_text"
|
||||||
|
>
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { useRef, useCallback } from "react"
|
||||||
|
import { message } from "antd"
|
||||||
|
import { getGenerationStatus, getGenerationTaskResults } from "@/api/template-editor"
|
||||||
|
import { safeExtractError } from "./errorUtils"
|
||||||
|
|
||||||
|
interface UseGenerationPollingOptions {
|
||||||
|
templateId: string
|
||||||
|
onProgress: (progress: number) => void
|
||||||
|
onComplete: (videos: unknown[]) => void
|
||||||
|
onFailed: (errorMsg: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成状态轮询 Hook
|
||||||
|
* 轮询生成状态,更新进度,处理完成/失败
|
||||||
|
*/
|
||||||
|
export const useGenerationPolling = ({
|
||||||
|
templateId,
|
||||||
|
onProgress,
|
||||||
|
onComplete,
|
||||||
|
onFailed,
|
||||||
|
}: UseGenerationPollingOptions) => {
|
||||||
|
const progressTimer = useRef<ReturnType<typeof setTimeout>>()
|
||||||
|
|
||||||
|
const clearTimer = useCallback(() => {
|
||||||
|
if (progressTimer.current) {
|
||||||
|
clearTimeout(progressTimer.current)
|
||||||
|
progressTimer.current = undefined
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const startPolling = useCallback(() => {
|
||||||
|
const poll = async () => {
|
||||||
|
try {
|
||||||
|
const data = await getGenerationStatus(templateId)
|
||||||
|
|
||||||
|
if (data.plan_status === "completed") {
|
||||||
|
onProgress(100)
|
||||||
|
// 获取生成的视频结果
|
||||||
|
let videos: unknown[] = []
|
||||||
|
if (data.generation_task_id) {
|
||||||
|
try {
|
||||||
|
videos = await getGenerationTaskResults(data.generation_task_id)
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[获取生成结果失败]", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onComplete(videos)
|
||||||
|
message.success("视频生成完成!")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (data.plan_status === "failed") {
|
||||||
|
const dataAny = data as unknown as Record<string, unknown>
|
||||||
|
const rawMsg =
|
||||||
|
dataAny.error_message ||
|
||||||
|
dataAny.error ||
|
||||||
|
dataAny.message ||
|
||||||
|
(Array.isArray(data.clips)
|
||||||
|
? (data.clips as { status: string; error_message?: string }[]).find(
|
||||||
|
(c) => c.status === "failed",
|
||||||
|
)?.error_message
|
||||||
|
: undefined) ||
|
||||||
|
"视频生成失败,请联系管理员或重试"
|
||||||
|
const errorMsg = safeExtractError(rawMsg)
|
||||||
|
console.error("[生成失败] templateId:", templateId, "响应:", data)
|
||||||
|
onFailed(errorMsg)
|
||||||
|
message.error(errorMsg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const clips = data.clips || []
|
||||||
|
const total = clips.length || 1
|
||||||
|
const done = (clips as { status: string }[]).filter((c) => c.status === "completed").length
|
||||||
|
onProgress(Math.round((done / total) * 100))
|
||||||
|
|
||||||
|
progressTimer.current = setTimeout(poll, 2000)
|
||||||
|
} catch (pollErr) {
|
||||||
|
console.error("[轮询出错] templateId:", templateId, pollErr)
|
||||||
|
progressTimer.current = setTimeout(poll, 3000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
progressTimer.current = setTimeout(poll, 2000)
|
||||||
|
}, [templateId, onProgress, onComplete, onFailed])
|
||||||
|
|
||||||
|
return { startPolling, clearTimer }
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import type { EditPlanConfig } from "@/api/template-editor"
|
||||||
|
|
||||||
|
type VoiceConfig = Pick<
|
||||||
|
EditPlanConfig,
|
||||||
|
"voice_id" | "voice_clone_profile_id" | "custom_audio_url" | "custom_text"
|
||||||
|
>
|
||||||
|
|
||||||
|
interface BuildVoiceConfigOptions {
|
||||||
|
voiceMode: "preset" | "custom" | "clone"
|
||||||
|
selectedVoice: string
|
||||||
|
selectedClonedVoice: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据 voiceMode 构建配音配置
|
||||||
|
*/
|
||||||
|
export const buildVoiceConfig = ({
|
||||||
|
voiceMode,
|
||||||
|
selectedVoice,
|
||||||
|
selectedClonedVoice,
|
||||||
|
}: BuildVoiceConfigOptions): VoiceConfig => {
|
||||||
|
const voiceConfig: VoiceConfig = {}
|
||||||
|
if (voiceMode === "preset") {
|
||||||
|
voiceConfig.voice_id = selectedVoice || undefined
|
||||||
|
} else if (voiceMode === "clone") {
|
||||||
|
voiceConfig.voice_clone_profile_id = selectedClonedVoice || undefined
|
||||||
|
} else if (voiceMode === "custom") {
|
||||||
|
voiceConfig.voice_id = selectedVoice || undefined
|
||||||
|
// 注意:customAudioUrl / customVoiceText 在 step5 hook 中,
|
||||||
|
// 自定义配音模式需从 step5 组件传回
|
||||||
|
}
|
||||||
|
return voiceConfig
|
||||||
|
}
|
||||||
@@ -0,0 +1,309 @@
|
|||||||
|
/**
|
||||||
|
* GeneratePage 表单状态管理
|
||||||
|
* 集中管理 7 步向导的所有共享状态、API 加载、URL 参数解析
|
||||||
|
*/
|
||||||
|
import { useState, useEffect, useMemo } from "react"
|
||||||
|
import { useQuery } from "@tanstack/react-query"
|
||||||
|
import { useSearchParams } from "react-router-dom"
|
||||||
|
import type { GeneratedVideo, TitleConfig } from "@/api/template-editor"
|
||||||
|
import type { EditingTemplate } from "@/api/editing-planner"
|
||||||
|
import { getEditPlan } from "@/api/template-editor"
|
||||||
|
import type { CoverConfig } from "../../editing-planner/types"
|
||||||
|
import { getEditingTemplates } from "@/api/editing-planner"
|
||||||
|
import type { PresetVoiceItem } from "@/api/voices"
|
||||||
|
import { fetchPresetVoices } from "@/api/voices"
|
||||||
|
import { DEFAULT_COVER_SETTINGS } from "../constants"
|
||||||
|
import type { TitleSettings } from "../types"
|
||||||
|
|
||||||
|
const DEFAULT_TITLE_SETTINGS: TitleSettings = {
|
||||||
|
aiAutoSelect: false,
|
||||||
|
title: "",
|
||||||
|
position: "bottom",
|
||||||
|
font: "思源黑体",
|
||||||
|
size: 28,
|
||||||
|
bold: true,
|
||||||
|
italic: false,
|
||||||
|
stroke: true,
|
||||||
|
shadow: false,
|
||||||
|
color: "#ffffff",
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GenerateFormState {
|
||||||
|
/* 步骤 */
|
||||||
|
currentStep: number
|
||||||
|
setCurrentStep: (step: number | ((prev: number) => number)) => void
|
||||||
|
|
||||||
|
/* 模板 */
|
||||||
|
selectedTemplate: string
|
||||||
|
setSelectedTemplate: (id: string) => void
|
||||||
|
userTemplates: EditingTemplate[]
|
||||||
|
|
||||||
|
/* 素材 */
|
||||||
|
selectedMaterials: string[]
|
||||||
|
setSelectedMaterials: (ids: string[]) => void
|
||||||
|
materialMode: "manual" | "auto"
|
||||||
|
setMaterialMode: (mode: "manual" | "auto") => void
|
||||||
|
smartSelectedIds: string[]
|
||||||
|
setSmartSelectedIds: (ids: string[]) => void
|
||||||
|
|
||||||
|
/* 标题 */
|
||||||
|
titleSettings: TitleSettings
|
||||||
|
setTitleSettings: (settings: TitleSettings | ((prev: TitleSettings) => TitleSettings)) => void
|
||||||
|
|
||||||
|
/* 封面 */
|
||||||
|
coverSettings: CoverConfig
|
||||||
|
setCoverSettings: (settings: CoverConfig | ((prev: CoverConfig) => CoverConfig)) => void
|
||||||
|
|
||||||
|
/* 配音 */
|
||||||
|
selectedVoice: string
|
||||||
|
setSelectedVoice: (id: string) => void
|
||||||
|
voiceMode: "preset" | "custom" | "clone"
|
||||||
|
setVoiceMode: (mode: "preset" | "custom" | "clone") => void
|
||||||
|
selectedClonedVoice: string
|
||||||
|
setSelectedClonedVoice: (id: string) => void
|
||||||
|
presetVoices: PresetVoiceItem[]
|
||||||
|
|
||||||
|
/* 克隆弹窗 */
|
||||||
|
cloneModalOpen: boolean
|
||||||
|
setCloneModalOpen: (open: boolean) => void
|
||||||
|
|
||||||
|
/* 生成数量 */
|
||||||
|
generateCount: number
|
||||||
|
setGenerateCount: (n: number) => void
|
||||||
|
|
||||||
|
/* 高级设置 */
|
||||||
|
videoRatio: string
|
||||||
|
duration: number
|
||||||
|
style: string
|
||||||
|
autoSubtitles: boolean
|
||||||
|
bgm: boolean
|
||||||
|
|
||||||
|
/* URL 参数 */
|
||||||
|
editPlanId: string | null
|
||||||
|
planConfigStr: string | null
|
||||||
|
|
||||||
|
/* 预览弹窗 */
|
||||||
|
previewVideo: GeneratedVideo | null
|
||||||
|
setPreviewVideo: (v: GeneratedVideo | null) => void
|
||||||
|
previewModalOpen: boolean
|
||||||
|
setPreviewModalOpen: (open: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useGenerateFormState = (): GenerateFormState => {
|
||||||
|
const [searchParams] = useSearchParams()
|
||||||
|
const editPlanId = searchParams.get("edit_plan_id")
|
||||||
|
const planConfigStr = searchParams.get("plan_config")
|
||||||
|
|
||||||
|
/* ── 步骤状态 ── */
|
||||||
|
const [currentStep, setCurrentStep] = useState(1)
|
||||||
|
|
||||||
|
/* ── 模板(从 API 加载) ── */
|
||||||
|
const [selectedTemplate, setSelectedTemplate] = useState("")
|
||||||
|
const { data: userTemplates = [] } = useQuery({
|
||||||
|
queryKey: ["generate-templates"],
|
||||||
|
queryFn: () => getEditingTemplates(),
|
||||||
|
staleTime: 60_000,
|
||||||
|
})
|
||||||
|
/* 模板加载完成后自动选中第一个 */
|
||||||
|
useEffect(() => {
|
||||||
|
if (userTemplates.length > 0 && !selectedTemplate) {
|
||||||
|
setSelectedTemplate(userTemplates[0].id)
|
||||||
|
}
|
||||||
|
}, [userTemplates, selectedTemplate])
|
||||||
|
|
||||||
|
/* ── 素材 ── */
|
||||||
|
const [selectedMaterials, setSelectedMaterials] = useState<string[]>([])
|
||||||
|
const [materialMode, setMaterialMode] = useState<"manual" | "auto">("manual")
|
||||||
|
const [smartSelectedIds, setSmartSelectedIds] = useState<string[]>([])
|
||||||
|
|
||||||
|
/* ── 标题设置 ── */
|
||||||
|
const [titleSettings, setTitleSettings] = useState<TitleSettings>(DEFAULT_TITLE_SETTINGS)
|
||||||
|
|
||||||
|
/* ── 封面设置 ── */
|
||||||
|
const [coverSettings, setCoverSettings] = useState<CoverConfig>(DEFAULT_COVER_SETTINGS)
|
||||||
|
|
||||||
|
/* 当选中模板开启了「AI自动匹配标题」,自动填入模板预设标题 / 封面 */
|
||||||
|
useEffect(() => {
|
||||||
|
const tpl = userTemplates.find((t) => t.id === selectedTemplate)
|
||||||
|
if (tpl?.title_config) {
|
||||||
|
setTitleSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
aiAutoSelect: tpl.title_config!.ai_auto_select,
|
||||||
|
title: tpl.title_config!.content || prev.title,
|
||||||
|
position: tpl.title_config!.position || prev.position,
|
||||||
|
font: tpl.title_config!.font_preset || prev.font,
|
||||||
|
size: tpl.title_config!.font_size || prev.size,
|
||||||
|
color: tpl.title_config!.font_color || prev.color,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
if (tpl?.cover_config) {
|
||||||
|
setCoverSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
enabled: tpl.cover_config!.enabled ?? prev.enabled,
|
||||||
|
mode: (tpl.cover_config!.mode as CoverConfig["mode"]) || prev.mode,
|
||||||
|
frame_time: tpl.cover_config!.frame_time ?? prev.frame_time,
|
||||||
|
upload_url: tpl.cover_config!.upload_url || prev.upload_url,
|
||||||
|
ai_suggested_time: tpl.cover_config!.ai_suggested_time ?? prev.ai_suggested_time,
|
||||||
|
thumbnail_url: tpl.cover_config!.thumbnail_url || prev.thumbnail_url,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}, [selectedTemplate, userTemplates])
|
||||||
|
|
||||||
|
/* ── 配音 ── */
|
||||||
|
const [selectedVoice, setSelectedVoice] = useState("")
|
||||||
|
const [voiceMode, setVoiceMode] = useState<"preset" | "custom" | "clone">("preset")
|
||||||
|
const [selectedClonedVoice, setSelectedClonedVoice] = useState("")
|
||||||
|
|
||||||
|
/* ── 预置音色 API ── */
|
||||||
|
const { data: presetVoicesData } = useQuery({
|
||||||
|
queryKey: ["preset-voices"],
|
||||||
|
queryFn: fetchPresetVoices,
|
||||||
|
})
|
||||||
|
const presetVoices: PresetVoiceItem[] = useMemo(
|
||||||
|
() => presetVoicesData?.items ?? [],
|
||||||
|
[presetVoicesData],
|
||||||
|
)
|
||||||
|
|
||||||
|
/* ── 克隆声音弹窗 ── */
|
||||||
|
const [cloneModalOpen, setCloneModalOpen] = useState(false)
|
||||||
|
|
||||||
|
/* ── 生成数量 ── */
|
||||||
|
const [generateCount, setGenerateCount] = useState(1)
|
||||||
|
|
||||||
|
/* ── 高级设置(隐藏但保留) ── */
|
||||||
|
const [videoRatio] = useState("16:9")
|
||||||
|
const [duration] = useState(30)
|
||||||
|
const [style] = useState("business")
|
||||||
|
const [autoSubtitles] = useState(true)
|
||||||
|
const [bgm] = useState(true)
|
||||||
|
|
||||||
|
/* ── 预览弹窗 ── */
|
||||||
|
const [previewVideo, setPreviewVideo] = useState<GeneratedVideo | null>(null)
|
||||||
|
const [previewModalOpen, setPreviewModalOpen] = useState(false)
|
||||||
|
|
||||||
|
/** 解析 plan_config 并自动填充表单 */
|
||||||
|
useEffect(() => {
|
||||||
|
if (!planConfigStr) return
|
||||||
|
try {
|
||||||
|
const config = JSON.parse(planConfigStr) as {
|
||||||
|
title_config?: {
|
||||||
|
content?: string
|
||||||
|
ai_auto_select?: boolean
|
||||||
|
position?: string
|
||||||
|
font_preset?: string
|
||||||
|
font_size?: number
|
||||||
|
font_color?: string
|
||||||
|
}
|
||||||
|
subtitle_config?: { enabled?: boolean }
|
||||||
|
bgm_config?: { enabled?: boolean; music_id?: string }
|
||||||
|
mode?: string
|
||||||
|
total_duration?: number
|
||||||
|
segments?: Array<{ media_asset_id?: string; material_type?: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.title_config) {
|
||||||
|
const tc = config.title_config as TitleConfig
|
||||||
|
setTitleSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
title: tc.content || "",
|
||||||
|
aiAutoSelect: tc.ai_auto_select || false,
|
||||||
|
position: tc.position || prev.position,
|
||||||
|
font: tc.font_preset || prev.font,
|
||||||
|
size: tc.font_size || prev.size,
|
||||||
|
color: tc.font_color || prev.color,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
if (config.segments && config.segments.length > 0) {
|
||||||
|
const assetIds = config.segments
|
||||||
|
.map((s) => s.media_asset_id)
|
||||||
|
.filter((id): id is string => !!id)
|
||||||
|
if (assetIds.length > 0) {
|
||||||
|
setSelectedMaterials(assetIds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("解析 plan_config 失败:", err)
|
||||||
|
}
|
||||||
|
}, [planConfigStr])
|
||||||
|
|
||||||
|
/** 如果没有 plan_config,尝试通过 edit_plan_id 从后端拉取配置 */
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editPlanId || planConfigStr) return
|
||||||
|
const loadPlanConfig = async () => {
|
||||||
|
try {
|
||||||
|
const plan = await getEditPlan(editPlanId)
|
||||||
|
if (plan.name) setTitleSettings((prev) => ({ ...prev, title: plan.name }))
|
||||||
|
const cfg = plan.config
|
||||||
|
if (cfg?.title_config) {
|
||||||
|
setTitleSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
aiAutoSelect: cfg.title_config!.ai_auto_select,
|
||||||
|
title: cfg.title_config!.content || prev.title,
|
||||||
|
position: cfg.title_config!.position || prev.position,
|
||||||
|
font: cfg.title_config!.font_preset || prev.font,
|
||||||
|
size: cfg.title_config!.font_size || prev.size,
|
||||||
|
color: cfg.title_config!.font_color || prev.color,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
if (cfg?.cover_config) {
|
||||||
|
const cc = cfg.cover_config as CoverConfig
|
||||||
|
setCoverSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
enabled: cc.enabled ?? prev.enabled,
|
||||||
|
mode: cc.mode || prev.mode,
|
||||||
|
frame_time: cc.frame_time ?? prev.frame_time,
|
||||||
|
upload_url: cc.upload_url || prev.upload_url,
|
||||||
|
ai_suggested_time: cc.ai_suggested_time ?? prev.ai_suggested_time,
|
||||||
|
thumbnail_url: cc.thumbnail_url || prev.thumbnail_url,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
if (cfg?.asset_ids) {
|
||||||
|
setSelectedMaterials(cfg.asset_ids.filter((v): v is string => typeof v === "string"))
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("加载模板草稿配置失败:", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loadPlanConfig()
|
||||||
|
}, [editPlanId, planConfigStr])
|
||||||
|
|
||||||
|
return {
|
||||||
|
currentStep,
|
||||||
|
setCurrentStep,
|
||||||
|
selectedTemplate,
|
||||||
|
setSelectedTemplate,
|
||||||
|
userTemplates,
|
||||||
|
selectedMaterials,
|
||||||
|
setSelectedMaterials,
|
||||||
|
materialMode,
|
||||||
|
setMaterialMode,
|
||||||
|
smartSelectedIds,
|
||||||
|
setSmartSelectedIds,
|
||||||
|
titleSettings,
|
||||||
|
setTitleSettings,
|
||||||
|
coverSettings,
|
||||||
|
setCoverSettings,
|
||||||
|
selectedVoice,
|
||||||
|
setSelectedVoice,
|
||||||
|
voiceMode,
|
||||||
|
setVoiceMode,
|
||||||
|
selectedClonedVoice,
|
||||||
|
setSelectedClonedVoice,
|
||||||
|
presetVoices,
|
||||||
|
cloneModalOpen,
|
||||||
|
setCloneModalOpen,
|
||||||
|
generateCount,
|
||||||
|
setGenerateCount,
|
||||||
|
videoRatio,
|
||||||
|
duration,
|
||||||
|
style,
|
||||||
|
autoSubtitles,
|
||||||
|
bgm,
|
||||||
|
editPlanId,
|
||||||
|
planConfigStr,
|
||||||
|
previewVideo,
|
||||||
|
setPreviewVideo,
|
||||||
|
previewModalOpen,
|
||||||
|
setPreviewModalOpen,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,54 +2,35 @@
|
|||||||
* 视频生成 Hook
|
* 视频生成 Hook
|
||||||
* 封装视频生成的核心逻辑、状态管理、轮询等
|
* 封装视频生成的核心逻辑、状态管理、轮询等
|
||||||
*/
|
*/
|
||||||
import { useState, useRef, useCallback } from "react"
|
import { useState, useCallback } from "react"
|
||||||
import { message } from "antd"
|
import { message } from "antd"
|
||||||
import type { GeneratedVideo, EditPlanConfig } from "@/api/template-editor"
|
import type { GeneratedVideo } from "@/api/template-editor"
|
||||||
import {
|
import { generateEditPlan, updateEditPlan, getEditPlan } from "@/api/template-editor"
|
||||||
generateEditPlan,
|
import type { UseGenerateVideoProps } from "./generate-video/types"
|
||||||
updateEditPlan,
|
import { getGenerationPhase } from "./generate-video/phase"
|
||||||
getGenerationTaskResults,
|
import { useGenerationPolling } from "./generate-video/useGenerationPolling"
|
||||||
getGenerationStatus,
|
import { buildVoiceConfig } from "./generate-video/voiceConfig"
|
||||||
getEditPlan,
|
import { extractBackendError, translateError } from "./generate-video/errorUtils"
|
||||||
} from "@/api/template-editor"
|
|
||||||
import type { CoverConfig } from "../../editing-planner/types"
|
|
||||||
import type { TitleSettings } from "../types"
|
|
||||||
|
|
||||||
interface UseGenerateVideoProps {
|
export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||||
titleSettings: TitleSettings
|
const {
|
||||||
selectedTemplate: string
|
titleSettings,
|
||||||
selectedMaterials: string[]
|
selectedTemplate,
|
||||||
materialMode: "manual" | "auto"
|
selectedMaterials,
|
||||||
smartSelectedIds: string[]
|
materialMode,
|
||||||
voiceMode: "preset" | "custom" | "clone"
|
smartSelectedIds,
|
||||||
selectedVoice: string
|
voiceMode,
|
||||||
selectedClonedVoice: string
|
selectedVoice,
|
||||||
coverSettings: CoverConfig
|
selectedClonedVoice,
|
||||||
videoRatio: string
|
coverSettings,
|
||||||
style: string
|
videoRatio,
|
||||||
duration: number
|
style,
|
||||||
autoSubtitles: boolean
|
duration,
|
||||||
bgm: boolean
|
autoSubtitles,
|
||||||
generateCount: number
|
bgm,
|
||||||
}
|
generateCount,
|
||||||
|
} = props
|
||||||
|
|
||||||
export function useGenerateVideo({
|
|
||||||
titleSettings,
|
|
||||||
selectedTemplate,
|
|
||||||
selectedMaterials,
|
|
||||||
materialMode,
|
|
||||||
smartSelectedIds,
|
|
||||||
voiceMode,
|
|
||||||
selectedVoice,
|
|
||||||
selectedClonedVoice,
|
|
||||||
coverSettings,
|
|
||||||
videoRatio,
|
|
||||||
style,
|
|
||||||
duration,
|
|
||||||
autoSubtitles,
|
|
||||||
bgm,
|
|
||||||
generateCount,
|
|
||||||
}: UseGenerateVideoProps) {
|
|
||||||
/* ── 生成状态 ── */
|
/* ── 生成状态 ── */
|
||||||
const [generating, setGenerating] = useState(false)
|
const [generating, setGenerating] = useState(false)
|
||||||
const [progress, setProgress] = useState(0)
|
const [progress, setProgress] = useState(0)
|
||||||
@@ -57,24 +38,26 @@ export function useGenerateVideo({
|
|||||||
const [generateError, setGenerateError] = useState<string | null>(null)
|
const [generateError, setGenerateError] = useState<string | null>(null)
|
||||||
const [generatedVideos, setGeneratedVideos] = useState<GeneratedVideo[]>([])
|
const [generatedVideos, setGeneratedVideos] = useState<GeneratedVideo[]>([])
|
||||||
|
|
||||||
const progressTimer = useRef<ReturnType<typeof setInterval>>(undefined)
|
const handleProgress = useCallback((p: number) => setProgress(p), [])
|
||||||
|
const handleComplete = useCallback((videos: unknown[]) => {
|
||||||
|
setGenerating(false)
|
||||||
|
setGenerated(true)
|
||||||
|
setGeneratedVideos(videos as GeneratedVideo[])
|
||||||
|
}, [])
|
||||||
|
const handleFailed = useCallback((errorMsg: string) => {
|
||||||
|
setGenerating(false)
|
||||||
|
setGenerateError(errorMsg)
|
||||||
|
}, [])
|
||||||
|
|
||||||
/* ── 生成阶段映射 ── */
|
const { startPolling, clearTimer } = useGenerationPolling({
|
||||||
const getGenerationPhase = (p: number) => {
|
templateId: selectedTemplate,
|
||||||
if (p < 20) return { label: "分析素材与配置", icon: "🔍" }
|
onProgress: handleProgress,
|
||||||
if (p < 50) return { label: "智能剪辑合成", icon: "🎬" }
|
onComplete: handleComplete,
|
||||||
if (p < 80) return { label: "渲染视频中", icon: "⚡" }
|
onFailed: handleFailed,
|
||||||
return { label: "即将完成", icon: "✨" }
|
})
|
||||||
}
|
|
||||||
|
|
||||||
/* ── 生成视频 ── */
|
/* ── 生成视频 ── */
|
||||||
const generate = useCallback(async () => {
|
const generate = useCallback(async () => {
|
||||||
console.log("[handleGenerate] 开始生成, 参数:", {
|
|
||||||
titleSettings,
|
|
||||||
selectedTemplate,
|
|
||||||
selectedMaterials,
|
|
||||||
voiceMode,
|
|
||||||
})
|
|
||||||
if (!titleSettings.title.trim()) {
|
if (!titleSettings.title.trim()) {
|
||||||
message.warning("请先选择或输入标题")
|
message.warning("请先选择或输入标题")
|
||||||
return
|
return
|
||||||
@@ -83,7 +66,6 @@ export function useGenerateVideo({
|
|||||||
message.warning("请至少选择一个素材")
|
message.warning("请至少选择一个素材")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (voiceMode === "clone" && !selectedClonedVoice) {
|
if (voiceMode === "clone" && !selectedClonedVoice) {
|
||||||
message.warning("请先选择一个克隆音色")
|
message.warning("请先选择一个克隆音色")
|
||||||
return
|
return
|
||||||
@@ -93,21 +75,14 @@ export function useGenerateVideo({
|
|||||||
setProgress(0)
|
setProgress(0)
|
||||||
setGenerated(false)
|
setGenerated(false)
|
||||||
setGenerateError(null)
|
setGenerateError(null)
|
||||||
|
clearTimer()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const voiceConfig: Pick<
|
const voiceConfig = buildVoiceConfig({
|
||||||
EditPlanConfig,
|
voiceMode,
|
||||||
"voice_id" | "voice_clone_profile_id" | "custom_audio_url" | "custom_text"
|
selectedVoice,
|
||||||
> = {}
|
selectedClonedVoice,
|
||||||
if (voiceMode === "preset") {
|
})
|
||||||
voiceConfig.voice_id = selectedVoice || undefined
|
|
||||||
} else if (voiceMode === "clone") {
|
|
||||||
voiceConfig.voice_clone_profile_id = selectedClonedVoice || undefined
|
|
||||||
} else if (voiceMode === "custom") {
|
|
||||||
voiceConfig.voice_id = selectedVoice || undefined
|
|
||||||
// 注意:customAudioUrl / customVoiceText 在 step5 hook 中,
|
|
||||||
// 自定义配音模式需从 step5 组件传回
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取或创建草稿
|
// 获取或创建草稿
|
||||||
await getEditPlan(selectedTemplate)
|
await getEditPlan(selectedTemplate)
|
||||||
@@ -140,149 +115,13 @@ export function useGenerateVideo({
|
|||||||
})
|
})
|
||||||
|
|
||||||
await generateEditPlan(selectedTemplate)
|
await generateEditPlan(selectedTemplate)
|
||||||
|
startPolling()
|
||||||
const poll = async () => {
|
|
||||||
try {
|
|
||||||
const data = await getGenerationStatus(selectedTemplate)
|
|
||||||
|
|
||||||
if (data.plan_status === "completed") {
|
|
||||||
setProgress(100)
|
|
||||||
setGenerating(false)
|
|
||||||
setGenerated(true)
|
|
||||||
|
|
||||||
// 获取生成的视频结果
|
|
||||||
if (data.generation_task_id) {
|
|
||||||
try {
|
|
||||||
const videos = await getGenerationTaskResults(data.generation_task_id)
|
|
||||||
setGeneratedVideos(videos)
|
|
||||||
} catch (err) {
|
|
||||||
console.error("[获取生成结果失败]", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
message.success("视频生成完成!")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (data.plan_status === "failed") {
|
|
||||||
setGenerating(false)
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- 防御性错误提取
|
|
||||||
const dataAny = data as Record<string, any>
|
|
||||||
const rawMsg =
|
|
||||||
dataAny.error_message ||
|
|
||||||
dataAny.error ||
|
|
||||||
dataAny.message ||
|
|
||||||
(data.clips || []).find((c: { status: string }) => c.status === "failed")
|
|
||||||
?.error_message ||
|
|
||||||
"视频生成失败,请联系管理员或重试"
|
|
||||||
const safeExtract = (val: unknown): string => {
|
|
||||||
if (typeof val === "string") return val
|
|
||||||
if (typeof val === "object" && val !== null) {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- 防御性错误提取
|
|
||||||
const obj = val as Record<string, any>
|
|
||||||
if (typeof obj.message === "string") return obj.message
|
|
||||||
if (typeof obj.msg === "string") return obj.msg
|
|
||||||
if (typeof obj.detail === "string") return obj.detail
|
|
||||||
if (obj.message && typeof obj.message === "object") return safeExtract(obj.message)
|
|
||||||
return JSON.stringify(val)
|
|
||||||
}
|
|
||||||
return String(val ?? "")
|
|
||||||
}
|
|
||||||
const errorMsg = safeExtract(rawMsg)
|
|
||||||
console.error("[生成失败] templateId:", selectedTemplate, "响应:", data)
|
|
||||||
setGenerateError(errorMsg)
|
|
||||||
message.error(errorMsg)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const clips = data.clips || []
|
|
||||||
const total = clips.length || 1
|
|
||||||
const done = clips.filter((c: { status: string }) => c.status === "completed").length
|
|
||||||
setProgress(Math.round((done / total) * 100))
|
|
||||||
|
|
||||||
progressTimer.current = setTimeout(poll, 2000) as unknown as ReturnType<
|
|
||||||
typeof setInterval
|
|
||||||
>
|
|
||||||
} catch (pollErr) {
|
|
||||||
console.error("[轮询出错] templateId:", selectedTemplate, pollErr)
|
|
||||||
progressTimer.current = setTimeout(poll, 3000) as unknown as ReturnType<
|
|
||||||
typeof setInterval
|
|
||||||
>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
progressTimer.current = setTimeout(poll, 2000) as unknown as ReturnType<typeof setInterval>
|
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
console.error("[handleGenerate] 生成失败:", err)
|
console.error("[handleGenerate] 生成失败:", err)
|
||||||
setGenerating(false)
|
setGenerating(false)
|
||||||
const axiosErr = err as {
|
const backendMsg = extractBackendError(err)
|
||||||
response?: {
|
console.error("[handleGenerate] 错误信息:", backendMsg, "完整错误:", err)
|
||||||
data?: {
|
const finalMsg = translateError(backendMsg)
|
||||||
message?: string | object
|
|
||||||
error?: string | object
|
|
||||||
detail?: string | object
|
|
||||||
msg?: string | object
|
|
||||||
}
|
|
||||||
}
|
|
||||||
message?: string
|
|
||||||
}
|
|
||||||
const extractString = (val: unknown): string => {
|
|
||||||
if (typeof val === "string") return val
|
|
||||||
if (typeof val === "object" && val !== null) {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- 防御性错误提取
|
|
||||||
const obj = val as Record<string, any>
|
|
||||||
if (typeof obj.message === "string") return obj.message
|
|
||||||
if (typeof obj.msg === "string") return obj.msg
|
|
||||||
if (typeof obj.detail === "string") return obj.detail
|
|
||||||
if (typeof obj.message === "object" && obj.message !== null)
|
|
||||||
return extractString(obj.message)
|
|
||||||
if (typeof obj.msg === "object" && obj.msg !== null) return extractString(obj.msg)
|
|
||||||
return JSON.stringify(val)
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
const backendMsg =
|
|
||||||
extractString(axiosErr.response?.data?.message) ||
|
|
||||||
extractString(axiosErr.response?.data?.error) ||
|
|
||||||
extractString(axiosErr.response?.data?.detail) ||
|
|
||||||
extractString(axiosErr.response?.data?.msg) ||
|
|
||||||
axiosErr.message ||
|
|
||||||
""
|
|
||||||
console.error("[handleGenerate] 错误信息:", backendMsg, "完整错误:", axiosErr)
|
|
||||||
const safeExtractErr = (val: unknown): string => {
|
|
||||||
if (typeof val === "string") return val
|
|
||||||
if (typeof val === "object" && val !== null) {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- 防御性错误提取
|
|
||||||
const obj = val as Record<string, any>
|
|
||||||
if (typeof obj.message === "string") return obj.message
|
|
||||||
if (typeof obj.msg === "string") return obj.msg
|
|
||||||
if (typeof obj.detail === "string") return obj.detail
|
|
||||||
if (typeof obj.message === "object") return safeExtractErr(obj.message)
|
|
||||||
return JSON.stringify(val)
|
|
||||||
}
|
|
||||||
return String(val ?? "")
|
|
||||||
}
|
|
||||||
const rawError = safeExtractErr(backendMsg)
|
|
||||||
const translateError = (msg: string): string => {
|
|
||||||
if (!msg) return "生成失败,请检查网络后重试或联系管理员"
|
|
||||||
if (msg.includes("editing") || msg.includes("draft") || msg.includes("状态")) {
|
|
||||||
return "正在准备生成,请稍候再试"
|
|
||||||
}
|
|
||||||
if (msg.includes("template_id") || msg.includes("not found") || msg.includes("不存在")) {
|
|
||||||
return "所选模板或素材不可用,请重新选择"
|
|
||||||
}
|
|
||||||
if (msg.includes("asset") && (msg.includes("not found") || msg.includes("missing"))) {
|
|
||||||
return "素材数据异常,请返回视频库重新检查"
|
|
||||||
}
|
|
||||||
if (msg.includes("timeout") || msg.includes("network") || msg.includes("ECONN")) {
|
|
||||||
return "网络连接超时,请检查网络后重试"
|
|
||||||
}
|
|
||||||
if (msg.includes("quota") || msg.includes("limit") || msg.includes("exceed")) {
|
|
||||||
return "已达到生成次数上限,请稍后再试或联系客服"
|
|
||||||
}
|
|
||||||
if (msg.length > 0 && msg.length < 100 && !msg.includes("{")) return msg
|
|
||||||
return "生成失败,请稍后重试或联系管理员"
|
|
||||||
}
|
|
||||||
const finalMsg = translateError(rawError)
|
|
||||||
setGenerateError(finalMsg)
|
setGenerateError(finalMsg)
|
||||||
message.error(finalMsg)
|
message.error(finalMsg)
|
||||||
}
|
}
|
||||||
@@ -302,6 +141,8 @@ export function useGenerateVideo({
|
|||||||
materialMode,
|
materialMode,
|
||||||
coverSettings,
|
coverSettings,
|
||||||
smartSelectedIds,
|
smartSelectedIds,
|
||||||
|
clearTimer,
|
||||||
|
startPolling,
|
||||||
])
|
])
|
||||||
|
|
||||||
/* 重新生成(失败后重试) */
|
/* 重新生成(失败后重试) */
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
/**
|
||||||
|
* GeneratePage 步骤导航
|
||||||
|
* 管理步骤切换与各步骤的前置校验
|
||||||
|
*/
|
||||||
|
import { message } from "antd"
|
||||||
|
import type { TitleSettings } from "../types"
|
||||||
|
|
||||||
|
export interface UseStepNavigationOptions {
|
||||||
|
currentStep: number
|
||||||
|
setCurrentStep: (step: number | ((prev: number) => number)) => void
|
||||||
|
selectedTemplate: string
|
||||||
|
materialMode: "manual" | "auto"
|
||||||
|
selectedMaterials: string[]
|
||||||
|
smartSelectedIds: string[]
|
||||||
|
titleSettings: TitleSettings
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseStepNavigationReturn {
|
||||||
|
goNext: () => void
|
||||||
|
goPrev: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useStepNavigation = (options: UseStepNavigationOptions): UseStepNavigationReturn => {
|
||||||
|
const {
|
||||||
|
currentStep,
|
||||||
|
setCurrentStep,
|
||||||
|
selectedTemplate,
|
||||||
|
materialMode,
|
||||||
|
selectedMaterials,
|
||||||
|
smartSelectedIds,
|
||||||
|
titleSettings,
|
||||||
|
} = options
|
||||||
|
|
||||||
|
const goNext = () => {
|
||||||
|
if (currentStep === 1 && !selectedTemplate) {
|
||||||
|
message.warning("请先选择一个模板")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (currentStep === 2 && materialMode === "manual" && selectedMaterials.length === 0) {
|
||||||
|
message.warning("请至少选择一个素材")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (currentStep === 2 && materialMode === "auto" && smartSelectedIds.length === 0) {
|
||||||
|
message.warning("请先进行智能匹配并选择素材")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (currentStep === 4 && !titleSettings.title.trim()) {
|
||||||
|
message.warning("请选择或输入标题")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (currentStep < 7) {
|
||||||
|
setCurrentStep((s) => s + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const goPrev = () => {
|
||||||
|
if (currentStep > 1) {
|
||||||
|
setCurrentStep((s) => s - 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { goNext, goPrev }
|
||||||
|
}
|
||||||
Regular → Executable
+41
-294
@@ -1,259 +1,47 @@
|
|||||||
/**
|
/**
|
||||||
* 我的音色页面 — V21 设计系统(任务 3.12 升级 / 3.15 进度轮询)
|
* 我的音色页面 — V21 设计系统
|
||||||
*
|
*
|
||||||
* 功能:克隆音色卡片列表、试听播放、状态标签、删除、编辑名称、空状态引导
|
* 功能:克隆音色卡片列表、试听播放、状态标签、删除、编辑名称、空状态引导
|
||||||
* 使用 useCloneProgress hook 实现 processing 状态自动轮询
|
* 使用 useCloneProgress hook 实现 processing 状态自动轮询
|
||||||
*/
|
*/
|
||||||
import React, { useState, useCallback, useRef } from "react"
|
import React from "react"
|
||||||
import { useNavigate } from "react-router-dom"
|
import { PlusOutlined } from "@ant-design/icons"
|
||||||
import {
|
import { Button, Modal, Input } from "@/components/ui"
|
||||||
PlayCircleOutlined,
|
|
||||||
PauseCircleOutlined,
|
|
||||||
DeleteOutlined,
|
|
||||||
EditOutlined,
|
|
||||||
PlusOutlined,
|
|
||||||
SoundOutlined,
|
|
||||||
ClockCircleOutlined,
|
|
||||||
} from "@ant-design/icons"
|
|
||||||
import { Button, Modal, Input, Tooltip } from "@/components/ui"
|
|
||||||
import type { ButtonProps } from "antd"
|
import type { ButtonProps } from "antd"
|
||||||
import PageHead from "@/components/layout/PageHead"
|
import PageHead from "@/components/layout/PageHead"
|
||||||
import { useCloneProgress } from "@/hooks/useCloneProgress"
|
import { VoiceCard } from "./components/VoiceCard"
|
||||||
import { deleteVoiceClone, updateVoiceClone, formatDuration } from "@/api/voice-clone"
|
import {
|
||||||
import type { VoiceClone, VoiceCloneStatus } from "@/api/voice-clone"
|
StatsBar,
|
||||||
|
EmptyState,
|
||||||
|
LoadingState,
|
||||||
|
PollingHint,
|
||||||
|
ToastContainer,
|
||||||
|
} from "./components/States"
|
||||||
|
import { useMyVoices } from "./hooks/useMyVoices"
|
||||||
import "./my-voices.css"
|
import "./my-voices.css"
|
||||||
|
|
||||||
/* ============================================================
|
|
||||||
* 工具函数
|
|
||||||
* ============================================================ */
|
|
||||||
function formatDate(isoStr: string): string {
|
|
||||||
const d = new Date(isoStr)
|
|
||||||
return d.toLocaleDateString("zh-CN", {
|
|
||||||
year: "numeric",
|
|
||||||
month: "2-digit",
|
|
||||||
day: "2-digit",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================================
|
|
||||||
* 状态配置
|
|
||||||
* ============================================================ */
|
|
||||||
const STATUS_CONFIG: Record<VoiceCloneStatus, { label: string; dotClass: string }> = {
|
|
||||||
ready: { label: "就绪", dotClass: "xx-mv-status-dot--ready" },
|
|
||||||
processing: { label: "克隆中", dotClass: "xx-mv-status-dot--processing" },
|
|
||||||
failed: { label: "失败", dotClass: "xx-mv-status-dot--failed" },
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================================
|
|
||||||
* Toast 组件
|
|
||||||
* ============================================================ */
|
|
||||||
interface ToastItem {
|
|
||||||
id: number
|
|
||||||
message: string
|
|
||||||
type: "success" | "error"
|
|
||||||
}
|
|
||||||
let _toastId = 0
|
|
||||||
|
|
||||||
/* ============================================================
|
|
||||||
* 音色卡片组件
|
|
||||||
* ============================================================ */
|
|
||||||
interface VoiceCardProps {
|
|
||||||
voice: VoiceClone
|
|
||||||
isPlaying: boolean
|
|
||||||
onTogglePlay: (voice: VoiceClone) => void
|
|
||||||
onEdit: (voice: VoiceClone) => void
|
|
||||||
onDelete: (voice: VoiceClone) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
const VoiceCard: React.FC<VoiceCardProps> = ({
|
|
||||||
voice,
|
|
||||||
isPlaying,
|
|
||||||
onTogglePlay,
|
|
||||||
onEdit,
|
|
||||||
onDelete,
|
|
||||||
}) => {
|
|
||||||
const statusCfg = STATUS_CONFIG[voice.status]
|
|
||||||
const isReady = voice.status === "ready"
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`xx-mv-card xx-mv-card--${voice.status}`}>
|
|
||||||
{/* 头部:头像 + 名称 + 状态 */}
|
|
||||||
<div className="xx-mv-card-header">
|
|
||||||
<div className={`xx-mv-card-avatar xx-mv-card-avatar--${voice.status}`}>
|
|
||||||
<SoundOutlined />
|
|
||||||
</div>
|
|
||||||
<div className="xx-mv-card-info">
|
|
||||||
<h4 className="xx-mv-card-name">{voice.name}</h4>
|
|
||||||
<span className="xx-mv-status">
|
|
||||||
<span className={`xx-mv-status-dot ${statusCfg.dotClass}`} />
|
|
||||||
{statusCfg.label}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 元信息 */}
|
|
||||||
<div className="xx-mv-card-meta">
|
|
||||||
<span className="xx-mv-card-meta-item">
|
|
||||||
<ClockCircleOutlined /> {formatDate(voice.created_at)}
|
|
||||||
</span>
|
|
||||||
{voice.duration_seconds > 0 && (
|
|
||||||
<span className="xx-mv-card-meta-item">{formatDuration(voice.duration_seconds)}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 进度条(克隆中 — indeterminate 条纹流动动画) */}
|
|
||||||
{voice.status === "processing" && (
|
|
||||||
<div className="xx-mv-progress xx-mv-progress--indeterminate">
|
|
||||||
<div className="xx-mv-progress-bar" />
|
|
||||||
<span className="xx-mv-progress-text">处理中…</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 操作区 */}
|
|
||||||
<div className="xx-mv-card-actions">
|
|
||||||
{isReady ? (
|
|
||||||
<Button
|
|
||||||
buttonType={isPlaying ? "secondary" : "ghost"}
|
|
||||||
buttonSize="sm"
|
|
||||||
onClick={() => onTogglePlay(voice)}
|
|
||||||
>
|
|
||||||
{isPlaying ? (
|
|
||||||
<>
|
|
||||||
<PauseCircleOutlined /> 暂停
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<PlayCircleOutlined /> 试听
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
) : voice.status === "failed" ? (
|
|
||||||
<Button buttonType="ghost" buttonSize="sm" disabled>
|
|
||||||
克隆失败
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button buttonType="ghost" buttonSize="sm" disabled>
|
|
||||||
处理中...
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<div className="xx-mv-card-icon-actions">
|
|
||||||
<Tooltip title="编辑名称">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="xx-mv-icon-btn"
|
|
||||||
onClick={() => onEdit(voice)}
|
|
||||||
disabled={!isReady}
|
|
||||||
>
|
|
||||||
<EditOutlined />
|
|
||||||
</button>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip title="删除">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="xx-mv-icon-btn xx-mv-icon-btn--danger"
|
|
||||||
onClick={() => onDelete(voice)}
|
|
||||||
>
|
|
||||||
<DeleteOutlined />
|
|
||||||
</button>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================================
|
|
||||||
* 主页面组件
|
|
||||||
* ============================================================ */
|
|
||||||
const MyVoices: React.FC = () => {
|
const MyVoices: React.FC = () => {
|
||||||
const navigate = useNavigate()
|
const {
|
||||||
const { clones, loading, removeClone, updateClone, hasProcessing } = useCloneProgress()
|
clones,
|
||||||
const [playingId, setPlayingId] = useState<string | null>(null)
|
loading,
|
||||||
const [toasts, setToasts] = useState<ToastItem[]>([])
|
hasProcessing,
|
||||||
const [editModalOpen, setEditModalOpen] = useState(false)
|
playingId,
|
||||||
const [editingVoice, setEditingVoice] = useState<VoiceClone | null>(null)
|
toasts,
|
||||||
const [editName, setEditName] = useState("")
|
editModalOpen,
|
||||||
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null)
|
editName,
|
||||||
const audioRef = useRef<HTMLAudioElement | null>(null)
|
setEditName,
|
||||||
|
deleteConfirmId,
|
||||||
// Toast
|
readyCount,
|
||||||
const showToast = useCallback((message: string, type: ToastItem["type"]) => {
|
processingCount,
|
||||||
const id = ++_toastId
|
handleTogglePlay,
|
||||||
setToasts((prev) => [...prev, { id, message, type }])
|
handleEdit,
|
||||||
setTimeout(() => setToasts((prev) => prev.filter((t) => t.id !== id)), 3000)
|
handleEditCancel,
|
||||||
}, [])
|
handleEditConfirm,
|
||||||
|
handleDelete,
|
||||||
// 试听播放
|
handleDeleteCancel,
|
||||||
const handleTogglePlay = useCallback(
|
handleDeleteConfirm,
|
||||||
(voice: VoiceClone) => {
|
handleCloneNew,
|
||||||
if (playingId === voice.id) {
|
} = useMyVoices()
|
||||||
audioRef.current?.pause()
|
|
||||||
setPlayingId(null)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (audioRef.current) {
|
|
||||||
audioRef.current.pause()
|
|
||||||
}
|
|
||||||
if (!voice.sample_url) {
|
|
||||||
showToast("暂无试听音频", "error")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const audio = new Audio(voice.sample_url)
|
|
||||||
audioRef.current = audio
|
|
||||||
audio.play().catch(() => showToast("播放失败,请检查音频文件", "error"))
|
|
||||||
audio.onended = () => setPlayingId(null)
|
|
||||||
setPlayingId(voice.id)
|
|
||||||
},
|
|
||||||
[playingId, showToast],
|
|
||||||
)
|
|
||||||
|
|
||||||
// 编辑
|
|
||||||
const handleEdit = (voice: VoiceClone) => {
|
|
||||||
setEditingVoice(voice)
|
|
||||||
setEditName(voice.name)
|
|
||||||
setEditModalOpen(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleEditConfirm = async () => {
|
|
||||||
if (!editingVoice || !editName.trim()) return
|
|
||||||
try {
|
|
||||||
const updated = await updateVoiceClone(editingVoice.id, {
|
|
||||||
name: editName.trim(),
|
|
||||||
})
|
|
||||||
updateClone(updated)
|
|
||||||
setEditModalOpen(false)
|
|
||||||
setEditingVoice(null)
|
|
||||||
showToast("名称已更新", "success")
|
|
||||||
} catch {
|
|
||||||
showToast("更新失败,请重试", "error")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 删除
|
|
||||||
const handleDelete = (voice: VoiceClone) => {
|
|
||||||
setDeleteConfirmId(voice.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDeleteConfirm = async () => {
|
|
||||||
if (!deleteConfirmId) return
|
|
||||||
try {
|
|
||||||
await deleteVoiceClone(deleteConfirmId)
|
|
||||||
removeClone(deleteConfirmId)
|
|
||||||
setDeleteConfirmId(null)
|
|
||||||
showToast("音色已删除", "success")
|
|
||||||
} catch {
|
|
||||||
showToast("删除失败,请重试", "error")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 克隆新音色
|
|
||||||
const handleCloneNew = () => {
|
|
||||||
navigate("/app/voices")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 统计
|
|
||||||
const readyCount = clones.filter((v) => v.status === "ready").length
|
|
||||||
const processingCount = clones.filter((v) => v.status === "processing").length
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="xx-mv-page">
|
<div className="xx-mv-page">
|
||||||
@@ -269,39 +57,15 @@ const MyVoices: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 轮询提示 */}
|
{/* 轮询提示 */}
|
||||||
{hasProcessing && (
|
{hasProcessing && <PollingHint />}
|
||||||
<div className="xx-mv-polling-hint">
|
|
||||||
<span className="xx-mv-polling-dot" />
|
|
||||||
正在同步克隆进度...
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 统计栏 */}
|
{/* 统计栏 */}
|
||||||
{!loading && clones.length > 0 && (
|
{!loading && clones.length > 0 && (
|
||||||
<div className="xx-mv-stats">
|
<StatsBar total={clones.length} readyCount={readyCount} processingCount={processingCount} />
|
||||||
<span className="xx-mv-stat">
|
|
||||||
共 <strong>{clones.length}</strong> 个音色
|
|
||||||
</span>
|
|
||||||
<span className="xx-mv-stat xx-mv-stat--ready">
|
|
||||||
<span className="xx-mv-stat-dot xx-mv-stat-dot--ready" />
|
|
||||||
就绪 {readyCount}
|
|
||||||
</span>
|
|
||||||
{processingCount > 0 && (
|
|
||||||
<span className="xx-mv-stat xx-mv-stat--processing">
|
|
||||||
<span className="xx-mv-stat-dot xx-mv-stat-dot--processing" />
|
|
||||||
克隆中 {processingCount}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 加载状态 */}
|
{/* 加载状态 */}
|
||||||
{loading && (
|
{loading && <LoadingState />}
|
||||||
<div className="xx-mv-empty">
|
|
||||||
<div className="xx-mv-empty-icon">⏳</div>
|
|
||||||
<p className="xx-mv-empty-desc">加载中...</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 卡片网格 */}
|
{/* 卡片网格 */}
|
||||||
{!loading && clones.length > 0 && (
|
{!loading && clones.length > 0 && (
|
||||||
@@ -320,22 +84,13 @@ const MyVoices: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 空状态 */}
|
{/* 空状态 */}
|
||||||
{!loading && clones.length === 0 && (
|
{!loading && clones.length === 0 && <EmptyState onClone={handleCloneNew} />}
|
||||||
<div className="xx-mv-empty">
|
|
||||||
<div className="xx-mv-empty-icon">🎤</div>
|
|
||||||
<h3 className="xx-mv-empty-title">还没有克隆音色</h3>
|
|
||||||
<p className="xx-mv-empty-desc">上传你的声音样本,AI 将克隆生成你的专属音色</p>
|
|
||||||
<Button buttonType="primary" onClick={handleCloneNew}>
|
|
||||||
<PlusOutlined /> 去配音库克隆
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 编辑弹窗 */}
|
{/* 编辑弹窗 */}
|
||||||
<Modal
|
<Modal
|
||||||
open={editModalOpen}
|
open={editModalOpen}
|
||||||
title="编辑音色名称"
|
title="编辑音色名称"
|
||||||
onCancel={() => setEditModalOpen(false)}
|
onCancel={handleEditCancel}
|
||||||
onOk={handleEditConfirm}
|
onOk={handleEditConfirm}
|
||||||
okText="保存"
|
okText="保存"
|
||||||
cancelText="取消"
|
cancelText="取消"
|
||||||
@@ -355,7 +110,7 @@ const MyVoices: React.FC = () => {
|
|||||||
<Modal
|
<Modal
|
||||||
open={!!deleteConfirmId}
|
open={!!deleteConfirmId}
|
||||||
title="确认删除"
|
title="确认删除"
|
||||||
onCancel={() => setDeleteConfirmId(null)}
|
onCancel={handleDeleteCancel}
|
||||||
onOk={handleDeleteConfirm}
|
onOk={handleDeleteConfirm}
|
||||||
okText="删除"
|
okText="删除"
|
||||||
cancelText="取消"
|
cancelText="取消"
|
||||||
@@ -365,15 +120,7 @@ const MyVoices: React.FC = () => {
|
|||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
{/* Toast */}
|
{/* Toast */}
|
||||||
{toasts.length > 0 && (
|
<ToastContainer toasts={toasts} />
|
||||||
<div className="xx-mv-toast-container">
|
|
||||||
{toasts.map((t) => (
|
|
||||||
<div key={t.id} className={`xx-mv-toast xx-mv-toast--${t.type}`}>
|
|
||||||
{t.type === "success" ? "✅" : "❌"} {t.message}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import React from "react"
|
||||||
|
import { PlusOutlined } from "@ant-design/icons"
|
||||||
|
import { Button } from "@/components/ui"
|
||||||
|
import type { ToastItem } from "../types"
|
||||||
|
|
||||||
|
interface StatsBarProps {
|
||||||
|
total: number
|
||||||
|
readyCount: number
|
||||||
|
processingCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 统计栏 */
|
||||||
|
export const StatsBar: React.FC<StatsBarProps> = ({ total, readyCount, processingCount }) => (
|
||||||
|
<div className="xx-mv-stats">
|
||||||
|
<span className="xx-mv-stat">
|
||||||
|
共 <strong>{total}</strong> 个音色
|
||||||
|
</span>
|
||||||
|
<span className="xx-mv-stat xx-mv-stat--ready">
|
||||||
|
<span className="xx-mv-stat-dot xx-mv-stat-dot--ready" />
|
||||||
|
就绪 {readyCount}
|
||||||
|
</span>
|
||||||
|
{processingCount > 0 && (
|
||||||
|
<span className="xx-mv-stat xx-mv-stat--processing">
|
||||||
|
<span className="xx-mv-stat-dot xx-mv-stat-dot--processing" />
|
||||||
|
克隆中 {processingCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
interface EmptyStateProps {
|
||||||
|
onClone: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 空状态 */
|
||||||
|
export const EmptyState: React.FC<EmptyStateProps> = ({ onClone }) => (
|
||||||
|
<div className="xx-mv-empty">
|
||||||
|
<div className="xx-mv-empty-icon">🎤</div>
|
||||||
|
<h3 className="xx-mv-empty-title">还没有克隆音色</h3>
|
||||||
|
<p className="xx-mv-empty-desc">上传你的声音样本,AI 将克隆生成你的专属音色</p>
|
||||||
|
<Button buttonType="primary" onClick={onClone}>
|
||||||
|
<PlusOutlined /> 去配音库克隆
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 加载状态 */
|
||||||
|
export const LoadingState: React.FC = () => (
|
||||||
|
<div className="xx-mv-empty">
|
||||||
|
<div className="xx-mv-empty-icon">⏳</div>
|
||||||
|
<p className="xx-mv-empty-desc">加载中...</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 轮询提示 */
|
||||||
|
export const PollingHint: React.FC = () => (
|
||||||
|
<div className="xx-mv-polling-hint">
|
||||||
|
<span className="xx-mv-polling-dot" />
|
||||||
|
正在同步克隆进度...
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
interface ToastContainerProps {
|
||||||
|
toasts: ToastItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Toast 容器 */
|
||||||
|
export const ToastContainer: React.FC<ToastContainerProps> = ({ toasts }) => {
|
||||||
|
if (toasts.length === 0) return null
|
||||||
|
return (
|
||||||
|
<div className="xx-mv-toast-container">
|
||||||
|
{toasts.map((t) => (
|
||||||
|
<div key={t.id} className={`xx-mv-toast xx-mv-toast--${t.type}`}>
|
||||||
|
{t.type === "success" ? "✅" : "❌"} {t.message}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import React from "react"
|
||||||
|
import {
|
||||||
|
PlayCircleOutlined,
|
||||||
|
PauseCircleOutlined,
|
||||||
|
DeleteOutlined,
|
||||||
|
EditOutlined,
|
||||||
|
SoundOutlined,
|
||||||
|
ClockCircleOutlined,
|
||||||
|
} from "@ant-design/icons"
|
||||||
|
import { Button, Tooltip } from "@/components/ui"
|
||||||
|
import { formatDuration, type VoiceClone } from "@/api/voice-clone"
|
||||||
|
import { STATUS_CONFIG } from "../types"
|
||||||
|
import { formatDate } from "../utils"
|
||||||
|
|
||||||
|
interface VoiceCardProps {
|
||||||
|
voice: VoiceClone
|
||||||
|
isPlaying: boolean
|
||||||
|
onTogglePlay: (voice: VoiceClone) => void
|
||||||
|
onEdit: (voice: VoiceClone) => void
|
||||||
|
onDelete: (voice: VoiceClone) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 音色卡片组件
|
||||||
|
*/
|
||||||
|
export const VoiceCard: React.FC<VoiceCardProps> = ({
|
||||||
|
voice,
|
||||||
|
isPlaying,
|
||||||
|
onTogglePlay,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
}) => {
|
||||||
|
const statusCfg = STATUS_CONFIG[voice.status]
|
||||||
|
const isReady = voice.status === "ready"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`xx-mv-card xx-mv-card--${voice.status}`}>
|
||||||
|
{/* 头部:头像 + 名称 + 状态 */}
|
||||||
|
<div className="xx-mv-card-header">
|
||||||
|
<div className={`xx-mv-card-avatar xx-mv-card-avatar--${voice.status}`}>
|
||||||
|
<SoundOutlined />
|
||||||
|
</div>
|
||||||
|
<div className="xx-mv-card-info">
|
||||||
|
<h4 className="xx-mv-card-name">{voice.name}</h4>
|
||||||
|
<span className="xx-mv-status">
|
||||||
|
<span className={`xx-mv-status-dot ${statusCfg.dotClass}`} />
|
||||||
|
{statusCfg.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 元信息 */}
|
||||||
|
<div className="xx-mv-card-meta">
|
||||||
|
<span className="xx-mv-card-meta-item">
|
||||||
|
<ClockCircleOutlined /> {formatDate(voice.created_at)}
|
||||||
|
</span>
|
||||||
|
{voice.duration_seconds > 0 && (
|
||||||
|
<span className="xx-mv-card-meta-item">{formatDuration(voice.duration_seconds)}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 进度条(克隆中 — indeterminate 条纹流动动画) */}
|
||||||
|
{voice.status === "processing" && (
|
||||||
|
<div className="xx-mv-progress xx-mv-progress--indeterminate">
|
||||||
|
<div className="xx-mv-progress-bar" />
|
||||||
|
<span className="xx-mv-progress-text">处理中…</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 操作区 */}
|
||||||
|
<div className="xx-mv-card-actions">
|
||||||
|
{isReady ? (
|
||||||
|
<Button
|
||||||
|
buttonType={isPlaying ? "secondary" : "ghost"}
|
||||||
|
buttonSize="sm"
|
||||||
|
onClick={() => onTogglePlay(voice)}
|
||||||
|
>
|
||||||
|
{isPlaying ? (
|
||||||
|
<>
|
||||||
|
<PauseCircleOutlined /> 暂停
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<PlayCircleOutlined /> 试听
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
) : voice.status === "failed" ? (
|
||||||
|
<Button buttonType="ghost" buttonSize="sm" disabled>
|
||||||
|
克隆失败
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button buttonType="ghost" buttonSize="sm" disabled>
|
||||||
|
处理中...
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<div className="xx-mv-card-icon-actions">
|
||||||
|
<Tooltip title="编辑名称">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="xx-mv-icon-btn"
|
||||||
|
onClick={() => onEdit(voice)}
|
||||||
|
disabled={!isReady}
|
||||||
|
>
|
||||||
|
<EditOutlined />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="删除">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="xx-mv-icon-btn xx-mv-icon-btn--danger"
|
||||||
|
onClick={() => onDelete(voice)}
|
||||||
|
>
|
||||||
|
<DeleteOutlined />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import { useState, useCallback, useRef } from "react"
|
||||||
|
import { useNavigate } from "react-router-dom"
|
||||||
|
import { useCloneProgress } from "@/hooks/useCloneProgress"
|
||||||
|
import { deleteVoiceClone, updateVoiceClone } from "@/api/voice-clone"
|
||||||
|
import type { VoiceClone } from "@/api/voice-clone"
|
||||||
|
import type { ToastItem } from "../types"
|
||||||
|
|
||||||
|
let _toastId = 0
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 我的音色业务 Hook
|
||||||
|
* 封装播放、编辑、删除、Toast 等业务逻辑
|
||||||
|
*/
|
||||||
|
export const useMyVoices = () => {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { clones, loading, removeClone, updateClone, hasProcessing } = useCloneProgress()
|
||||||
|
const [playingId, setPlayingId] = useState<string | null>(null)
|
||||||
|
const [toasts, setToasts] = useState<ToastItem[]>([])
|
||||||
|
const [editModalOpen, setEditModalOpen] = useState(false)
|
||||||
|
const [editingVoice, setEditingVoice] = useState<VoiceClone | null>(null)
|
||||||
|
const [editName, setEditName] = useState("")
|
||||||
|
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null)
|
||||||
|
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||||
|
|
||||||
|
// Toast
|
||||||
|
const showToast = useCallback((message: string, type: ToastItem["type"]) => {
|
||||||
|
const id = ++_toastId
|
||||||
|
setToasts((prev) => [...prev, { id, message, type }])
|
||||||
|
setTimeout(() => setToasts((prev) => prev.filter((t) => t.id !== id)), 3000)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// 试听播放
|
||||||
|
const handleTogglePlay = useCallback(
|
||||||
|
(voice: VoiceClone) => {
|
||||||
|
if (playingId === voice.id) {
|
||||||
|
audioRef.current?.pause()
|
||||||
|
setPlayingId(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (audioRef.current) {
|
||||||
|
audioRef.current.pause()
|
||||||
|
}
|
||||||
|
if (!voice.sample_url) {
|
||||||
|
showToast("暂无试听音频", "error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const audio = new Audio(voice.sample_url)
|
||||||
|
audioRef.current = audio
|
||||||
|
audio.play().catch(() => showToast("播放失败,请检查音频文件", "error"))
|
||||||
|
audio.onended = () => setPlayingId(null)
|
||||||
|
setPlayingId(voice.id)
|
||||||
|
},
|
||||||
|
[playingId, showToast],
|
||||||
|
)
|
||||||
|
|
||||||
|
// 编辑
|
||||||
|
const handleEdit = useCallback((voice: VoiceClone) => {
|
||||||
|
setEditingVoice(voice)
|
||||||
|
setEditName(voice.name)
|
||||||
|
setEditModalOpen(true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleEditCancel = useCallback(() => {
|
||||||
|
setEditModalOpen(false)
|
||||||
|
setEditingVoice(null)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleEditConfirm = useCallback(async () => {
|
||||||
|
if (!editingVoice || !editName.trim()) return
|
||||||
|
try {
|
||||||
|
const updated = await updateVoiceClone(editingVoice.id, {
|
||||||
|
name: editName.trim(),
|
||||||
|
})
|
||||||
|
updateClone(updated)
|
||||||
|
setEditModalOpen(false)
|
||||||
|
setEditingVoice(null)
|
||||||
|
showToast("名称已更新", "success")
|
||||||
|
} catch {
|
||||||
|
showToast("更新失败,请重试", "error")
|
||||||
|
}
|
||||||
|
}, [editingVoice, editName, updateClone, showToast])
|
||||||
|
|
||||||
|
// 删除
|
||||||
|
const handleDelete = useCallback((voice: VoiceClone) => {
|
||||||
|
setDeleteConfirmId(voice.id)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleDeleteCancel = useCallback(() => {
|
||||||
|
setDeleteConfirmId(null)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleDeleteConfirm = useCallback(async () => {
|
||||||
|
if (!deleteConfirmId) return
|
||||||
|
try {
|
||||||
|
await deleteVoiceClone(deleteConfirmId)
|
||||||
|
removeClone(deleteConfirmId)
|
||||||
|
setDeleteConfirmId(null)
|
||||||
|
showToast("音色已删除", "success")
|
||||||
|
} catch {
|
||||||
|
showToast("删除失败,请重试", "error")
|
||||||
|
}
|
||||||
|
}, [deleteConfirmId, removeClone, showToast])
|
||||||
|
|
||||||
|
// 克隆新音色
|
||||||
|
const handleCloneNew = useCallback(() => {
|
||||||
|
navigate("/app/voices")
|
||||||
|
}, [navigate])
|
||||||
|
|
||||||
|
// 统计
|
||||||
|
const readyCount = clones.filter((v) => v.status === "ready").length
|
||||||
|
const processingCount = clones.filter((v) => v.status === "processing").length
|
||||||
|
|
||||||
|
return {
|
||||||
|
// 数据
|
||||||
|
clones,
|
||||||
|
loading,
|
||||||
|
hasProcessing,
|
||||||
|
// 状态
|
||||||
|
playingId,
|
||||||
|
toasts,
|
||||||
|
editModalOpen,
|
||||||
|
editingVoice,
|
||||||
|
editName,
|
||||||
|
setEditName,
|
||||||
|
deleteConfirmId,
|
||||||
|
// 统计
|
||||||
|
readyCount,
|
||||||
|
processingCount,
|
||||||
|
// 操作
|
||||||
|
showToast,
|
||||||
|
handleTogglePlay,
|
||||||
|
handleEdit,
|
||||||
|
handleEditCancel,
|
||||||
|
handleEditConfirm,
|
||||||
|
handleDelete,
|
||||||
|
handleDeleteCancel,
|
||||||
|
handleDeleteConfirm,
|
||||||
|
handleCloneNew,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import type { VoiceCloneStatus } from "@/api/voice-clone"
|
||||||
|
|
||||||
|
/** 状态配置 */
|
||||||
|
export const STATUS_CONFIG: Record<VoiceCloneStatus, { label: string; dotClass: string }> = {
|
||||||
|
ready: { label: "就绪", dotClass: "xx-mv-status-dot--ready" },
|
||||||
|
processing: { label: "克隆中", dotClass: "xx-mv-status-dot--processing" },
|
||||||
|
failed: { label: "失败", dotClass: "xx-mv-status-dot--failed" },
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Toast 类型 */
|
||||||
|
export interface ToastItem {
|
||||||
|
id: number
|
||||||
|
message: string
|
||||||
|
type: "success" | "error"
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
/** 格式化日期 */
|
||||||
|
export function formatDate(isoStr: string): string {
|
||||||
|
const d = new Date(isoStr)
|
||||||
|
return d.toLocaleDateString("zh-CN", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -4,731 +4,132 @@
|
|||||||
* 支持:标题卡片展示、AI 生成标题、复制/编辑/删除、收藏、分类筛选、搜索
|
* 支持:标题卡片展示、AI 生成标题、复制/编辑/删除、收藏、分类筛选、搜索
|
||||||
* 对接后端真实 API(GET/POST/PUT/DELETE /titles)
|
* 对接后端真实 API(GET/POST/PUT/DELETE /titles)
|
||||||
*/
|
*/
|
||||||
import React, { useMemo, useState, useCallback } from "react"
|
import React from "react"
|
||||||
import { Modal as AntModal, message, Popconfirm } from "antd"
|
import { useTitleLibrary } from "./hooks/useTitleLibrary"
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
import { useTitleEdit } from "./hooks/useTitleEdit"
|
||||||
import {
|
import { useTitleAI } from "./hooks/useTitleAI"
|
||||||
PlusOutlined,
|
import { CategorySidebar } from "./components/title-library/CategorySidebar"
|
||||||
SearchOutlined,
|
import { FilterBar } from "./components/title-library/FilterBar"
|
||||||
DeleteOutlined,
|
import { TitleGrid } from "./components/title-library/TitleGrid"
|
||||||
EditOutlined,
|
import { CreateTitleModal } from "./components/title-library/CreateTitleModal"
|
||||||
CopyOutlined,
|
import { AIGenerateModal } from "./components/title-library/AIGenerateModal"
|
||||||
CheckOutlined,
|
|
||||||
RobotOutlined,
|
|
||||||
StarOutlined,
|
|
||||||
StarFilled,
|
|
||||||
FileTextOutlined,
|
|
||||||
} from "@ant-design/icons"
|
|
||||||
import { Button, Input, Select } from "@/components/ui"
|
|
||||||
import { getTitles, createTitle, updateTitle, deleteTitle, type TitleItem } from "@/api/titles"
|
|
||||||
import "./titles.css"
|
import "./titles.css"
|
||||||
|
|
||||||
/* ============================================================
|
|
||||||
* 类型
|
|
||||||
* ============================================================ */
|
|
||||||
type TitleType = "hot" | "normal" | "creative"
|
|
||||||
type Industry = "general" | "food" | "tech" | "beauty" | "education" | "travel"
|
|
||||||
type Frequency = "all" | "high" | "medium" | "low"
|
|
||||||
|
|
||||||
interface TitleData {
|
|
||||||
id: string
|
|
||||||
content: string
|
|
||||||
type: TitleType
|
|
||||||
industry: Industry
|
|
||||||
category: string
|
|
||||||
usageCount: number
|
|
||||||
isFavorited: boolean
|
|
||||||
createdAt: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 后端 TitleItem → 前端 TitleData 映射 */
|
|
||||||
const toTitleData = (item: TitleItem): TitleData => ({
|
|
||||||
id: item.id,
|
|
||||||
content: item.content,
|
|
||||||
type: (item.category as TitleType) || "normal",
|
|
||||||
industry: "general",
|
|
||||||
category: item.category || "未分类",
|
|
||||||
usageCount: 0,
|
|
||||||
isFavorited: false,
|
|
||||||
createdAt: item.created_at?.slice(0, 10) || "",
|
|
||||||
})
|
|
||||||
|
|
||||||
/* ============================================================
|
|
||||||
* 工具函数
|
|
||||||
* ============================================================ */
|
|
||||||
const typeLabel = (type: TitleType): string => {
|
|
||||||
switch (type) {
|
|
||||||
case "hot":
|
|
||||||
return "爆款"
|
|
||||||
case "normal":
|
|
||||||
return "常规"
|
|
||||||
case "creative":
|
|
||||||
return "创意"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 复制文本到剪贴板 */
|
|
||||||
const copyToClipboard = async (text: string): Promise<boolean> => {
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(text)
|
|
||||||
return true
|
|
||||||
} catch {
|
|
||||||
/* 降级方案 */
|
|
||||||
const textarea = document.createElement("textarea")
|
|
||||||
textarea.value = text
|
|
||||||
textarea.style.position = "fixed"
|
|
||||||
textarea.style.opacity = "0"
|
|
||||||
document.body.appendChild(textarea)
|
|
||||||
textarea.select()
|
|
||||||
try {
|
|
||||||
document.execCommand("copy")
|
|
||||||
return true
|
|
||||||
} catch {
|
|
||||||
return false
|
|
||||||
} finally {
|
|
||||||
document.body.removeChild(textarea)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================================
|
|
||||||
* TitleCard 组件
|
|
||||||
* ============================================================ */
|
|
||||||
const TitleCard: React.FC<{
|
|
||||||
title: TitleData
|
|
||||||
isEditing: boolean
|
|
||||||
editText: string
|
|
||||||
onEditChange: (text: string) => void
|
|
||||||
onStartEdit: () => void
|
|
||||||
onSaveEdit: () => void
|
|
||||||
onCancelEdit: () => void
|
|
||||||
onCopy: () => void
|
|
||||||
onDelete: () => void
|
|
||||||
onToggleFavorite: () => void
|
|
||||||
}> = ({
|
|
||||||
title,
|
|
||||||
isEditing,
|
|
||||||
editText,
|
|
||||||
onEditChange,
|
|
||||||
onStartEdit,
|
|
||||||
onSaveEdit,
|
|
||||||
onCancelEdit,
|
|
||||||
onCopy,
|
|
||||||
onDelete,
|
|
||||||
onToggleFavorite,
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<div className="xx-title-card">
|
|
||||||
{/* 收藏按钮 */}
|
|
||||||
<button
|
|
||||||
className="xx-title-fav-btn"
|
|
||||||
onClick={onToggleFavorite}
|
|
||||||
title={title.isFavorited ? "取消收藏" : "收藏"}
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: 12,
|
|
||||||
right: 12,
|
|
||||||
color: title.isFavorited ? "#f59e0b" : "var(--text-tertiary)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{title.isFavorited ? <StarFilled /> : <StarOutlined />}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* 标题文本 / 编辑区 */}
|
|
||||||
{isEditing ? (
|
|
||||||
<textarea
|
|
||||||
className="xx-title-card-edit"
|
|
||||||
value={editText}
|
|
||||||
onChange={(e) => onEditChange(e.target.value)}
|
|
||||||
autoFocus
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter" && !e.shiftKey) {
|
|
||||||
e.preventDefault()
|
|
||||||
onSaveEdit()
|
|
||||||
}
|
|
||||||
if (e.key === "Escape") {
|
|
||||||
onCancelEdit()
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="xx-title-card-text" style={{ paddingRight: 24 }}>
|
|
||||||
{title.content}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 底部元信息 */}
|
|
||||||
<div className="xx-title-card-meta">
|
|
||||||
<div className="xx-title-card-meta-left">
|
|
||||||
<span className={`xx-title-type-tag ${title.type}`}>{typeLabel(title.type)}</span>
|
|
||||||
<span className="xx-title-card-stat">使用 {title.usageCount} 次</span>
|
|
||||||
<span className="xx-title-card-stat">{title.createdAt}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="xx-title-card-actions">
|
|
||||||
{isEditing ? (
|
|
||||||
<>
|
|
||||||
<button className="xx-title-card-action-btn" onClick={onSaveEdit} title="保存">
|
|
||||||
<CheckOutlined />
|
|
||||||
</button>
|
|
||||||
<button className="xx-title-card-action-btn" onClick={onCancelEdit} title="取消">
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<button className="xx-title-card-action-btn" onClick={onCopy} title="复制">
|
|
||||||
<CopyOutlined />
|
|
||||||
</button>
|
|
||||||
<button className="xx-title-card-action-btn" onClick={onStartEdit} title="编辑">
|
|
||||||
<EditOutlined />
|
|
||||||
</button>
|
|
||||||
<Popconfirm
|
|
||||||
title="确定删除此标题?"
|
|
||||||
onConfirm={onDelete}
|
|
||||||
okText="删除"
|
|
||||||
cancelText="取消"
|
|
||||||
>
|
|
||||||
<button className="xx-title-card-action-btn danger" title="删除">
|
|
||||||
<DeleteOutlined />
|
|
||||||
</button>
|
|
||||||
</Popconfirm>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================================
|
|
||||||
* 主组件
|
|
||||||
* ============================================================ */
|
|
||||||
const TitleLibrary: React.FC = () => {
|
const TitleLibrary: React.FC = () => {
|
||||||
const queryClient = useQueryClient()
|
const {
|
||||||
|
categories,
|
||||||
|
activeCatId,
|
||||||
|
filteredTitles,
|
||||||
|
searchText,
|
||||||
|
filterType,
|
||||||
|
filterIndustry,
|
||||||
|
filterFrequency,
|
||||||
|
createMutation,
|
||||||
|
updateMutation,
|
||||||
|
setActiveCatId,
|
||||||
|
setSearchText,
|
||||||
|
setFilterType,
|
||||||
|
setFilterIndustry,
|
||||||
|
setFilterFrequency,
|
||||||
|
handleToggleFavorite,
|
||||||
|
handleCopy,
|
||||||
|
handleDelete,
|
||||||
|
} = useTitleLibrary()
|
||||||
|
|
||||||
/* 分类数据 — 从真实标题数据动态派生 */
|
const {
|
||||||
const [activeCatId, setActiveCatId] = useState<string>("cat-all")
|
editingId,
|
||||||
|
editText,
|
||||||
|
setEditText,
|
||||||
|
createTitleModalOpen,
|
||||||
|
setCreateTitleModalOpen,
|
||||||
|
newTitleContent,
|
||||||
|
setNewTitleContent,
|
||||||
|
newTitleType,
|
||||||
|
setNewTitleType,
|
||||||
|
handleStartEdit,
|
||||||
|
handleSaveEdit,
|
||||||
|
handleCancelEdit,
|
||||||
|
handleCreateTitle,
|
||||||
|
handleCloseCreateModal,
|
||||||
|
} = useTitleEdit({ updateMutation, createMutation })
|
||||||
|
|
||||||
/* 标题数据 — 真实 API */
|
const {
|
||||||
const { data: apiTitles = [] } = useQuery({
|
aiModalOpen,
|
||||||
queryKey: ["titles"],
|
setAiModalOpen,
|
||||||
queryFn: getTitles,
|
aiKeyword,
|
||||||
staleTime: 30_000,
|
setAiKeyword,
|
||||||
})
|
aiLoading,
|
||||||
const titles: TitleData[] = useMemo(() => apiTitles.map(toTitleData), [apiTitles])
|
aiResults,
|
||||||
|
handleAIGenerate,
|
||||||
/* 从真实标题数据动态派生分类(无需后端分类 API) */
|
handleAdoptAITitle,
|
||||||
const categories = useMemo(() => {
|
handleCopyAI,
|
||||||
const cats = new Map<string, number>()
|
handleCloseAIModal,
|
||||||
apiTitles.forEach((t) => {
|
} = useTitleAI({ createMutation })
|
||||||
const cat = t.category || "未分类"
|
|
||||||
cats.set(cat, (cats.get(cat) || 0) + 1)
|
|
||||||
})
|
|
||||||
return [
|
|
||||||
{ id: "cat-all", name: "全部标题", count: apiTitles.length },
|
|
||||||
...Array.from(cats.entries()).map(([name, count]) => ({
|
|
||||||
id: `cat-${name}`,
|
|
||||||
name,
|
|
||||||
count,
|
|
||||||
})),
|
|
||||||
]
|
|
||||||
}, [apiTitles])
|
|
||||||
|
|
||||||
/* CRUD mutations */
|
|
||||||
const createMutation = useMutation({
|
|
||||||
mutationFn: (content: string) => createTitle({ content }),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["titles"] })
|
|
||||||
},
|
|
||||||
onError: () => message.error("创建标题失败"),
|
|
||||||
})
|
|
||||||
|
|
||||||
const updateMutation = useMutation({
|
|
||||||
mutationFn: ({ id, content }: { id: string; content: string }) => updateTitle(id, { content }),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["titles"] })
|
|
||||||
},
|
|
||||||
onError: () => message.error("更新标题失败"),
|
|
||||||
})
|
|
||||||
|
|
||||||
const deleteMutation = useMutation({
|
|
||||||
mutationFn: (id: string) => deleteTitle(id),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["titles"] })
|
|
||||||
},
|
|
||||||
onError: () => message.error("删除标题失败"),
|
|
||||||
})
|
|
||||||
|
|
||||||
/* 筛选 */
|
|
||||||
const [searchText, setSearchText] = useState("")
|
|
||||||
const [filterType, setFilterType] = useState<string>("all")
|
|
||||||
const [filterIndustry, setFilterIndustry] = useState<string>("all")
|
|
||||||
const [filterFrequency, setFilterFrequency] = useState<Frequency>("all")
|
|
||||||
|
|
||||||
/* 编辑状态 */
|
|
||||||
const [editingId, setEditingId] = useState<string | null>(null)
|
|
||||||
const [editText, setEditText] = useState("")
|
|
||||||
|
|
||||||
/* 新建标题 */
|
|
||||||
const [createTitleModalOpen, setCreateTitleModalOpen] = useState(false)
|
|
||||||
const [newTitleContent, setNewTitleContent] = useState("")
|
|
||||||
const [newTitleType, setNewTitleType] = useState<TitleType>("normal")
|
|
||||||
|
|
||||||
/* AI 生成 */
|
|
||||||
const [aiModalOpen, setAiModalOpen] = useState(false)
|
|
||||||
const [aiKeyword, setAiKeyword] = useState("")
|
|
||||||
const [aiLoading, setAiLoading] = useState(false)
|
|
||||||
const [aiResults, setAiResults] = useState<string[]>([])
|
|
||||||
|
|
||||||
/* 派生数据 */
|
|
||||||
const activeCategory = categories.find((c) => c.id === activeCatId)
|
|
||||||
|
|
||||||
const filteredTitles = useMemo(() => {
|
|
||||||
let list = titles
|
|
||||||
|
|
||||||
/* 按分类过滤("全部标题" 不过滤)— 直接匹配后端 category 字段 */
|
|
||||||
if (activeCatId !== "cat-all") {
|
|
||||||
const catName = activeCategory?.name || ""
|
|
||||||
if (catName) {
|
|
||||||
list = list.filter((t) => t.category === catName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 按类型筛选 */
|
|
||||||
if (filterType !== "all") {
|
|
||||||
list = list.filter((t) => t.type === filterType)
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 按行业筛选 */
|
|
||||||
if (filterIndustry !== "all") {
|
|
||||||
list = list.filter((t) => t.industry === filterIndustry)
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 按使用频率筛选 */
|
|
||||||
if (filterFrequency !== "all") {
|
|
||||||
switch (filterFrequency) {
|
|
||||||
case "high":
|
|
||||||
list = list.filter((t) => t.usageCount >= 100)
|
|
||||||
break
|
|
||||||
case "medium":
|
|
||||||
list = list.filter((t) => t.usageCount >= 30 && t.usageCount < 100)
|
|
||||||
break
|
|
||||||
case "low":
|
|
||||||
list = list.filter((t) => t.usageCount < 30)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 搜索 */
|
|
||||||
if (searchText.trim()) {
|
|
||||||
const q = searchText.trim().toLowerCase()
|
|
||||||
list = list.filter((t) => t.content.toLowerCase().includes(q))
|
|
||||||
}
|
|
||||||
|
|
||||||
return list
|
|
||||||
}, [titles, activeCatId, activeCategory, filterType, filterIndustry, filterFrequency, searchText])
|
|
||||||
|
|
||||||
/* 收藏切换(暂不支持,待后端 API) */
|
|
||||||
const handleToggleFavorite = useCallback((_id: string) => {
|
|
||||||
message.info("收藏功能即将上线")
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
/* 复制 */
|
|
||||||
const handleCopy = useCallback(async (title: TitleData) => {
|
|
||||||
const ok = await copyToClipboard(title.content)
|
|
||||||
if (ok) {
|
|
||||||
message.success("已复制到剪贴板")
|
|
||||||
} else {
|
|
||||||
message.error("复制失败")
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
/* 编辑 */
|
|
||||||
const handleStartEdit = useCallback((title: TitleData) => {
|
|
||||||
setEditingId(title.id)
|
|
||||||
setEditText(title.content)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleSaveEdit = useCallback(() => {
|
|
||||||
if (!editText.trim()) {
|
|
||||||
message.warning("标题内容不能为空")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (editingId) {
|
|
||||||
updateMutation.mutate({ id: editingId, content: editText.trim() })
|
|
||||||
}
|
|
||||||
setEditingId(null)
|
|
||||||
setEditText("")
|
|
||||||
message.success("标题已更新")
|
|
||||||
}, [editingId, editText, updateMutation])
|
|
||||||
|
|
||||||
const handleCancelEdit = useCallback(() => {
|
|
||||||
setEditingId(null)
|
|
||||||
setEditText("")
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
/* 删除 */
|
|
||||||
const handleDelete = useCallback(
|
|
||||||
(id: string) => {
|
|
||||||
deleteMutation.mutate(id)
|
|
||||||
message.success("标题已删除")
|
|
||||||
},
|
|
||||||
[deleteMutation],
|
|
||||||
)
|
|
||||||
|
|
||||||
/* 新建标题 */
|
|
||||||
const handleCreateTitle = () => {
|
|
||||||
if (!newTitleContent.trim()) {
|
|
||||||
message.warning("请输入标题内容")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
createMutation.mutate(newTitleContent.trim(), {
|
|
||||||
onSuccess: () => {
|
|
||||||
setCreateTitleModalOpen(false)
|
|
||||||
setNewTitleContent("")
|
|
||||||
setNewTitleType("normal")
|
|
||||||
message.success("标题创建成功")
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/* AI 生成标题 */
|
|
||||||
const handleAIGenerate = () => {
|
|
||||||
if (!aiKeyword.trim()) {
|
|
||||||
message.warning("请输入关键词或主题")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setAiLoading(true)
|
|
||||||
setAiResults([])
|
|
||||||
|
|
||||||
/* Mock AI 生成延迟 */
|
|
||||||
setTimeout(() => {
|
|
||||||
const keyword = aiKeyword.trim()
|
|
||||||
const results = [
|
|
||||||
`${keyword}:这个方法让我事半功倍!`,
|
|
||||||
`关于${keyword},99%的人都不知道的事`,
|
|
||||||
`${keyword}全攻略,看完这篇就够了`,
|
|
||||||
`我花了 3 个月研究${keyword},总结出这些经验`,
|
|
||||||
`${keyword}避坑指南,帮你省下 1000 块`,
|
|
||||||
]
|
|
||||||
setAiResults(results)
|
|
||||||
setAiLoading(false)
|
|
||||||
}, 2000)
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 采纳 AI 生成的标题 */
|
|
||||||
const handleAdoptAITitle = (text: string) => {
|
|
||||||
createMutation.mutate(text, {
|
|
||||||
onSuccess: () => {
|
|
||||||
message.success("标题已采纳并添加到标题库")
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 复制 AI 生成的标题 */
|
|
||||||
const handleCopyAI = async (text: string) => {
|
|
||||||
const ok = await copyToClipboard(text)
|
|
||||||
if (ok) {
|
|
||||||
message.success("已复制到剪贴板")
|
|
||||||
} else {
|
|
||||||
message.error("复制失败")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="xx-titles-page">
|
<div className="xx-titles-page">
|
||||||
{/* 两栏布局 */}
|
|
||||||
<div className="xx-titles-layout">
|
<div className="xx-titles-layout">
|
||||||
{/* ─── 左侧:分类列表 ─── */}
|
{/* 左侧:分类列表 */}
|
||||||
<div className="xx-title-category-list">
|
<CategorySidebar
|
||||||
{categories.map((cat) => (
|
categories={categories}
|
||||||
<div
|
activeCatId={activeCatId}
|
||||||
key={cat.id}
|
onSelect={setActiveCatId}
|
||||||
className={`xx-title-category-item${cat.id === activeCatId ? " active" : ""}`}
|
/>
|
||||||
onClick={() => setActiveCatId(cat.id)}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ minWidth: 0 }}>
|
|
||||||
<h4 style={{ margin: 0 }}>
|
|
||||||
<FileTextOutlined /> {cat.name}
|
|
||||||
</h4>
|
|
||||||
<span>{cat.count} 条</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* TODO: 新建分类功能待后端分类 API 就绪后启用 */}
|
{/* 右侧:内容区 */}
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ─── 右侧:内容区 ─── */}
|
|
||||||
<div className="xx-titles-content">
|
<div className="xx-titles-content">
|
||||||
{/* 筛选栏 */}
|
<FilterBar
|
||||||
<div className="xx-titles-filters">
|
searchText={searchText}
|
||||||
<div className="xx-titles-filters-left">
|
onSearchChange={setSearchText}
|
||||||
<Input
|
filterType={filterType}
|
||||||
placeholder="搜索标题关键词..."
|
onFilterTypeChange={setFilterType}
|
||||||
prefix={<SearchOutlined />}
|
filterIndustry={filterIndustry}
|
||||||
value={searchText}
|
onFilterIndustryChange={setFilterIndustry}
|
||||||
onChange={(e) => setSearchText(e.target.value)}
|
filterFrequency={filterFrequency}
|
||||||
allowClear
|
onFilterFrequencyChange={setFilterFrequency}
|
||||||
style={{ width: 220 }}
|
onCreateClick={() => setCreateTitleModalOpen(true)}
|
||||||
/>
|
onAIClick={() => setAiModalOpen(true)}
|
||||||
<Select
|
/>
|
||||||
value={filterType}
|
|
||||||
onChange={setFilterType}
|
|
||||||
style={{ width: 110 }}
|
|
||||||
options={[
|
|
||||||
{ value: "all", label: "全部类型" },
|
|
||||||
{ value: "hot", label: "爆款" },
|
|
||||||
{ value: "normal", label: "常规" },
|
|
||||||
{ value: "creative", label: "创意" },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<Select
|
|
||||||
value={filterIndustry}
|
|
||||||
onChange={setFilterIndustry}
|
|
||||||
style={{ width: 110 }}
|
|
||||||
options={[
|
|
||||||
{ value: "all", label: "全部行业" },
|
|
||||||
{ value: "food", label: "美食" },
|
|
||||||
{ value: "tech", label: "科技" },
|
|
||||||
{ value: "beauty", label: "美妆" },
|
|
||||||
{ value: "education", label: "教育" },
|
|
||||||
{ value: "travel", label: "旅行" },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<Select
|
|
||||||
value={filterFrequency}
|
|
||||||
onChange={(v) => setFilterFrequency(v as Frequency)}
|
|
||||||
style={{ width: 120 }}
|
|
||||||
options={[
|
|
||||||
{ value: "all", label: "全部频率" },
|
|
||||||
{ value: "high", label: "高频使用" },
|
|
||||||
{ value: "medium", label: "中频使用" },
|
|
||||||
{ value: "low", label: "低频使用" },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="xx-titles-filters-right">
|
|
||||||
<Button
|
|
||||||
buttonType="ghost"
|
|
||||||
buttonSize="sm"
|
|
||||||
icon={<PlusOutlined />}
|
|
||||||
onClick={() => setCreateTitleModalOpen(true)}
|
|
||||||
>
|
|
||||||
新建标题
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
buttonType="primary"
|
|
||||||
buttonSize="sm"
|
|
||||||
icon={<RobotOutlined />}
|
|
||||||
onClick={() => setAiModalOpen(true)}
|
|
||||||
>
|
|
||||||
AI 生成标题
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 标题卡片网格 */}
|
<TitleGrid
|
||||||
{filteredTitles.length > 0 ? (
|
titles={filteredTitles}
|
||||||
<div className="xx-title-grid">
|
editingId={editingId}
|
||||||
{filteredTitles.map((title) => (
|
editText={editText}
|
||||||
<TitleCard
|
searchText={searchText}
|
||||||
key={title.id}
|
onEditChange={setEditText}
|
||||||
title={title}
|
onStartEdit={handleStartEdit}
|
||||||
isEditing={editingId === title.id}
|
onSaveEdit={handleSaveEdit}
|
||||||
editText={editingId === title.id ? editText : ""}
|
onCancelEdit={handleCancelEdit}
|
||||||
onEditChange={setEditText}
|
onCopy={handleCopy}
|
||||||
onStartEdit={() => handleStartEdit(title)}
|
onDelete={handleDelete}
|
||||||
onSaveEdit={handleSaveEdit}
|
onToggleFavorite={handleToggleFavorite}
|
||||||
onCancelEdit={handleCancelEdit}
|
/>
|
||||||
onCopy={() => handleCopy(title)}
|
|
||||||
onDelete={() => handleDelete(title.id)}
|
|
||||||
onToggleFavorite={() => handleToggleFavorite(title.id)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="xx-titles-empty">
|
|
||||||
<div className="xx-titles-empty-icon">
|
|
||||||
<FileTextOutlined />
|
|
||||||
</div>
|
|
||||||
<p>
|
|
||||||
{searchText
|
|
||||||
? "未找到匹配的标题"
|
|
||||||
: "暂无标题,点击「新建标题」或「AI 生成标题」开始"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ─── 新建标题弹窗 ─── */}
|
{/* 新建标题弹窗 */}
|
||||||
<AntModal
|
<CreateTitleModal
|
||||||
title="新建标题"
|
|
||||||
open={createTitleModalOpen}
|
open={createTitleModalOpen}
|
||||||
onCancel={() => setCreateTitleModalOpen(false)}
|
newTitleContent={newTitleContent}
|
||||||
onOk={handleCreateTitle}
|
newTitleType={newTitleType}
|
||||||
okText="创建"
|
onContentChange={setNewTitleContent}
|
||||||
cancelText="取消"
|
onTypeChange={setNewTitleType}
|
||||||
destroyOnClose
|
onCancel={handleCloseCreateModal}
|
||||||
>
|
onSubmit={handleCreateTitle}
|
||||||
<div
|
/>
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
gap: 16,
|
|
||||||
padding: "8px 0",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
marginBottom: 6,
|
|
||||||
fontSize: "var(--font-size-sm)",
|
|
||||||
color: "var(--text-secondary)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
标题内容
|
|
||||||
</div>
|
|
||||||
<Input.TextArea
|
|
||||||
placeholder="请输入标题内容"
|
|
||||||
value={newTitleContent}
|
|
||||||
onChange={(e) => setNewTitleContent(e.target.value)}
|
|
||||||
rows={3}
|
|
||||||
maxLength={200}
|
|
||||||
showCount
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
marginBottom: 6,
|
|
||||||
fontSize: "var(--font-size-sm)",
|
|
||||||
color: "var(--text-secondary)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
标题类型
|
|
||||||
</div>
|
|
||||||
<Select
|
|
||||||
value={newTitleType}
|
|
||||||
onChange={(v) => setNewTitleType(v)}
|
|
||||||
style={{ width: "100%" }}
|
|
||||||
options={[
|
|
||||||
{ value: "hot", label: "爆款" },
|
|
||||||
{ value: "normal", label: "常规" },
|
|
||||||
{ value: "creative", label: "创意" },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</AntModal>
|
|
||||||
|
|
||||||
{/* ─── AI 生成标题弹窗 ─── */}
|
{/* AI 生成标题弹窗 */}
|
||||||
<AntModal
|
<AIGenerateModal
|
||||||
title="AI 生成标题"
|
|
||||||
open={aiModalOpen}
|
open={aiModalOpen}
|
||||||
onCancel={() => {
|
aiKeyword={aiKeyword}
|
||||||
setAiModalOpen(false)
|
aiLoading={aiLoading}
|
||||||
setAiLoading(false)
|
aiResults={aiResults}
|
||||||
setAiResults([])
|
onKeywordChange={setAiKeyword}
|
||||||
setAiKeyword("")
|
onGenerate={handleAIGenerate}
|
||||||
}}
|
onCancel={handleCloseAIModal}
|
||||||
onOk={handleAIGenerate}
|
onCopy={handleCopyAI}
|
||||||
okText={aiLoading ? "生成中..." : "生成"}
|
onAdopt={handleAdoptAITitle}
|
||||||
cancelText="关闭"
|
/>
|
||||||
okButtonProps={{ disabled: aiLoading }}
|
|
||||||
destroyOnClose
|
|
||||||
width={640}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
gap: 16,
|
|
||||||
padding: "8px 0",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
marginBottom: 6,
|
|
||||||
fontSize: "var(--font-size-sm)",
|
|
||||||
color: "var(--text-secondary)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
输入关键词或主题
|
|
||||||
</div>
|
|
||||||
<Input
|
|
||||||
placeholder="例如:美食探店、科技评测、旅行攻略..."
|
|
||||||
value={aiKeyword}
|
|
||||||
onChange={(e) => setAiKeyword(e.target.value)}
|
|
||||||
maxLength={100}
|
|
||||||
onPressEnter={handleAIGenerate}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* AI 加载动画 */}
|
|
||||||
{aiLoading && (
|
|
||||||
<div className="xx-ai-loading">
|
|
||||||
<div className="xx-ai-loading-dots">
|
|
||||||
<div className="xx-ai-loading-dot" />
|
|
||||||
<div className="xx-ai-loading-dot" />
|
|
||||||
<div className="xx-ai-loading-dot" />
|
|
||||||
</div>
|
|
||||||
<span>AI 正在生成标题候选...</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* AI 生成结果列表 */}
|
|
||||||
{aiResults.length > 0 && (
|
|
||||||
<div className="xx-ai-results">
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: "var(--font-size-sm)",
|
|
||||||
color: "var(--text-secondary)",
|
|
||||||
marginBottom: 4,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
已生成 {aiResults.length} 个候选标题,点击采纳或复制:
|
|
||||||
</div>
|
|
||||||
{aiResults.map((text, idx) => (
|
|
||||||
<div key={idx} className="xx-ai-result-item">
|
|
||||||
<span className="xx-ai-result-text">{text}</span>
|
|
||||||
<div className="xx-ai-result-actions">
|
|
||||||
<Button
|
|
||||||
buttonType="ghost"
|
|
||||||
buttonSize="sm"
|
|
||||||
icon={<CopyOutlined />}
|
|
||||||
onClick={() => handleCopyAI(text)}
|
|
||||||
>
|
|
||||||
复制
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
buttonType="primary"
|
|
||||||
buttonSize="sm"
|
|
||||||
icon={<CheckOutlined />}
|
|
||||||
onClick={() => handleAdoptAITitle(text)}
|
|
||||||
>
|
|
||||||
采纳
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</AntModal>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import React from "react"
|
||||||
|
import { Modal as AntModal } from "antd"
|
||||||
|
import { CopyOutlined, CheckOutlined } from "@ant-design/icons"
|
||||||
|
import { Button, Input } from "@/components/ui"
|
||||||
|
import { AI_KEYWORD_MAX_LENGTH } from "../../constants/titleLibrary"
|
||||||
|
|
||||||
|
interface AIGenerateModalProps {
|
||||||
|
open: boolean
|
||||||
|
aiKeyword: string
|
||||||
|
aiLoading: boolean
|
||||||
|
aiResults: string[]
|
||||||
|
onKeywordChange: (keyword: string) => void
|
||||||
|
onGenerate: () => void
|
||||||
|
onCancel: () => void
|
||||||
|
onCopy: (text: string) => void
|
||||||
|
onAdopt: (text: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AIGenerateModal: React.FC<AIGenerateModalProps> = ({
|
||||||
|
open,
|
||||||
|
aiKeyword,
|
||||||
|
aiLoading,
|
||||||
|
aiResults,
|
||||||
|
onKeywordChange,
|
||||||
|
onGenerate,
|
||||||
|
onCancel,
|
||||||
|
onCopy,
|
||||||
|
onAdopt,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<AntModal
|
||||||
|
title="AI 生成标题"
|
||||||
|
open={open}
|
||||||
|
onCancel={onCancel}
|
||||||
|
onOk={onGenerate}
|
||||||
|
okText={aiLoading ? "生成中..." : "生成"}
|
||||||
|
cancelText="关闭"
|
||||||
|
okButtonProps={{ disabled: aiLoading }}
|
||||||
|
destroyOnClose
|
||||||
|
width={640}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 16,
|
||||||
|
padding: "8px 0",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginBottom: 6,
|
||||||
|
fontSize: "var(--font-size-sm)",
|
||||||
|
color: "var(--text-secondary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
输入关键词或主题
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
placeholder="例如:美食探店、科技评测、旅行攻略..."
|
||||||
|
value={aiKeyword}
|
||||||
|
onChange={(e) => onKeywordChange(e.target.value)}
|
||||||
|
maxLength={AI_KEYWORD_MAX_LENGTH}
|
||||||
|
onPressEnter={onGenerate}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* AI 加载动画 */}
|
||||||
|
{aiLoading && (
|
||||||
|
<div className="xx-ai-loading">
|
||||||
|
<div className="xx-ai-loading-dots">
|
||||||
|
<div className="xx-ai-loading-dot" />
|
||||||
|
<div className="xx-ai-loading-dot" />
|
||||||
|
<div className="xx-ai-loading-dot" />
|
||||||
|
</div>
|
||||||
|
<span>AI 正在生成标题候选...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* AI 生成结果列表 */}
|
||||||
|
{aiResults.length > 0 && (
|
||||||
|
<div className="xx-ai-results">
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "var(--font-size-sm)",
|
||||||
|
color: "var(--text-secondary)",
|
||||||
|
marginBottom: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
已生成 {aiResults.length} 个候选标题,点击采纳或复制:
|
||||||
|
</div>
|
||||||
|
{aiResults.map((text, idx) => (
|
||||||
|
<div key={idx} className="xx-ai-result-item">
|
||||||
|
<span className="xx-ai-result-text">{text}</span>
|
||||||
|
<div className="xx-ai-result-actions">
|
||||||
|
<Button
|
||||||
|
buttonType="ghost"
|
||||||
|
buttonSize="sm"
|
||||||
|
icon={<CopyOutlined />}
|
||||||
|
onClick={() => onCopy(text)}
|
||||||
|
>
|
||||||
|
复制
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
buttonType="primary"
|
||||||
|
buttonSize="sm"
|
||||||
|
icon={<CheckOutlined />}
|
||||||
|
onClick={() => onAdopt(text)}
|
||||||
|
>
|
||||||
|
采纳
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</AntModal>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import React from "react"
|
||||||
|
import { FileTextOutlined } from "@ant-design/icons"
|
||||||
|
import type { CategoryItem } from "../../types/titleLibrary"
|
||||||
|
|
||||||
|
interface CategorySidebarProps {
|
||||||
|
categories: CategoryItem[]
|
||||||
|
activeCatId: string
|
||||||
|
onSelect: (catId: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CategorySidebar: React.FC<CategorySidebarProps> = ({
|
||||||
|
categories,
|
||||||
|
activeCatId,
|
||||||
|
onSelect,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className="xx-title-category-list">
|
||||||
|
{categories.map((cat) => (
|
||||||
|
<div
|
||||||
|
key={cat.id}
|
||||||
|
className={`xx-title-category-item${cat.id === activeCatId ? " active" : ""}`}
|
||||||
|
onClick={() => onSelect(cat.id)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ minWidth: 0 }}>
|
||||||
|
<h4 style={{ margin: 0 }}>
|
||||||
|
<FileTextOutlined /> {cat.name}
|
||||||
|
</h4>
|
||||||
|
<span>{cat.count} 条</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import React from "react"
|
||||||
|
import { Modal as AntModal } from "antd"
|
||||||
|
import { Input, Select } from "@/components/ui"
|
||||||
|
import type { TitleType } from "../../types/titleLibrary"
|
||||||
|
import { TITLE_MAX_LENGTH } from "../../constants/titleLibrary"
|
||||||
|
|
||||||
|
const TITLE_TYPE_CREATE_OPTIONS: Array<{ value: TitleType; label: string }> = [
|
||||||
|
{ value: "hot", label: "爆款" },
|
||||||
|
{ value: "normal", label: "常规" },
|
||||||
|
{ value: "creative", label: "创意" },
|
||||||
|
]
|
||||||
|
|
||||||
|
interface CreateTitleModalProps {
|
||||||
|
open: boolean
|
||||||
|
newTitleContent: string
|
||||||
|
newTitleType: TitleType
|
||||||
|
onContentChange: (content: string) => void
|
||||||
|
onTypeChange: (type: TitleType) => void
|
||||||
|
onCancel: () => void
|
||||||
|
onSubmit: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CreateTitleModal: React.FC<CreateTitleModalProps> = ({
|
||||||
|
open,
|
||||||
|
newTitleContent,
|
||||||
|
newTitleType,
|
||||||
|
onContentChange,
|
||||||
|
onTypeChange,
|
||||||
|
onCancel,
|
||||||
|
onSubmit,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<AntModal
|
||||||
|
title="新建标题"
|
||||||
|
open={open}
|
||||||
|
onCancel={onCancel}
|
||||||
|
onOk={onSubmit}
|
||||||
|
okText="创建"
|
||||||
|
cancelText="取消"
|
||||||
|
destroyOnClose
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 16,
|
||||||
|
padding: "8px 0",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginBottom: 6,
|
||||||
|
fontSize: "var(--font-size-sm)",
|
||||||
|
color: "var(--text-secondary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
标题内容
|
||||||
|
</div>
|
||||||
|
<Input.TextArea
|
||||||
|
placeholder="请输入标题内容"
|
||||||
|
value={newTitleContent}
|
||||||
|
onChange={(e) => onContentChange(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
maxLength={TITLE_MAX_LENGTH}
|
||||||
|
showCount
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginBottom: 6,
|
||||||
|
fontSize: "var(--font-size-sm)",
|
||||||
|
color: "var(--text-secondary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
标题类型
|
||||||
|
</div>
|
||||||
|
<Select
|
||||||
|
value={newTitleType}
|
||||||
|
onChange={(v) => onTypeChange(v as TitleType)}
|
||||||
|
style={{ width: "100%" }}
|
||||||
|
options={TITLE_TYPE_CREATE_OPTIONS}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AntModal>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import React from "react"
|
||||||
|
import { SearchOutlined, PlusOutlined, RobotOutlined } from "@ant-design/icons"
|
||||||
|
import { Button, Input, Select } from "@/components/ui"
|
||||||
|
import type { Frequency } from "../../types/titleLibrary"
|
||||||
|
import {
|
||||||
|
TITLE_TYPE_OPTIONS,
|
||||||
|
INDUSTRY_OPTIONS,
|
||||||
|
FREQUENCY_OPTIONS,
|
||||||
|
} from "../../constants/titleLibrary"
|
||||||
|
|
||||||
|
interface FilterBarProps {
|
||||||
|
searchText: string
|
||||||
|
onSearchChange: (text: string) => void
|
||||||
|
filterType: string
|
||||||
|
onFilterTypeChange: (value: string) => void
|
||||||
|
filterIndustry: string
|
||||||
|
onFilterIndustryChange: (value: string) => void
|
||||||
|
filterFrequency: Frequency
|
||||||
|
onFilterFrequencyChange: (value: Frequency) => void
|
||||||
|
onCreateClick: () => void
|
||||||
|
onAIClick: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FilterBar: React.FC<FilterBarProps> = ({
|
||||||
|
searchText,
|
||||||
|
onSearchChange,
|
||||||
|
filterType,
|
||||||
|
onFilterTypeChange,
|
||||||
|
filterIndustry,
|
||||||
|
onFilterIndustryChange,
|
||||||
|
filterFrequency,
|
||||||
|
onFilterFrequencyChange,
|
||||||
|
onCreateClick,
|
||||||
|
onAIClick,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className="xx-titles-filters">
|
||||||
|
<div className="xx-titles-filters-left">
|
||||||
|
<Input
|
||||||
|
placeholder="搜索标题关键词..."
|
||||||
|
prefix={<SearchOutlined />}
|
||||||
|
value={searchText}
|
||||||
|
onChange={(e) => onSearchChange(e.target.value)}
|
||||||
|
allowClear
|
||||||
|
style={{ width: 220 }}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
value={filterType}
|
||||||
|
onChange={onFilterTypeChange}
|
||||||
|
style={{ width: 110 }}
|
||||||
|
options={TITLE_TYPE_OPTIONS}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
value={filterIndustry}
|
||||||
|
onChange={onFilterIndustryChange}
|
||||||
|
style={{ width: 110 }}
|
||||||
|
options={INDUSTRY_OPTIONS}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
value={filterFrequency}
|
||||||
|
onChange={(v) => onFilterFrequencyChange(v as Frequency)}
|
||||||
|
style={{ width: 120 }}
|
||||||
|
options={FREQUENCY_OPTIONS}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="xx-titles-filters-right">
|
||||||
|
<Button buttonType="ghost" buttonSize="sm" icon={<PlusOutlined />} onClick={onCreateClick}>
|
||||||
|
新建标题
|
||||||
|
</Button>
|
||||||
|
<Button buttonType="primary" buttonSize="sm" icon={<RobotOutlined />} onClick={onAIClick}>
|
||||||
|
AI 生成标题
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import React from "react"
|
||||||
|
import { Popconfirm } from "antd"
|
||||||
|
import {
|
||||||
|
StarOutlined,
|
||||||
|
StarFilled,
|
||||||
|
EditOutlined,
|
||||||
|
CopyOutlined,
|
||||||
|
DeleteOutlined,
|
||||||
|
CheckOutlined,
|
||||||
|
} from "@ant-design/icons"
|
||||||
|
import type { TitleData } from "../../types/titleLibrary"
|
||||||
|
import { typeLabel } from "../../utils/titleLibrary"
|
||||||
|
|
||||||
|
interface TitleCardProps {
|
||||||
|
title: TitleData
|
||||||
|
isEditing: boolean
|
||||||
|
editText: string
|
||||||
|
onEditChange: (text: string) => void
|
||||||
|
onStartEdit: () => void
|
||||||
|
onSaveEdit: () => void
|
||||||
|
onCancelEdit: () => void
|
||||||
|
onCopy: () => void
|
||||||
|
onDelete: () => void
|
||||||
|
onToggleFavorite: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TitleCard: React.FC<TitleCardProps> = ({
|
||||||
|
title,
|
||||||
|
isEditing,
|
||||||
|
editText,
|
||||||
|
onEditChange,
|
||||||
|
onStartEdit,
|
||||||
|
onSaveEdit,
|
||||||
|
onCancelEdit,
|
||||||
|
onCopy,
|
||||||
|
onDelete,
|
||||||
|
onToggleFavorite,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className="xx-title-card">
|
||||||
|
{/* 收藏按钮 */}
|
||||||
|
<button
|
||||||
|
className="xx-title-fav-btn"
|
||||||
|
onClick={onToggleFavorite}
|
||||||
|
title={title.isFavorited ? "取消收藏" : "收藏"}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 12,
|
||||||
|
right: 12,
|
||||||
|
color: title.isFavorited ? "#f59e0b" : "var(--text-tertiary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{title.isFavorited ? <StarFilled /> : <StarOutlined />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 标题文本 / 编辑区 */}
|
||||||
|
{isEditing ? (
|
||||||
|
<textarea
|
||||||
|
className="xx-title-card-edit"
|
||||||
|
value={editText}
|
||||||
|
onChange={(e) => onEditChange(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
|
e.preventDefault()
|
||||||
|
onSaveEdit()
|
||||||
|
}
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
onCancelEdit()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="xx-title-card-text" style={{ paddingRight: 24 }}>
|
||||||
|
{title.content}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 底部元信息 */}
|
||||||
|
<div className="xx-title-card-meta">
|
||||||
|
<div className="xx-title-card-meta-left">
|
||||||
|
<span className={`xx-title-type-tag ${title.type}`}>{typeLabel(title.type)}</span>
|
||||||
|
<span className="xx-title-card-stat">使用 {title.usageCount} 次</span>
|
||||||
|
<span className="xx-title-card-stat">{title.createdAt}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="xx-title-card-actions">
|
||||||
|
{isEditing ? (
|
||||||
|
<>
|
||||||
|
<button className="xx-title-card-action-btn" onClick={onSaveEdit} title="保存">
|
||||||
|
<CheckOutlined />
|
||||||
|
</button>
|
||||||
|
<button className="xx-title-card-action-btn" onClick={onCancelEdit} title="取消">
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<button className="xx-title-card-action-btn" onClick={onCopy} title="复制">
|
||||||
|
<CopyOutlined />
|
||||||
|
</button>
|
||||||
|
<button className="xx-title-card-action-btn" onClick={onStartEdit} title="编辑">
|
||||||
|
<EditOutlined />
|
||||||
|
</button>
|
||||||
|
<Popconfirm
|
||||||
|
title="确定删除此标题?"
|
||||||
|
onConfirm={onDelete}
|
||||||
|
okText="删除"
|
||||||
|
cancelText="取消"
|
||||||
|
>
|
||||||
|
<button className="xx-title-card-action-btn danger" title="删除">
|
||||||
|
<DeleteOutlined />
|
||||||
|
</button>
|
||||||
|
</Popconfirm>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import React from "react"
|
||||||
|
import { FileTextOutlined } from "@ant-design/icons"
|
||||||
|
import { TitleCard } from "./TitleCard"
|
||||||
|
import type { TitleData } from "../../types/titleLibrary"
|
||||||
|
|
||||||
|
interface TitleGridProps {
|
||||||
|
titles: TitleData[]
|
||||||
|
editingId: string | null
|
||||||
|
editText: string
|
||||||
|
searchText: string
|
||||||
|
onEditChange: (text: string) => void
|
||||||
|
onStartEdit: (title: TitleData) => void
|
||||||
|
onSaveEdit: () => void
|
||||||
|
onCancelEdit: () => void
|
||||||
|
onCopy: (title: TitleData) => void
|
||||||
|
onDelete: (id: string) => void
|
||||||
|
onToggleFavorite: (id: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TitleGrid: React.FC<TitleGridProps> = ({
|
||||||
|
titles,
|
||||||
|
editingId,
|
||||||
|
editText,
|
||||||
|
searchText,
|
||||||
|
onEditChange,
|
||||||
|
onStartEdit,
|
||||||
|
onSaveEdit,
|
||||||
|
onCancelEdit,
|
||||||
|
onCopy,
|
||||||
|
onDelete,
|
||||||
|
onToggleFavorite,
|
||||||
|
}) => {
|
||||||
|
if (titles.length > 0) {
|
||||||
|
return (
|
||||||
|
<div className="xx-title-grid">
|
||||||
|
{titles.map((title) => (
|
||||||
|
<TitleCard
|
||||||
|
key={title.id}
|
||||||
|
title={title}
|
||||||
|
isEditing={editingId === title.id}
|
||||||
|
editText={editingId === title.id ? editText : ""}
|
||||||
|
onEditChange={onEditChange}
|
||||||
|
onStartEdit={() => onStartEdit(title)}
|
||||||
|
onSaveEdit={onSaveEdit}
|
||||||
|
onCancelEdit={onCancelEdit}
|
||||||
|
onCopy={() => onCopy(title)}
|
||||||
|
onDelete={() => onDelete(title.id)}
|
||||||
|
onToggleFavorite={() => onToggleFavorite(title.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="xx-titles-empty">
|
||||||
|
<div className="xx-titles-empty-icon">
|
||||||
|
<FileTextOutlined />
|
||||||
|
</div>
|
||||||
|
<p>{searchText ? "未找到匹配的标题" : "暂无标题,点击「新建标题」或「AI 生成标题」开始"}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import type { TitleType, Industry, Frequency } from "../types/titleLibrary"
|
||||||
|
|
||||||
|
export const TITLE_TYPE_OPTIONS: Array<{ value: TitleType | "all"; label: string }> = [
|
||||||
|
{ value: "all", label: "全部类型" },
|
||||||
|
{ value: "hot", label: "爆款" },
|
||||||
|
{ value: "normal", label: "常规" },
|
||||||
|
{ value: "creative", label: "创意" },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const INDUSTRY_OPTIONS: Array<{ value: Industry | "all"; label: string }> = [
|
||||||
|
{ value: "all", label: "全部行业" },
|
||||||
|
{ value: "food", label: "美食" },
|
||||||
|
{ value: "tech", label: "科技" },
|
||||||
|
{ value: "beauty", label: "美妆" },
|
||||||
|
{ value: "education", label: "教育" },
|
||||||
|
{ value: "travel", label: "旅行" },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const FREQUENCY_OPTIONS: Array<{ value: Frequency; label: string }> = [
|
||||||
|
{ value: "all", label: "全部频率" },
|
||||||
|
{ value: "high", label: "高频使用" },
|
||||||
|
{ value: "medium", label: "中频使用" },
|
||||||
|
{ value: "low", label: "低频使用" },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const FREQUENCY_THRESHOLDS = {
|
||||||
|
high: 100,
|
||||||
|
medium: 30,
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export const AI_GENERATE_DELAY = 2000
|
||||||
|
export const TITLE_MAX_LENGTH = 200
|
||||||
|
export const AI_KEYWORD_MAX_LENGTH = 100
|
||||||
|
export const ALL_CATEGORY_ID = "cat-all"
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { useState, useCallback } from "react"
|
||||||
|
import { message } from "antd"
|
||||||
|
import type { UseMutationResult } from "@tanstack/react-query"
|
||||||
|
import type { TitleItem } from "@/api/titles"
|
||||||
|
import { copyToClipboard } from "../utils/titleLibrary"
|
||||||
|
import { AI_GENERATE_DELAY } from "../constants/titleLibrary"
|
||||||
|
|
||||||
|
interface UseTitleAIProps {
|
||||||
|
createMutation: UseMutationResult<TitleItem, Error, string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
const generateMockTitles = (keyword: string): string[] => [
|
||||||
|
`${keyword}:这个方法让我事半功倍!`,
|
||||||
|
`关于${keyword},99%的人都不知道的事`,
|
||||||
|
`${keyword}全攻略,看完这篇就够了`,
|
||||||
|
`我花了 3 个月研究${keyword},总结出这些经验`,
|
||||||
|
`${keyword}避坑指南,帮你省下 1000 块`,
|
||||||
|
]
|
||||||
|
|
||||||
|
export const useTitleAI = ({ createMutation }: UseTitleAIProps) => {
|
||||||
|
const [aiModalOpen, setAiModalOpen] = useState(false)
|
||||||
|
const [aiKeyword, setAiKeyword] = useState("")
|
||||||
|
const [aiLoading, setAiLoading] = useState(false)
|
||||||
|
const [aiResults, setAiResults] = useState<string[]>([])
|
||||||
|
|
||||||
|
/* AI 生成标题 */
|
||||||
|
const handleAIGenerate = useCallback(() => {
|
||||||
|
if (!aiKeyword.trim()) {
|
||||||
|
message.warning("请输入关键词或主题")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setAiLoading(true)
|
||||||
|
setAiResults([])
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
const results = generateMockTitles(aiKeyword.trim())
|
||||||
|
setAiResults(results)
|
||||||
|
setAiLoading(false)
|
||||||
|
}, AI_GENERATE_DELAY)
|
||||||
|
}, [aiKeyword])
|
||||||
|
|
||||||
|
/* 采纳 AI 生成的标题 */
|
||||||
|
const handleAdoptAITitle = useCallback(
|
||||||
|
(text: string) => {
|
||||||
|
createMutation.mutate(text, {
|
||||||
|
onSuccess: () => {
|
||||||
|
message.success("标题已采纳并添加到标题库")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[createMutation],
|
||||||
|
)
|
||||||
|
|
||||||
|
/* 复制 AI 生成的标题 */
|
||||||
|
const handleCopyAI = useCallback(async (text: string) => {
|
||||||
|
const ok = await copyToClipboard(text)
|
||||||
|
if (ok) {
|
||||||
|
message.success("已复制到剪贴板")
|
||||||
|
} else {
|
||||||
|
message.error("复制失败")
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
/* 关闭 AI 弹窗 */
|
||||||
|
const handleCloseAIModal = useCallback(() => {
|
||||||
|
setAiModalOpen(false)
|
||||||
|
setAiLoading(false)
|
||||||
|
setAiResults([])
|
||||||
|
setAiKeyword("")
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return {
|
||||||
|
aiModalOpen,
|
||||||
|
setAiModalOpen,
|
||||||
|
aiKeyword,
|
||||||
|
setAiKeyword,
|
||||||
|
aiLoading,
|
||||||
|
aiResults,
|
||||||
|
handleAIGenerate,
|
||||||
|
handleAdoptAITitle,
|
||||||
|
handleCopyAI,
|
||||||
|
handleCloseAIModal,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { useState, useCallback } from "react"
|
||||||
|
import { message } from "antd"
|
||||||
|
import type { TitleData, TitleType } from "../types/titleLibrary"
|
||||||
|
import type { UseMutationResult } from "@tanstack/react-query"
|
||||||
|
import type { TitleItem } from "@/api/titles"
|
||||||
|
|
||||||
|
interface UseTitleEditProps {
|
||||||
|
updateMutation: UseMutationResult<TitleItem, Error, { id: string; content: string }, unknown>
|
||||||
|
createMutation: UseMutationResult<TitleItem, Error, string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useTitleEdit = ({ updateMutation, createMutation }: UseTitleEditProps) => {
|
||||||
|
/* 编辑状态 */
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null)
|
||||||
|
const [editText, setEditText] = useState("")
|
||||||
|
|
||||||
|
/* 新建标题弹窗 */
|
||||||
|
const [createTitleModalOpen, setCreateTitleModalOpen] = useState(false)
|
||||||
|
const [newTitleContent, setNewTitleContent] = useState("")
|
||||||
|
const [newTitleType, setNewTitleType] = useState<TitleType>("normal")
|
||||||
|
|
||||||
|
/* 开始编辑 */
|
||||||
|
const handleStartEdit = useCallback((title: TitleData) => {
|
||||||
|
setEditingId(title.id)
|
||||||
|
setEditText(title.content)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
/* 保存编辑 */
|
||||||
|
const handleSaveEdit = useCallback(() => {
|
||||||
|
if (!editText.trim()) {
|
||||||
|
message.warning("标题内容不能为空")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (editingId) {
|
||||||
|
updateMutation.mutate({ id: editingId, content: editText.trim() })
|
||||||
|
}
|
||||||
|
setEditingId(null)
|
||||||
|
setEditText("")
|
||||||
|
message.success("标题已更新")
|
||||||
|
}, [editingId, editText, updateMutation])
|
||||||
|
|
||||||
|
/* 取消编辑 */
|
||||||
|
const handleCancelEdit = useCallback(() => {
|
||||||
|
setEditingId(null)
|
||||||
|
setEditText("")
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
/* 新建标题提交 */
|
||||||
|
const handleCreateTitle = useCallback(() => {
|
||||||
|
if (!newTitleContent.trim()) {
|
||||||
|
message.warning("请输入标题内容")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
createMutation.mutate(newTitleContent.trim(), {
|
||||||
|
onSuccess: () => {
|
||||||
|
setCreateTitleModalOpen(false)
|
||||||
|
setNewTitleContent("")
|
||||||
|
setNewTitleType("normal")
|
||||||
|
message.success("标题创建成功")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}, [newTitleContent, createMutation])
|
||||||
|
|
||||||
|
/* 关闭新建弹窗 */
|
||||||
|
const handleCloseCreateModal = useCallback(() => {
|
||||||
|
setCreateTitleModalOpen(false)
|
||||||
|
setNewTitleContent("")
|
||||||
|
setNewTitleType("normal")
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return {
|
||||||
|
editingId,
|
||||||
|
editText,
|
||||||
|
setEditText,
|
||||||
|
createTitleModalOpen,
|
||||||
|
setCreateTitleModalOpen,
|
||||||
|
newTitleContent,
|
||||||
|
setNewTitleContent,
|
||||||
|
newTitleType,
|
||||||
|
setNewTitleType,
|
||||||
|
handleStartEdit,
|
||||||
|
handleSaveEdit,
|
||||||
|
handleCancelEdit,
|
||||||
|
handleCreateTitle,
|
||||||
|
handleCloseCreateModal,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import { useMemo, useState, useCallback } from "react"
|
||||||
|
import { message } from "antd"
|
||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
||||||
|
import { getTitles, createTitle, updateTitle, deleteTitle } from "@/api/titles"
|
||||||
|
import type { TitleData, CategoryItem, Frequency } from "../types/titleLibrary"
|
||||||
|
import { toTitleData, copyToClipboard } from "../utils/titleLibrary"
|
||||||
|
import { ALL_CATEGORY_ID, FREQUENCY_THRESHOLDS } from "../constants/titleLibrary"
|
||||||
|
|
||||||
|
export const useTitleLibrary = () => {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
/* 分类 */
|
||||||
|
const [activeCatId, setActiveCatId] = useState<string>(ALL_CATEGORY_ID)
|
||||||
|
|
||||||
|
/* 数据获取 */
|
||||||
|
const { data: apiTitles = [] } = useQuery({
|
||||||
|
queryKey: ["titles"],
|
||||||
|
queryFn: getTitles,
|
||||||
|
staleTime: 30_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
const titles: TitleData[] = useMemo(() => apiTitles.map(toTitleData), [apiTitles])
|
||||||
|
|
||||||
|
/* 动态派生分类 */
|
||||||
|
const categories: CategoryItem[] = useMemo(() => {
|
||||||
|
const cats = new Map<string, number>()
|
||||||
|
apiTitles.forEach((t) => {
|
||||||
|
const cat = t.category || "未分类"
|
||||||
|
cats.set(cat, (cats.get(cat) || 0) + 1)
|
||||||
|
})
|
||||||
|
return [
|
||||||
|
{ id: ALL_CATEGORY_ID, name: "全部标题", count: apiTitles.length },
|
||||||
|
...Array.from(cats.entries()).map(([name, count]) => ({
|
||||||
|
id: `cat-${name}`,
|
||||||
|
name,
|
||||||
|
count,
|
||||||
|
})),
|
||||||
|
]
|
||||||
|
}, [apiTitles])
|
||||||
|
|
||||||
|
/* CRUD mutations */
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: (content: string) => createTitle({ content }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["titles"] })
|
||||||
|
},
|
||||||
|
onError: () => message.error("创建标题失败"),
|
||||||
|
})
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: ({ id, content }: { id: string; content: string }) => updateTitle(id, { content }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["titles"] })
|
||||||
|
},
|
||||||
|
onError: () => message.error("更新标题失败"),
|
||||||
|
})
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (id: string) => deleteTitle(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["titles"] })
|
||||||
|
},
|
||||||
|
onError: () => message.error("删除标题失败"),
|
||||||
|
})
|
||||||
|
|
||||||
|
/* 筛选状态 */
|
||||||
|
const [searchText, setSearchText] = useState("")
|
||||||
|
const [filterType, setFilterType] = useState<string>("all")
|
||||||
|
const [filterIndustry, setFilterIndustry] = useState<string>("all")
|
||||||
|
const [filterFrequency, setFilterFrequency] = useState<Frequency>("all")
|
||||||
|
|
||||||
|
/* 派生:筛选后的标题列表 */
|
||||||
|
const activeCategory = categories.find((c) => c.id === activeCatId)
|
||||||
|
|
||||||
|
const filteredTitles = useMemo(() => {
|
||||||
|
let list = titles
|
||||||
|
|
||||||
|
/* 按分类过滤 */
|
||||||
|
if (activeCatId !== ALL_CATEGORY_ID) {
|
||||||
|
const catName = activeCategory?.name || ""
|
||||||
|
if (catName) {
|
||||||
|
list = list.filter((t) => t.category === catName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 按类型筛选 */
|
||||||
|
if (filterType !== "all") {
|
||||||
|
list = list.filter((t) => t.type === filterType)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 按行业筛选 */
|
||||||
|
if (filterIndustry !== "all") {
|
||||||
|
list = list.filter((t) => t.industry === filterIndustry)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 按使用频率筛选 */
|
||||||
|
if (filterFrequency !== "all") {
|
||||||
|
switch (filterFrequency) {
|
||||||
|
case "high":
|
||||||
|
list = list.filter((t) => t.usageCount >= FREQUENCY_THRESHOLDS.high)
|
||||||
|
break
|
||||||
|
case "medium":
|
||||||
|
list = list.filter(
|
||||||
|
(t) =>
|
||||||
|
t.usageCount >= FREQUENCY_THRESHOLDS.medium &&
|
||||||
|
t.usageCount < FREQUENCY_THRESHOLDS.high,
|
||||||
|
)
|
||||||
|
break
|
||||||
|
case "low":
|
||||||
|
list = list.filter((t) => t.usageCount < FREQUENCY_THRESHOLDS.medium)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 搜索 */
|
||||||
|
if (searchText.trim()) {
|
||||||
|
const q = searchText.trim().toLowerCase()
|
||||||
|
list = list.filter((t) => t.content.toLowerCase().includes(q))
|
||||||
|
}
|
||||||
|
|
||||||
|
return list
|
||||||
|
}, [titles, activeCatId, activeCategory, filterType, filterIndustry, filterFrequency, searchText])
|
||||||
|
|
||||||
|
/* 操作:收藏 */
|
||||||
|
const handleToggleFavorite = useCallback((_id: string) => {
|
||||||
|
message.info("收藏功能即将上线")
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
/* 操作:复制 */
|
||||||
|
const handleCopy = useCallback(async (title: TitleData) => {
|
||||||
|
const ok = await copyToClipboard(title.content)
|
||||||
|
if (ok) {
|
||||||
|
message.success("已复制到剪贴板")
|
||||||
|
} else {
|
||||||
|
message.error("复制失败")
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
/* 操作:删除 */
|
||||||
|
const handleDelete = useCallback(
|
||||||
|
(id: string) => {
|
||||||
|
deleteMutation.mutate(id)
|
||||||
|
message.success("标题已删除")
|
||||||
|
},
|
||||||
|
[deleteMutation],
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
/* 状态 */
|
||||||
|
titles,
|
||||||
|
categories,
|
||||||
|
activeCatId,
|
||||||
|
activeCategory,
|
||||||
|
filteredTitles,
|
||||||
|
searchText,
|
||||||
|
filterType,
|
||||||
|
filterIndustry,
|
||||||
|
filterFrequency,
|
||||||
|
/* mutations */
|
||||||
|
createMutation,
|
||||||
|
updateMutation,
|
||||||
|
deleteMutation,
|
||||||
|
/* setters */
|
||||||
|
setActiveCatId,
|
||||||
|
setSearchText,
|
||||||
|
setFilterType,
|
||||||
|
setFilterIndustry,
|
||||||
|
setFilterFrequency,
|
||||||
|
/* handlers */
|
||||||
|
handleToggleFavorite,
|
||||||
|
handleCopy,
|
||||||
|
handleDelete,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
export type TitleType = "hot" | "normal" | "creative"
|
||||||
|
export type Industry = "general" | "food" | "tech" | "beauty" | "education" | "travel"
|
||||||
|
export type Frequency = "all" | "high" | "medium" | "low"
|
||||||
|
|
||||||
|
export interface TitleData {
|
||||||
|
id: string
|
||||||
|
content: string
|
||||||
|
type: TitleType
|
||||||
|
industry: Industry
|
||||||
|
category: string
|
||||||
|
usageCount: number
|
||||||
|
isFavorited: boolean
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CategoryItem {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
count: number
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import type { TitleData, TitleType } from "../types/titleLibrary"
|
||||||
|
import type { TitleItem } from "@/api/titles"
|
||||||
|
|
||||||
|
export const typeLabel = (type: TitleType): string => {
|
||||||
|
switch (type) {
|
||||||
|
case "hot":
|
||||||
|
return "爆款"
|
||||||
|
case "normal":
|
||||||
|
return "常规"
|
||||||
|
case "creative":
|
||||||
|
return "创意"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 后端 TitleItem → 前端 TitleData 映射 */
|
||||||
|
export const toTitleData = (item: TitleItem): TitleData => ({
|
||||||
|
id: item.id,
|
||||||
|
content: item.content,
|
||||||
|
type: (item.category as TitleType) || "normal",
|
||||||
|
industry: "general",
|
||||||
|
category: item.category || "未分类",
|
||||||
|
usageCount: 0,
|
||||||
|
isFavorited: false,
|
||||||
|
createdAt: item.created_at?.slice(0, 10) || "",
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 复制文本到剪贴板 */
|
||||||
|
export const copyToClipboard = async (text: string): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
/* 降级方案 */
|
||||||
|
const textarea = document.createElement("textarea")
|
||||||
|
textarea.value = text
|
||||||
|
textarea.style.position = "fixed"
|
||||||
|
textarea.style.opacity = "0"
|
||||||
|
document.body.appendChild(textarea)
|
||||||
|
textarea.select()
|
||||||
|
try {
|
||||||
|
document.execCommand("copy")
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
} finally {
|
||||||
|
document.body.removeChild(textarea)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,21 +9,8 @@
|
|||||||
* - 删除素材
|
* - 删除素材
|
||||||
*/
|
*/
|
||||||
import React from "react"
|
import React from "react"
|
||||||
import {
|
import { PlusOutlined, RobotOutlined } from "@ant-design/icons"
|
||||||
AudioOutlined,
|
import { Button, Modal } from "@/components/ui"
|
||||||
SearchOutlined,
|
|
||||||
PlusOutlined,
|
|
||||||
DeleteOutlined,
|
|
||||||
UploadOutlined,
|
|
||||||
UnorderedListOutlined,
|
|
||||||
AppstoreOutlined,
|
|
||||||
CheckOutlined,
|
|
||||||
TagsOutlined,
|
|
||||||
RobotOutlined,
|
|
||||||
LoadingOutlined,
|
|
||||||
} from "@ant-design/icons"
|
|
||||||
import { Button, Input, Select, Modal } from "@/components/ui"
|
|
||||||
import { Popover, Popconfirm } from "antd"
|
|
||||||
import PageHead from "@/components/layout/PageHead"
|
import PageHead from "@/components/layout/PageHead"
|
||||||
import { useVoiceMaterials } from "./hooks/useVoiceMaterials"
|
import { useVoiceMaterials } from "./hooks/useVoiceMaterials"
|
||||||
import { useAudioPlayer } from "./hooks/useAudioPlayer"
|
import { useAudioPlayer } from "./hooks/useAudioPlayer"
|
||||||
@@ -32,6 +19,11 @@ import { useTtsSynthesize } from "./hooks/useTtsSynthesize"
|
|||||||
import MaterialForm from "./components/MaterialForm"
|
import MaterialForm from "./components/MaterialForm"
|
||||||
import VoiceMaterialCard from "./components/VoiceMaterialCard"
|
import VoiceMaterialCard from "./components/VoiceMaterialCard"
|
||||||
import VoiceMaterialRow from "./components/VoiceMaterialRow"
|
import VoiceMaterialRow from "./components/VoiceMaterialRow"
|
||||||
|
import Toolbar from "./components/Toolbar"
|
||||||
|
import TagFilterBar from "./components/TagFilterBar"
|
||||||
|
import BatchBar from "./components/BatchBar"
|
||||||
|
import EmptyState from "./components/EmptyState"
|
||||||
|
import TtsModal from "./components/TtsModal"
|
||||||
import "./voice-materials.css"
|
import "./voice-materials.css"
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
@@ -137,7 +129,7 @@ const VoiceMaterialLibrary: React.FC = () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── 渲染 ─────────────────────────────────────────────── */
|
const hasFilter = !!searchText || filterGender !== "all" || filterTagId !== "all"
|
||||||
|
|
||||||
const pageActions = (
|
const pageActions = (
|
||||||
<div className="vmat-page-actions">
|
<div className="vmat-page-actions">
|
||||||
@@ -164,155 +156,47 @@ const VoiceMaterialLibrary: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 工具栏:搜索 + 筛选 + 视图切换 */}
|
{/* 工具栏:搜索 + 筛选 + 视图切换 */}
|
||||||
<div className="vmat-toolbar">
|
<Toolbar
|
||||||
<div className="vmat-toolbar-left">
|
searchText={searchText}
|
||||||
<Input
|
filterGender={filterGender}
|
||||||
placeholder="搜索配音素材..."
|
viewMode={viewMode}
|
||||||
prefix={<SearchOutlined />}
|
resultCount={filtered.length}
|
||||||
value={searchText}
|
onSearchChange={setSearchText}
|
||||||
onChange={(e) => setSearchText(e.target.value)}
|
onGenderChange={setFilterGender}
|
||||||
allowClear
|
onViewModeChange={setViewMode}
|
||||||
style={{ width: 240 }}
|
/>
|
||||||
/>
|
|
||||||
<Select
|
|
||||||
value={filterGender}
|
|
||||||
onChange={setFilterGender}
|
|
||||||
style={{ width: 120 }}
|
|
||||||
options={[
|
|
||||||
{ value: "all", label: "全部性别" },
|
|
||||||
{ value: "male", label: "男声" },
|
|
||||||
{ value: "female", label: "女声" },
|
|
||||||
{ value: "child", label: "童声" },
|
|
||||||
{ value: "neutral", label: "中性" },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="vmat-toolbar-right">
|
|
||||||
<span className="vmat-result-count">共 {filtered.length} 个素材</span>
|
|
||||||
<div className="vmat-view-toggle">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`vmat-view-btn${viewMode === "card" ? " active" : ""}`}
|
|
||||||
onClick={() => setViewMode("card")}
|
|
||||||
title="卡片视图"
|
|
||||||
>
|
|
||||||
<AppstoreOutlined />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`vmat-view-btn${viewMode === "list" ? " active" : ""}`}
|
|
||||||
onClick={() => setViewMode("list")}
|
|
||||||
title="列表视图"
|
|
||||||
>
|
|
||||||
<UnorderedListOutlined />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── 标签筛选药丸条 ─────────────────────────────────── */}
|
{/* 标签筛选药丸条 */}
|
||||||
{tags.length > 0 && (
|
<TagFilterBar
|
||||||
<div className="vmat-tag-filter-bar">
|
tags={tags}
|
||||||
<button
|
filterTagId={filterTagId}
|
||||||
type="button"
|
tagCountMap={tagCountMap}
|
||||||
className={`vmat-filter-pill${filterTagId === "all" ? " active" : ""}`}
|
onTagSelect={setFilterTagId}
|
||||||
onClick={() => setFilterTagId("all")}
|
/>
|
||||||
tabIndex={0}
|
|
||||||
>
|
|
||||||
全部
|
|
||||||
</button>
|
|
||||||
{tags.map((tag) => (
|
|
||||||
<button
|
|
||||||
key={tag.id}
|
|
||||||
type="button"
|
|
||||||
className={`vmat-filter-pill${filterTagId === tag.id ? " active" : ""}`}
|
|
||||||
onClick={() => setFilterTagId(tag.id)}
|
|
||||||
tabIndex={0}
|
|
||||||
>
|
|
||||||
{tag.name}
|
|
||||||
<span className="vmat-filter-pill-count">{tagCountMap[tag.id] || 0}</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 加载中 */}
|
|
||||||
{isLoading && (
|
|
||||||
<div className="vmat-empty">
|
|
||||||
<div className="vmat-empty-icon">
|
|
||||||
<AudioOutlined />
|
|
||||||
</div>
|
|
||||||
<h3>加载中...</h3>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 批量操作栏 */}
|
{/* 批量操作栏 */}
|
||||||
{batchMode && (
|
{batchMode && (
|
||||||
<div className="vmat-batch-bar">
|
<BatchBar
|
||||||
<div className="vmat-batch-bar-left">
|
selectedCount={selectedIds.size}
|
||||||
<div
|
allSelected={allSelected}
|
||||||
className={`vmat-checkbox${allSelected ? " checked" : ""}`}
|
batchCustomTag={batchCustomTag}
|
||||||
onClick={handleSelectAll}
|
tags={tags}
|
||||||
>
|
onSelectAll={handleSelectAll}
|
||||||
{allSelected && <CheckOutlined />}
|
onBatchCustomTagChange={setBatchCustomTag}
|
||||||
</div>
|
onBatchCustomTagSubmit={handleBatchCustomTag}
|
||||||
<span className="vmat-select-all" onClick={handleSelectAll}>
|
onBatchTag={handleBatchTag}
|
||||||
{allSelected ? "取消全选" : "全选"}
|
onBatchDelete={handleBatchDelete}
|
||||||
</span>
|
/>
|
||||||
<span className="vmat-batch-count">已选择 {selectedIds.size} 项</span>
|
|
||||||
</div>
|
|
||||||
<div className="vmat-batch-bar-right">
|
|
||||||
<Popover
|
|
||||||
content={
|
|
||||||
<div className="vmat-tag-popover">
|
|
||||||
<div className="vmat-tag-pop-input-row">
|
|
||||||
<Input
|
|
||||||
placeholder="输入自定义标签后回车"
|
|
||||||
value={batchCustomTag}
|
|
||||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
|
||||||
setBatchCustomTag(e.target.value)
|
|
||||||
}
|
|
||||||
onKeyDown={(e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
||||||
if (e.key === "Enter" && batchCustomTag.trim()) {
|
|
||||||
handleBatchCustomTag(batchCustomTag.trim())
|
|
||||||
setBatchCustomTag("")
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{tags.map((tag) => (
|
|
||||||
<button
|
|
||||||
key={tag.id}
|
|
||||||
type="button"
|
|
||||||
className="vmat-tag-pop-btn"
|
|
||||||
onClick={() => handleBatchTag(tag.id)}
|
|
||||||
>
|
|
||||||
{tag.name}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
title="批量打标签"
|
|
||||||
trigger="click"
|
|
||||||
>
|
|
||||||
<Button buttonType="ghost" buttonSize="sm" icon={<TagsOutlined />}>
|
|
||||||
批量打标签
|
|
||||||
</Button>
|
|
||||||
</Popover>
|
|
||||||
<Popconfirm
|
|
||||||
title={`确定删除选中的 ${selectedIds.size} 个素材?`}
|
|
||||||
onConfirm={handleBatchDelete}
|
|
||||||
okText="删除"
|
|
||||||
cancelText="取消"
|
|
||||||
>
|
|
||||||
<Button buttonType="danger" buttonSize="sm" icon={<DeleteOutlined />}>
|
|
||||||
批量删除
|
|
||||||
</Button>
|
|
||||||
</Popconfirm>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 加载/空状态 */}
|
||||||
|
<EmptyState
|
||||||
|
isLoading={isLoading}
|
||||||
|
isEmpty={filtered.length === 0}
|
||||||
|
hasFilter={hasFilter}
|
||||||
|
onUploadClick={() => setUploadOpen(true)}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* 内容区 — 卡片视图 */}
|
{/* 内容区 — 卡片视图 */}
|
||||||
{!isLoading && filtered.length > 0 && viewMode === "card" && (
|
{!isLoading && filtered.length > 0 && viewMode === "card" && (
|
||||||
<div className="vmat-grid">
|
<div className="vmat-grid">
|
||||||
@@ -374,31 +258,6 @@ const VoiceMaterialLibrary: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 空状态 */}
|
|
||||||
{!isLoading && filtered.length === 0 && (
|
|
||||||
<div className="vmat-empty">
|
|
||||||
<div className="vmat-empty-icon">
|
|
||||||
<AudioOutlined />
|
|
||||||
</div>
|
|
||||||
<h3>暂无配音素材</h3>
|
|
||||||
<p>
|
|
||||||
{searchText || filterGender !== "all" || filterTagId !== "all"
|
|
||||||
? "未找到匹配的素材,试试调整筛选条件"
|
|
||||||
: "上传音频文件,开始管理配音素材"}
|
|
||||||
</p>
|
|
||||||
{!searchText && filterGender === "all" && filterTagId === "all" && (
|
|
||||||
<Button
|
|
||||||
buttonType="primary"
|
|
||||||
buttonSize="md"
|
|
||||||
icon={<UploadOutlined />}
|
|
||||||
onClick={() => setUploadOpen(true)}
|
|
||||||
>
|
|
||||||
上传配音
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 上传弹窗 */}
|
{/* 上传弹窗 */}
|
||||||
<Modal
|
<Modal
|
||||||
title="上传配音素材"
|
title="上传配音素材"
|
||||||
@@ -450,151 +309,22 @@ const VoiceMaterialLibrary: React.FC = () => {
|
|||||||
width={560}
|
width={560}
|
||||||
destroyOnClose
|
destroyOnClose
|
||||||
>
|
>
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
<TtsModal
|
||||||
{/* 文本输入 */}
|
open={ttsOpen}
|
||||||
<div>
|
text={ttsText}
|
||||||
<label
|
voiceId={ttsVoiceId}
|
||||||
style={{
|
speed={ttsSpeed}
|
||||||
fontSize: 13,
|
status={ttsStatus}
|
||||||
fontWeight: 500,
|
audioUrl={ttsAudioUrl ?? ""}
|
||||||
marginBottom: 6,
|
error={ttsError ?? ""}
|
||||||
display: "block",
|
presetVoices={presetVoices}
|
||||||
}}
|
onClose={handleTtsClose}
|
||||||
>
|
onTextChange={setTtsText}
|
||||||
输入文本
|
onVoiceChange={setTtsVoiceId}
|
||||||
</label>
|
onSpeedChange={setTtsSpeed}
|
||||||
<textarea
|
onSynthesize={handleTtsSynthesize}
|
||||||
rows={4}
|
onSave={handleTtsSave}
|
||||||
placeholder="请输入需要转换为语音的文本内容…"
|
/>
|
||||||
value={ttsText}
|
|
||||||
onChange={(e) => setTtsText(e.target.value)}
|
|
||||||
maxLength={2000}
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
padding: "8px 12px",
|
|
||||||
border: "1px solid var(--border-color, #d9d9d9)",
|
|
||||||
borderRadius: 6,
|
|
||||||
fontSize: 13,
|
|
||||||
resize: "vertical",
|
|
||||||
fontFamily: "inherit",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: 11,
|
|
||||||
color: "var(--text-tertiary, #999)",
|
|
||||||
marginTop: 4,
|
|
||||||
textAlign: "right",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{ttsText.length}/2000
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 音色选择 */}
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
style={{
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: 500,
|
|
||||||
marginBottom: 6,
|
|
||||||
display: "block",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
选择音色
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
value={ttsVoiceId}
|
|
||||||
onChange={(e) => setTtsVoiceId(e.target.value)}
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
height: 36,
|
|
||||||
padding: "0 10px",
|
|
||||||
border: "1px solid var(--border-color, #d9d9d9)",
|
|
||||||
borderRadius: 6,
|
|
||||||
fontSize: 13,
|
|
||||||
background: "var(--bg-primary, #fff)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<option value="">默认音色</option>
|
|
||||||
{presetVoices.map((v) => (
|
|
||||||
<option key={v.voice_id} value={v.voice_id}>
|
|
||||||
{v.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 语速调节 */}
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
style={{
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: 500,
|
|
||||||
marginBottom: 6,
|
|
||||||
display: "block",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
语速:{ttsSpeed.toFixed(1)}x
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min={0.5}
|
|
||||||
max={2.0}
|
|
||||||
step={0.1}
|
|
||||||
value={ttsSpeed}
|
|
||||||
onChange={(e) => setTtsSpeed(parseFloat(e.target.value))}
|
|
||||||
style={{ width: "100%" }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 合成按钮 */}
|
|
||||||
<Button
|
|
||||||
buttonType="primary"
|
|
||||||
buttonSize="md"
|
|
||||||
icon={ttsStatus === "synthesizing" ? <LoadingOutlined /> : <RobotOutlined />}
|
|
||||||
onClick={handleTtsSynthesize}
|
|
||||||
disabled={ttsStatus === "synthesizing" || !ttsText.trim()}
|
|
||||||
>
|
|
||||||
{ttsStatus === "synthesizing" ? "合成中…" : "开始合成"}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{/* 错误提示 */}
|
|
||||||
{ttsStatus === "error" && ttsError && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
padding: "8px 12px",
|
|
||||||
background: "#fff2f0",
|
|
||||||
borderRadius: 6,
|
|
||||||
color: "#ff4d4f",
|
|
||||||
fontSize: 13,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{ttsError}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 合成结果 */}
|
|
||||||
{ttsStatus === "done" && ttsAudioUrl && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
padding: 12,
|
|
||||||
background: "var(--bg-surface, #f5f5f5)",
|
|
||||||
borderRadius: 8,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<audio controls src={ttsAudioUrl} style={{ width: "100%", marginBottom: 12 }} />
|
|
||||||
<Button
|
|
||||||
buttonType="primary"
|
|
||||||
buttonSize="sm"
|
|
||||||
icon={<PlusOutlined />}
|
|
||||||
onClick={handleTtsSave}
|
|
||||||
>
|
|
||||||
保存到配音库
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Modal>
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import React from "react"
|
||||||
|
import { CheckOutlined, TagsOutlined, DeleteOutlined } from "@ant-design/icons"
|
||||||
|
import { Button, Input } from "@/components/ui"
|
||||||
|
import { Popover, Popconfirm } from "antd"
|
||||||
|
import type { TagItem } from "@/api/tags"
|
||||||
|
|
||||||
|
interface BatchBarProps {
|
||||||
|
selectedCount: number
|
||||||
|
allSelected: boolean
|
||||||
|
batchCustomTag: string
|
||||||
|
tags: TagItem[]
|
||||||
|
onSelectAll: () => void
|
||||||
|
onBatchCustomTagChange: (value: string) => void
|
||||||
|
onBatchCustomTagSubmit: (tag: string) => void
|
||||||
|
onBatchTag: (tagId: string) => void
|
||||||
|
onBatchDelete: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const BatchBar: React.FC<BatchBarProps> = ({
|
||||||
|
selectedCount,
|
||||||
|
allSelected,
|
||||||
|
batchCustomTag,
|
||||||
|
tags,
|
||||||
|
onSelectAll,
|
||||||
|
onBatchCustomTagChange,
|
||||||
|
onBatchCustomTagSubmit,
|
||||||
|
onBatchTag,
|
||||||
|
onBatchDelete,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className="vmat-batch-bar">
|
||||||
|
<div className="vmat-batch-bar-left">
|
||||||
|
<div className={`vmat-checkbox${allSelected ? " checked" : ""}`} onClick={onSelectAll}>
|
||||||
|
{allSelected && <CheckOutlined />}
|
||||||
|
</div>
|
||||||
|
<span className="vmat-select-all" onClick={onSelectAll}>
|
||||||
|
{allSelected ? "取消全选" : "全选"}
|
||||||
|
</span>
|
||||||
|
<span className="vmat-batch-count">已选择 {selectedCount} 项</span>
|
||||||
|
</div>
|
||||||
|
<div className="vmat-batch-bar-right">
|
||||||
|
<Popover
|
||||||
|
content={
|
||||||
|
<div className="vmat-tag-popover">
|
||||||
|
<div className="vmat-tag-pop-input-row">
|
||||||
|
<Input
|
||||||
|
placeholder="输入自定义标签后回车"
|
||||||
|
value={batchCustomTag}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||||
|
onBatchCustomTagChange(e.target.value)
|
||||||
|
}
|
||||||
|
onKeyDown={(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (e.key === "Enter" && batchCustomTag.trim()) {
|
||||||
|
onBatchCustomTagSubmit(batchCustomTag.trim())
|
||||||
|
onBatchCustomTagChange("")
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{tags.map((tag) => (
|
||||||
|
<button
|
||||||
|
key={tag.id}
|
||||||
|
type="button"
|
||||||
|
className="vmat-tag-pop-btn"
|
||||||
|
onClick={() => onBatchTag(tag.id)}
|
||||||
|
>
|
||||||
|
{tag.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
title="批量打标签"
|
||||||
|
trigger="click"
|
||||||
|
>
|
||||||
|
<Button buttonType="ghost" buttonSize="sm" icon={<TagsOutlined />}>
|
||||||
|
批量打标签
|
||||||
|
</Button>
|
||||||
|
</Popover>
|
||||||
|
<Popconfirm
|
||||||
|
title={`确定删除选中的 ${selectedCount} 个素材?`}
|
||||||
|
onConfirm={onBatchDelete}
|
||||||
|
okText="删除"
|
||||||
|
cancelText="取消"
|
||||||
|
>
|
||||||
|
<Button buttonType="danger" buttonSize="sm" icon={<DeleteOutlined />}>
|
||||||
|
批量删除
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default BatchBar
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import React from "react"
|
||||||
|
import { AudioOutlined, UploadOutlined } from "@ant-design/icons"
|
||||||
|
import { Button } from "@/components/ui"
|
||||||
|
|
||||||
|
interface EmptyStateProps {
|
||||||
|
isLoading: boolean
|
||||||
|
isEmpty: boolean
|
||||||
|
hasFilter: boolean
|
||||||
|
onUploadClick: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const EmptyState: React.FC<EmptyStateProps> = ({
|
||||||
|
isLoading,
|
||||||
|
isEmpty,
|
||||||
|
hasFilter,
|
||||||
|
onUploadClick,
|
||||||
|
}) => {
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="vmat-empty">
|
||||||
|
<div className="vmat-empty-icon">
|
||||||
|
<AudioOutlined />
|
||||||
|
</div>
|
||||||
|
<h3>加载中...</h3>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isEmpty) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="vmat-empty">
|
||||||
|
<div className="vmat-empty-icon">
|
||||||
|
<AudioOutlined />
|
||||||
|
</div>
|
||||||
|
<h3>暂无配音素材</h3>
|
||||||
|
<p>{hasFilter ? "未找到匹配的素材,试试调整筛选条件" : "上传音频文件,开始管理配音素材"}</p>
|
||||||
|
{!hasFilter && (
|
||||||
|
<Button
|
||||||
|
buttonType="primary"
|
||||||
|
buttonSize="md"
|
||||||
|
icon={<UploadOutlined />}
|
||||||
|
onClick={onUploadClick}
|
||||||
|
>
|
||||||
|
上传配音
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default EmptyState
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import React from "react"
|
||||||
|
import type { TagItem } from "@/api/tags"
|
||||||
|
|
||||||
|
interface TagFilterBarProps {
|
||||||
|
tags: TagItem[]
|
||||||
|
filterTagId: string
|
||||||
|
tagCountMap: Record<string, number>
|
||||||
|
onTagSelect: (tagId: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const TagFilterBar: React.FC<TagFilterBarProps> = ({
|
||||||
|
tags,
|
||||||
|
filterTagId,
|
||||||
|
tagCountMap,
|
||||||
|
onTagSelect,
|
||||||
|
}) => {
|
||||||
|
if (tags.length === 0) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="vmat-tag-filter-bar">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`vmat-filter-pill${filterTagId === "all" ? " active" : ""}`}
|
||||||
|
onClick={() => onTagSelect("all")}
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
|
全部
|
||||||
|
</button>
|
||||||
|
{tags.map((tag) => (
|
||||||
|
<button
|
||||||
|
key={tag.id}
|
||||||
|
type="button"
|
||||||
|
className={`vmat-filter-pill${filterTagId === tag.id ? " active" : ""}`}
|
||||||
|
onClick={() => onTagSelect(tag.id)}
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
|
{tag.name}
|
||||||
|
<span className="vmat-filter-pill-count">{tagCountMap[tag.id] || 0}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TagFilterBar
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import React from "react"
|
||||||
|
import { SearchOutlined, AppstoreOutlined, UnorderedListOutlined } from "@ant-design/icons"
|
||||||
|
import { Input, Select } from "@/components/ui"
|
||||||
|
import type { ViewMode } from "../types"
|
||||||
|
|
||||||
|
interface ToolbarProps {
|
||||||
|
searchText: string
|
||||||
|
filterGender: string
|
||||||
|
viewMode: ViewMode
|
||||||
|
resultCount: number
|
||||||
|
onSearchChange: (value: string) => void
|
||||||
|
onGenderChange: (value: string) => void
|
||||||
|
onViewModeChange: (mode: ViewMode) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const GENDER_OPTIONS = [
|
||||||
|
{ value: "all", label: "全部性别" },
|
||||||
|
{ value: "male", label: "男声" },
|
||||||
|
{ value: "female", label: "女声" },
|
||||||
|
{ value: "child", label: "童声" },
|
||||||
|
{ value: "neutral", label: "中性" },
|
||||||
|
]
|
||||||
|
|
||||||
|
const Toolbar: React.FC<ToolbarProps> = ({
|
||||||
|
searchText,
|
||||||
|
filterGender,
|
||||||
|
viewMode,
|
||||||
|
resultCount,
|
||||||
|
onSearchChange,
|
||||||
|
onGenderChange,
|
||||||
|
onViewModeChange,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className="vmat-toolbar">
|
||||||
|
<div className="vmat-toolbar-left">
|
||||||
|
<Input
|
||||||
|
placeholder="搜索配音素材..."
|
||||||
|
prefix={<SearchOutlined />}
|
||||||
|
value={searchText}
|
||||||
|
onChange={(e) => onSearchChange(e.target.value)}
|
||||||
|
allowClear
|
||||||
|
style={{ width: 240 }}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
value={filterGender}
|
||||||
|
onChange={onGenderChange}
|
||||||
|
style={{ width: 120 }}
|
||||||
|
options={GENDER_OPTIONS}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="vmat-toolbar-right">
|
||||||
|
<span className="vmat-result-count">共 {resultCount} 个素材</span>
|
||||||
|
<div className="vmat-view-toggle">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`vmat-view-btn${viewMode === "card" ? " active" : ""}`}
|
||||||
|
onClick={() => onViewModeChange("card")}
|
||||||
|
title="卡片视图"
|
||||||
|
>
|
||||||
|
<AppstoreOutlined />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`vmat-view-btn${viewMode === "list" ? " active" : ""}`}
|
||||||
|
onClick={() => onViewModeChange("list")}
|
||||||
|
title="列表视图"
|
||||||
|
>
|
||||||
|
<UnorderedListOutlined />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Toolbar
|
||||||
+171
@@ -0,0 +1,171 @@
|
|||||||
|
import React from "react"
|
||||||
|
import { RobotOutlined, LoadingOutlined, PlusOutlined } from "@ant-design/icons"
|
||||||
|
import { Button } from "@/components/ui"
|
||||||
|
|
||||||
|
export type TtsStatus = "idle" | "synthesizing" | "done" | "error"
|
||||||
|
|
||||||
|
export interface TtsPresetVoice {
|
||||||
|
voice_id: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TtsModalProps {
|
||||||
|
open: boolean
|
||||||
|
text: string
|
||||||
|
voiceId: string
|
||||||
|
speed: number
|
||||||
|
status: TtsStatus
|
||||||
|
audioUrl: string
|
||||||
|
error: string
|
||||||
|
presetVoices: TtsPresetVoice[]
|
||||||
|
onClose: () => void
|
||||||
|
onTextChange: (value: string) => void
|
||||||
|
onVoiceChange: (voiceId: string) => void
|
||||||
|
onSpeedChange: (speed: number) => void
|
||||||
|
onSynthesize: () => void
|
||||||
|
onSave: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const TtsModal: React.FC<TtsModalProps> = ({
|
||||||
|
open,
|
||||||
|
text,
|
||||||
|
voiceId,
|
||||||
|
speed,
|
||||||
|
status,
|
||||||
|
audioUrl,
|
||||||
|
error,
|
||||||
|
presetVoices,
|
||||||
|
onClose: _onClose,
|
||||||
|
onTextChange,
|
||||||
|
onVoiceChange,
|
||||||
|
onSpeedChange,
|
||||||
|
onSynthesize,
|
||||||
|
onSave,
|
||||||
|
}) => {
|
||||||
|
if (!open) return null
|
||||||
|
|
||||||
|
const labelStyle: React.CSSProperties = {
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 500,
|
||||||
|
marginBottom: 6,
|
||||||
|
display: "block",
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
||||||
|
{/* 文本输入 */}
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>输入文本</label>
|
||||||
|
<textarea
|
||||||
|
rows={4}
|
||||||
|
placeholder="请输入需要转换为语音的文本内容…"
|
||||||
|
value={text}
|
||||||
|
onChange={(e) => onTextChange(e.target.value)}
|
||||||
|
maxLength={2000}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
padding: "8px 12px",
|
||||||
|
border: "1px solid var(--border-color, #d9d9d9)",
|
||||||
|
borderRadius: 6,
|
||||||
|
fontSize: 13,
|
||||||
|
resize: "vertical",
|
||||||
|
fontFamily: "inherit",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
color: "var(--text-tertiary, #999)",
|
||||||
|
marginTop: 4,
|
||||||
|
textAlign: "right",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{text.length}/2000
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 音色选择 */}
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>选择音色</label>
|
||||||
|
<select
|
||||||
|
value={voiceId}
|
||||||
|
onChange={(e) => onVoiceChange(e.target.value)}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
height: 36,
|
||||||
|
padding: "0 10px",
|
||||||
|
border: "1px solid var(--border-color, #d9d9d9)",
|
||||||
|
borderRadius: 6,
|
||||||
|
fontSize: 13,
|
||||||
|
background: "var(--bg-primary, #fff)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">默认音色</option>
|
||||||
|
{presetVoices.map((v) => (
|
||||||
|
<option key={v.voice_id} value={v.voice_id}>
|
||||||
|
{v.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 语速调节 */}
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>语速:{speed.toFixed(1)}x</label>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0.5}
|
||||||
|
max={2.0}
|
||||||
|
step={0.1}
|
||||||
|
value={speed}
|
||||||
|
onChange={(e) => onSpeedChange(parseFloat(e.target.value))}
|
||||||
|
style={{ width: "100%" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 合成按钮 */}
|
||||||
|
<Button
|
||||||
|
buttonType="primary"
|
||||||
|
buttonSize="md"
|
||||||
|
icon={status === "synthesizing" ? <LoadingOutlined /> : <RobotOutlined />}
|
||||||
|
onClick={onSynthesize}
|
||||||
|
disabled={status === "synthesizing" || !text.trim()}
|
||||||
|
>
|
||||||
|
{status === "synthesizing" ? "合成中…" : "开始合成"}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* 错误提示 */}
|
||||||
|
{status === "error" && error && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "8px 12px",
|
||||||
|
background: "#fff2f0",
|
||||||
|
borderRadius: 6,
|
||||||
|
color: "#ff4d4f",
|
||||||
|
fontSize: 13,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 合成结果 */}
|
||||||
|
{status === "done" && audioUrl && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: 12,
|
||||||
|
background: "var(--bg-surface, #f5f5f5)",
|
||||||
|
borderRadius: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<audio controls src={audioUrl} style={{ width: "100%", marginBottom: 12 }} />
|
||||||
|
<Button buttonType="primary" buttonSize="sm" icon={<PlusOutlined />} onClick={onSave}>
|
||||||
|
保存到配音库
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TtsModal
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { useMemo } from "react"
|
||||||
|
import type { TagItem } from "@/api/tags"
|
||||||
|
import { useVoiceTags } from "./useVoiceTags"
|
||||||
|
import { useVoiceMaterialFilterState } from "./useVoiceMaterialFilterState"
|
||||||
|
import { useVoiceMaterialData } from "./useVoiceMaterialData"
|
||||||
|
import { useVoiceMaterialActions } from "./useVoiceMaterialActions"
|
||||||
|
import { type VoiceMaterial } from "../../types"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 配音素材数据 Hook
|
||||||
|
* 封装素材列表查询、筛选状态管理、增删改等数据操作逻辑
|
||||||
|
*/
|
||||||
|
export function useVoiceMaterials() {
|
||||||
|
// 标签管理
|
||||||
|
const { tags, tagMap, handleCreateTag } = useVoiceTags()
|
||||||
|
|
||||||
|
// 筛选视图状态
|
||||||
|
const filterState = useVoiceMaterialFilterState()
|
||||||
|
const { searchText, filterGender, filterTagId } = filterState
|
||||||
|
|
||||||
|
// 后端查询参数
|
||||||
|
const keyword = searchText.trim() || undefined
|
||||||
|
const gender = filterGender !== "all" ? filterGender : undefined
|
||||||
|
const tagIds = filterTagId !== "all" ? [filterTagId] : undefined
|
||||||
|
|
||||||
|
// 数据查询
|
||||||
|
const { libraries, voiceLibrary, materials, isLoading, createLibMutation } = useVoiceMaterialData(
|
||||||
|
{ keyword, gender, tagIds },
|
||||||
|
)
|
||||||
|
|
||||||
|
// 操作层
|
||||||
|
const actions = useVoiceMaterialActions({
|
||||||
|
voiceLibrary,
|
||||||
|
materials,
|
||||||
|
createLibMutation,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 前端二次筛选(与后端筛选同时存在,保证即时响应)
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
let list: VoiceMaterial[] = materials
|
||||||
|
if (filterGender !== "all") {
|
||||||
|
list = list.filter((m) => m.gender === filterGender)
|
||||||
|
}
|
||||||
|
if (filterTagId !== "all") {
|
||||||
|
list = list.filter((m) => m.tagIds.includes(filterTagId))
|
||||||
|
}
|
||||||
|
if (searchText.trim()) {
|
||||||
|
const q = searchText.trim().toLowerCase()
|
||||||
|
list = list.filter(
|
||||||
|
(m) =>
|
||||||
|
m.name.toLowerCase().includes(q) ||
|
||||||
|
m.description.toLowerCase().includes(q) ||
|
||||||
|
m.tagIds.some((id) =>
|
||||||
|
(tagMap as Map<string, TagItem>).get(id)?.name?.toLowerCase().includes(q),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return list
|
||||||
|
}, [materials, filterGender, filterTagId, searchText, tagMap])
|
||||||
|
|
||||||
|
// 标签使用计数
|
||||||
|
const tagCountMap = useMemo(() => {
|
||||||
|
const map: Record<string, number> = {}
|
||||||
|
materials.forEach((m) =>
|
||||||
|
m.tagIds.forEach((id) => {
|
||||||
|
map[id] = (map[id] || 0) + 1
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
return map
|
||||||
|
}, [materials])
|
||||||
|
|
||||||
|
return {
|
||||||
|
// 数据
|
||||||
|
libraries,
|
||||||
|
voiceLibrary,
|
||||||
|
tags,
|
||||||
|
tagMap,
|
||||||
|
materials,
|
||||||
|
filtered,
|
||||||
|
tagCountMap,
|
||||||
|
isLoading,
|
||||||
|
// 视图 & 筛选状态
|
||||||
|
viewMode: filterState.viewMode,
|
||||||
|
searchText: filterState.searchText,
|
||||||
|
filterGender: filterState.filterGender,
|
||||||
|
filterTagId: filterState.filterTagId,
|
||||||
|
// 上传 & 编辑状态
|
||||||
|
uploadProgress: actions.uploadProgress,
|
||||||
|
isUploading: actions.isUploading,
|
||||||
|
isEditing: actions.isEditing,
|
||||||
|
// 弹窗状态
|
||||||
|
uploadOpen: actions.uploadOpen,
|
||||||
|
editingMaterial: actions.editingMaterial,
|
||||||
|
// 视图控制
|
||||||
|
setViewMode: filterState.setViewMode,
|
||||||
|
setSearchText: filterState.setSearchText,
|
||||||
|
setFilterGender: filterState.setFilterGender,
|
||||||
|
setFilterTagId: filterState.setFilterTagId,
|
||||||
|
setUploadOpen: actions.setUploadOpen,
|
||||||
|
setEditingMaterial: actions.setEditingMaterial,
|
||||||
|
// 操作
|
||||||
|
handleCreateTag,
|
||||||
|
handleUpload: actions.handleUpload,
|
||||||
|
handleEdit: actions.handleEdit,
|
||||||
|
handleDelete: actions.handleDelete,
|
||||||
|
}
|
||||||
|
}
|
||||||
+20
-155
@@ -1,117 +1,35 @@
|
|||||||
import { useState, useMemo, useCallback, useEffect } from "react"
|
import { useState, useCallback } from "react"
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||||
import { message } from "antd"
|
import { message } from "antd"
|
||||||
import {
|
import {
|
||||||
getAssetsByKind,
|
|
||||||
createAsset,
|
createAsset,
|
||||||
updateAsset,
|
updateAsset,
|
||||||
deleteAsset,
|
deleteAsset,
|
||||||
uploadAssetDirect,
|
uploadAssetDirect,
|
||||||
getAssetLibraries,
|
getAssetLibraries,
|
||||||
createAssetLibrary,
|
type AssetLibraryItem,
|
||||||
} from "@/api/assets"
|
} from "@/api/assets"
|
||||||
import { type TagItem, getTags, createTag, tagAsset, untagAsset } from "@/api/tags"
|
import { tagAsset, untagAsset } from "@/api/tags"
|
||||||
import {
|
import { type VoiceGender, type VoiceMaterial, buildMetadata } from "../../types"
|
||||||
type VoiceGender,
|
import { getAudioDuration } from "../../utils/audio"
|
||||||
type ViewMode,
|
|
||||||
type VoiceMaterial,
|
interface UseVoiceMaterialActionsOptions {
|
||||||
mapAssetToMaterial,
|
voiceLibrary?: { id: string; kind: string }
|
||||||
buildMetadata,
|
materials: VoiceMaterial[]
|
||||||
} from "../types"
|
createLibMutation: { mutateAsync: () => Promise<AssetLibraryItem>; isPending: boolean }
|
||||||
import { getAudioDuration } from "../utils/audio"
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 配音素材数据 Hook
|
* 配音素材操作 Hook
|
||||||
* 封装素材列表查询、筛选状态管理、增删改等数据操作逻辑
|
* 封装上传、编辑、删除等变更操作及相关 UI 状态
|
||||||
*/
|
*/
|
||||||
export function useVoiceMaterials() {
|
export function useVoiceMaterialActions({
|
||||||
|
voiceLibrary,
|
||||||
|
materials,
|
||||||
|
createLibMutation,
|
||||||
|
}: UseVoiceMaterialActionsOptions) {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
// ── 获取 voice 类型素材库(用于上传) ──────────────────────
|
|
||||||
const { data: libraries = [] } = useQuery({
|
|
||||||
queryKey: ["asset-libraries"],
|
|
||||||
queryFn: getAssetLibraries,
|
|
||||||
staleTime: 60_000,
|
|
||||||
})
|
|
||||||
|
|
||||||
const voiceLibrary = useMemo(() => libraries.find((lib) => lib.kind === "voice"), [libraries])
|
|
||||||
|
|
||||||
// 自动创建 voice 素材库(如果不存在)
|
|
||||||
const createLibMutation = useMutation({
|
|
||||||
mutationFn: () => createAssetLibrary({ name: "配音库", kind: "voice" }),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] })
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (libraries.length > 0 && !voiceLibrary && !createLibMutation.isPending) {
|
|
||||||
createLibMutation.mutate()
|
|
||||||
}
|
|
||||||
}, [libraries, voiceLibrary, createLibMutation])
|
|
||||||
|
|
||||||
// ── 获取标签列表 ───────────────────────────────────────────
|
|
||||||
const { data: tags = [] } = useQuery({
|
|
||||||
queryKey: ["tags"],
|
|
||||||
queryFn: getTags,
|
|
||||||
staleTime: 60_000,
|
|
||||||
})
|
|
||||||
|
|
||||||
/** 标签 ID → TagItem 映射(用于卡片/行渲染) */
|
|
||||||
const tagMap = useMemo(() => {
|
|
||||||
const m = new Map<string, TagItem>()
|
|
||||||
tags.forEach((t) => m.set(t.id, t))
|
|
||||||
return m
|
|
||||||
}, [tags])
|
|
||||||
|
|
||||||
/** 创建标签 mutation(供 TagSelector 调用) */
|
|
||||||
const createTagMutation = useMutation({
|
|
||||||
mutationFn: (name: string) => createTag(name),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["tags"] })
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
/** 创建标签并返回 TagItem(供 TagSelector 使用) */
|
|
||||||
const handleCreateTag = useCallback(
|
|
||||||
async (name: string): Promise<TagItem> => {
|
|
||||||
return createTagMutation.mutateAsync(name)
|
|
||||||
},
|
|
||||||
[createTagMutation],
|
|
||||||
)
|
|
||||||
|
|
||||||
// ── 视图 & 筛选状态 ────────────────────────────────────────
|
|
||||||
const [viewMode, setViewMode] = useState<ViewMode>("card")
|
|
||||||
const [searchText, setSearchText] = useState("")
|
|
||||||
const [filterGender, setFilterGender] = useState<string>("all")
|
|
||||||
const [filterTagId, setFilterTagId] = useState<string>("all")
|
|
||||||
|
|
||||||
// ── 获取配音素材列表(筛选参数透传后端) ─────────────────
|
|
||||||
const filterKeyword = searchText.trim() || undefined
|
|
||||||
const filterGenderParam = filterGender !== "all" ? filterGender : undefined
|
|
||||||
const filterTagIdsParam = filterTagId !== "all" ? [filterTagId] : undefined
|
|
||||||
|
|
||||||
const { data: assets = [], isLoading } = useQuery({
|
|
||||||
queryKey: [
|
|
||||||
"assets",
|
|
||||||
"voice",
|
|
||||||
{
|
|
||||||
keyword: filterKeyword,
|
|
||||||
gender: filterGenderParam,
|
|
||||||
tag_ids: filterTagIdsParam,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
queryFn: () =>
|
|
||||||
getAssetsByKind("voice", {
|
|
||||||
keyword: filterKeyword,
|
|
||||||
gender: filterGenderParam,
|
|
||||||
tag_ids: filterTagIdsParam,
|
|
||||||
}),
|
|
||||||
staleTime: 30_000,
|
|
||||||
})
|
|
||||||
|
|
||||||
const materials: VoiceMaterial[] = useMemo(() => assets.map(mapAssetToMaterial), [assets])
|
|
||||||
|
|
||||||
// ── 弹窗状态 ──────────────────────────────────────────────
|
// ── 弹窗状态 ──────────────────────────────────────────────
|
||||||
const [uploadOpen, setUploadOpen] = useState(false)
|
const [uploadOpen, setUploadOpen] = useState(false)
|
||||||
const [editingMaterial, setEditingMaterial] = useState<VoiceMaterial | null>(null)
|
const [editingMaterial, setEditingMaterial] = useState<VoiceMaterial | null>(null)
|
||||||
@@ -140,7 +58,7 @@ export function useVoiceMaterials() {
|
|||||||
queryKey: ["asset-libraries"],
|
queryKey: ["asset-libraries"],
|
||||||
queryFn: getAssetLibraries,
|
queryFn: getAssetLibraries,
|
||||||
})
|
})
|
||||||
lib = libs.find((l) => l.kind === "voice")
|
lib = libs.find((l: AssetLibraryItem) => l.kind === "voice")
|
||||||
if (!lib) throw new Error("无法创建配音库")
|
if (!lib) throw new Error("无法创建配音库")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,40 +149,6 @@ export function useVoiceMaterials() {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
/* ── 前端二次筛选(与后端筛选同时存在) ──────────────────── */
|
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
|
||||||
let list = materials
|
|
||||||
if (filterGender !== "all") {
|
|
||||||
list = list.filter((m) => m.gender === filterGender)
|
|
||||||
}
|
|
||||||
if (filterTagId !== "all") {
|
|
||||||
list = list.filter((m) => m.tagIds.includes(filterTagId))
|
|
||||||
}
|
|
||||||
if (searchText.trim()) {
|
|
||||||
const q = searchText.trim().toLowerCase()
|
|
||||||
list = list.filter(
|
|
||||||
(m) =>
|
|
||||||
m.name.toLowerCase().includes(q) ||
|
|
||||||
m.description.toLowerCase().includes(q) ||
|
|
||||||
m.tagIds.some((id) => tagMap.get(id)?.name?.toLowerCase().includes(q)),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return list
|
|
||||||
}, [materials, filterGender, filterTagId, searchText, tagMap])
|
|
||||||
|
|
||||||
/* ── 标签使用计数(药丸条展示,按 tag ID 统计) ──────────── */
|
|
||||||
|
|
||||||
const tagCountMap = useMemo(() => {
|
|
||||||
const map: Record<string, number> = {}
|
|
||||||
materials.forEach((m) =>
|
|
||||||
m.tagIds.forEach((id) => {
|
|
||||||
map[id] = (map[id] || 0) + 1
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
return map
|
|
||||||
}, [materials])
|
|
||||||
|
|
||||||
/* ── 数据操作 handlers ──────────────────────────────────── */
|
/* ── 数据操作 handlers ──────────────────────────────────── */
|
||||||
|
|
||||||
const handleUpload = useCallback(
|
const handleUpload = useCallback(
|
||||||
@@ -314,20 +198,6 @@ export function useVoiceMaterials() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// 数据
|
|
||||||
libraries,
|
|
||||||
voiceLibrary,
|
|
||||||
tags,
|
|
||||||
tagMap,
|
|
||||||
materials,
|
|
||||||
filtered,
|
|
||||||
tagCountMap,
|
|
||||||
isLoading,
|
|
||||||
// 视图 & 筛选状态
|
|
||||||
viewMode,
|
|
||||||
searchText,
|
|
||||||
filterGender,
|
|
||||||
filterTagId,
|
|
||||||
// 上传 & 编辑状态
|
// 上传 & 编辑状态
|
||||||
uploadProgress,
|
uploadProgress,
|
||||||
isUploading: uploadMutation.isPending,
|
isUploading: uploadMutation.isPending,
|
||||||
@@ -336,14 +206,9 @@ export function useVoiceMaterials() {
|
|||||||
uploadOpen,
|
uploadOpen,
|
||||||
editingMaterial,
|
editingMaterial,
|
||||||
// 视图控制
|
// 视图控制
|
||||||
setViewMode,
|
|
||||||
setSearchText,
|
|
||||||
setFilterGender,
|
|
||||||
setFilterTagId,
|
|
||||||
setUploadOpen,
|
setUploadOpen,
|
||||||
setEditingMaterial,
|
setEditingMaterial,
|
||||||
// 操作
|
// 操作
|
||||||
handleCreateTag,
|
|
||||||
handleUpload,
|
handleUpload,
|
||||||
handleEdit,
|
handleEdit,
|
||||||
handleDelete,
|
handleDelete,
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { useMemo, useEffect } from "react"
|
||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
||||||
|
import { getAssetsByKind, getAssetLibraries, createAssetLibrary } from "@/api/assets"
|
||||||
|
import { type VoiceMaterial, mapAssetToMaterial } from "../../types"
|
||||||
|
|
||||||
|
interface UseVoiceMaterialDataOptions {
|
||||||
|
keyword?: string
|
||||||
|
gender?: string
|
||||||
|
tagIds?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 配音素材数据查询 Hook
|
||||||
|
* 封装素材库获取、自动创建 voice 库、素材列表查询
|
||||||
|
*/
|
||||||
|
export function useVoiceMaterialData({ keyword, gender, tagIds }: UseVoiceMaterialDataOptions) {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
// ── 获取 voice 类型素材库 ─────────────────────────────────
|
||||||
|
const { data: libraries = [] } = useQuery({
|
||||||
|
queryKey: ["asset-libraries"],
|
||||||
|
queryFn: getAssetLibraries,
|
||||||
|
staleTime: 60_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
const voiceLibrary = useMemo(() => libraries.find((lib) => lib.kind === "voice"), [libraries])
|
||||||
|
|
||||||
|
// 自动创建 voice 素材库(如果不存在)
|
||||||
|
const createLibMutation = useMutation({
|
||||||
|
mutationFn: () => createAssetLibrary({ name: "配音库", kind: "voice" }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (libraries.length > 0 && !voiceLibrary && !createLibMutation.isPending) {
|
||||||
|
createLibMutation.mutate()
|
||||||
|
}
|
||||||
|
}, [libraries, voiceLibrary, createLibMutation])
|
||||||
|
|
||||||
|
// ── 获取配音素材列表 ─────────────────────────────────────
|
||||||
|
const { data: assets = [], isLoading } = useQuery({
|
||||||
|
queryKey: ["assets", "voice", { keyword, gender, tag_ids: tagIds }],
|
||||||
|
queryFn: () => getAssetsByKind("voice", { keyword, gender, tag_ids: tagIds }),
|
||||||
|
staleTime: 30_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
const materials: VoiceMaterial[] = useMemo(() => assets.map(mapAssetToMaterial), [assets])
|
||||||
|
|
||||||
|
return {
|
||||||
|
libraries,
|
||||||
|
voiceLibrary,
|
||||||
|
materials,
|
||||||
|
isLoading,
|
||||||
|
createLibMutation,
|
||||||
|
}
|
||||||
|
}
|
||||||
+24
@@ -0,0 +1,24 @@
|
|||||||
|
import { useState } from "react"
|
||||||
|
import { type ViewMode } from "../../types"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 配音素材筛选视图状态 Hook
|
||||||
|
* 管理视图模式、搜索、性别/标签筛选的状态
|
||||||
|
*/
|
||||||
|
export function useVoiceMaterialFilterState() {
|
||||||
|
const [viewMode, setViewMode] = useState<ViewMode>("card")
|
||||||
|
const [searchText, setSearchText] = useState("")
|
||||||
|
const [filterGender, setFilterGender] = useState<string>("all")
|
||||||
|
const [filterTagId, setFilterTagId] = useState<string>("all")
|
||||||
|
|
||||||
|
return {
|
||||||
|
viewMode,
|
||||||
|
searchText,
|
||||||
|
filterGender,
|
||||||
|
filterTagId,
|
||||||
|
setViewMode,
|
||||||
|
setSearchText,
|
||||||
|
setFilterGender,
|
||||||
|
setFilterTagId,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { useMemo, useCallback } from "react"
|
||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
||||||
|
import { type TagItem, getTags, createTag } from "@/api/tags"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 配音素材标签管理 Hook
|
||||||
|
* 封装标签列表查询、标签映射、创建标签等逻辑
|
||||||
|
*/
|
||||||
|
export function useVoiceTags() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
const { data: tags = [] } = useQuery({
|
||||||
|
queryKey: ["tags"],
|
||||||
|
queryFn: getTags,
|
||||||
|
staleTime: 60_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 标签 ID → TagItem 映射(用于卡片/行渲染) */
|
||||||
|
const tagMap = useMemo(() => {
|
||||||
|
const m = new Map<string, TagItem>()
|
||||||
|
tags.forEach((t) => m.set(t.id, t))
|
||||||
|
return m
|
||||||
|
}, [tags])
|
||||||
|
|
||||||
|
const createTagMutation = useMutation({
|
||||||
|
mutationFn: (name: string) => createTag(name),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["tags"] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleCreateTag = useCallback(
|
||||||
|
async (name: string): Promise<TagItem> => {
|
||||||
|
return createTagMutation.mutateAsync(name)
|
||||||
|
},
|
||||||
|
[createTagMutation],
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
tags,
|
||||||
|
tagMap,
|
||||||
|
handleCreateTag,
|
||||||
|
}
|
||||||
|
}
|
||||||
Regular → Executable
+88
-281
@@ -1,47 +1,39 @@
|
|||||||
/**
|
/**
|
||||||
* 配音库页面 — V21 设计系统
|
* 配音库页面 — V21 设计系统
|
||||||
*
|
*
|
||||||
* Phase 3: 逻辑抽离为 Hooks
|
* 主组件仅保留 Hook 组装与整体布局
|
||||||
* - useVoicesData: 三 Tab 数据查询 + 筛选
|
* 数据查询 → hooks/useVoicesData
|
||||||
* - useAudioPlayer: 播放控制
|
* 播放控制 → hooks/useAudioPlayer
|
||||||
* - useCloneOperations: 克隆音色删除/重试/详情
|
* 克隆操作 → hooks/useCloneOperations
|
||||||
* - useTtsSynthesize: AI 配音合成
|
* TTS 合成 → hooks/useTtsSynthesize
|
||||||
* - useVoiceUpload: 上传音频
|
* 上传音频 → hooks/useVoiceUpload
|
||||||
|
* Tab 切换 → components/VoiceTabBar
|
||||||
|
* 预置音色 → components/PresetVoiceTab
|
||||||
|
* 克隆音色 → components/ClonedVoiceTab
|
||||||
|
* 配音素材 → components/MaterialVoiceTab
|
||||||
|
* 弹窗集合 → components/VoiceModals
|
||||||
|
* Toast 提示 → components/VoiceToasts
|
||||||
*/
|
*/
|
||||||
import React, { useCallback, useState } from "react"
|
import React, { useCallback, useState } from "react"
|
||||||
import {
|
import { UploadOutlined, AudioOutlined, RobotOutlined } from "@ant-design/icons"
|
||||||
SoundOutlined,
|
|
||||||
PlusOutlined,
|
|
||||||
RobotOutlined,
|
|
||||||
UploadOutlined,
|
|
||||||
AudioOutlined,
|
|
||||||
UserOutlined,
|
|
||||||
} from "@ant-design/icons"
|
|
||||||
import { Button } from "@/components/ui"
|
import { Button } from "@/components/ui"
|
||||||
import PageHead from "@/components/layout/PageHead"
|
import PageHead from "@/components/layout/PageHead"
|
||||||
import { type AssetItem } from "@/api/assets"
|
import { type AssetItem } from "@/api/assets"
|
||||||
import { genderLabel, languageLabel } from "@/pages/voices/utils/format"
|
import VoiceTabBar from "./components/VoiceTabBar"
|
||||||
import CloneModal from "@/components/voice/CloneModal"
|
import type { VoiceTabKey } from "./components/VoiceTabBar"
|
||||||
import VoiceCard from "@/pages/voices/components/VoiceCard"
|
import PresetVoiceTab from "./components/PresetVoiceTab"
|
||||||
import CloneVoiceCard from "@/pages/voices/components/CloneVoiceCard"
|
import ClonedVoiceTab from "./components/ClonedVoiceTab"
|
||||||
import CloneDetailModal from "@/pages/voices/components/CloneDetailModal"
|
import MaterialVoiceTab from "./components/MaterialVoiceTab"
|
||||||
import CloneCardSkeleton from "@/pages/voices/components/CloneCardSkeleton"
|
import VoiceModals from "./components/VoiceModals"
|
||||||
import UploadVoiceModal from "@/pages/voices/components/UploadVoiceModal"
|
import VoiceToasts from "./components/VoiceToasts"
|
||||||
import TtsModal from "@/pages/voices/components/TtsModal"
|
import type { Toast } from "./components/VoiceToasts"
|
||||||
import VoiceFilterBar from "@/pages/voices/components/VoiceFilterBar"
|
import { useVoicesData } from "./hooks/useVoicesData"
|
||||||
import { useVoicesData } from "@/pages/voices/hooks/useVoicesData"
|
import { useAudioPlayer } from "./hooks/useAudioPlayer"
|
||||||
import { useAudioPlayer } from "@/pages/voices/hooks/useAudioPlayer"
|
import { useCloneOperations } from "./hooks/useCloneOperations"
|
||||||
import { useCloneOperations } from "@/pages/voices/hooks/useCloneOperations"
|
import { useTtsSynthesize } from "./hooks/useTtsSynthesize"
|
||||||
import { useTtsSynthesize } from "@/pages/voices/hooks/useTtsSynthesize"
|
import { useVoiceUpload } from "./hooks/useVoiceUpload"
|
||||||
import { useVoiceUpload } from "@/pages/voices/hooks/useVoiceUpload"
|
|
||||||
import "./voices.css"
|
import "./voices.css"
|
||||||
|
|
||||||
interface Toast {
|
|
||||||
id: number
|
|
||||||
message: string
|
|
||||||
type: "success" | "error"
|
|
||||||
}
|
|
||||||
|
|
||||||
let toastIdSeq = 0
|
let toastIdSeq = 0
|
||||||
|
|
||||||
const VoiceLibrary: React.FC = () => {
|
const VoiceLibrary: React.FC = () => {
|
||||||
@@ -138,17 +130,9 @@ const VoiceLibrary: React.FC = () => {
|
|||||||
handleUploadClose,
|
handleUploadClose,
|
||||||
} = useVoiceUpload({ showToast })
|
} = useVoiceUpload({ showToast })
|
||||||
|
|
||||||
// ── 克隆音色播放切换 ──────────────────────────────────
|
|
||||||
const handleClonePlayPause = useCallback(
|
|
||||||
(voiceId: string, duration: number) => {
|
|
||||||
handleTogglePlay(voiceId, duration)
|
|
||||||
},
|
|
||||||
[handleTogglePlay],
|
|
||||||
)
|
|
||||||
|
|
||||||
// ── 切换 Tab 时停止播放 ───────────────────────────────
|
// ── 切换 Tab 时停止播放 ───────────────────────────────
|
||||||
const handleTabChange = useCallback(
|
const handleTabChange = useCallback(
|
||||||
(tab: typeof activeTab) => {
|
(tab: VoiceTabKey) => {
|
||||||
setActiveTab(tab)
|
setActiveTab(tab)
|
||||||
stopPlayback()
|
stopPlayback()
|
||||||
},
|
},
|
||||||
@@ -200,254 +184,85 @@ const VoiceLibrary: React.FC = () => {
|
|||||||
actions={pageActions}
|
actions={pageActions}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* ── Tabs ──────────────────────────────────────── */}
|
{/* ── Tab 切换栏 ────────────────────────────────── */}
|
||||||
<div className="xx-voices-tabs">
|
<VoiceTabBar
|
||||||
<button
|
activeTab={activeTab as VoiceTabKey}
|
||||||
className={`xx-voices-tab${activeTab === "preset" ? " active" : ""}`}
|
presetCount={presetCount}
|
||||||
onClick={() => handleTabChange("preset")}
|
cloneCount={cloneCount}
|
||||||
>
|
materialCount={materialCount}
|
||||||
<AudioOutlined />
|
onTabChange={handleTabChange}
|
||||||
预置音色
|
/>
|
||||||
<span className="xx-voices-tab-count">{presetCount}</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={`xx-voices-tab${activeTab === "cloned" ? " active" : ""}`}
|
|
||||||
onClick={() => handleTabChange("cloned")}
|
|
||||||
>
|
|
||||||
<UserOutlined />
|
|
||||||
我的克隆
|
|
||||||
<span className="xx-voices-tab-count">{cloneCount}</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={`xx-voices-tab${activeTab === "material" ? " active" : ""}`}
|
|
||||||
onClick={() => handleTabChange("material")}
|
|
||||||
>
|
|
||||||
<SoundOutlined />
|
|
||||||
配音素材
|
|
||||||
<span className="xx-voices-tab-count">{materialCount}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── 预置音色 ──────────────────────────────────── */}
|
{/* ── 预置音色 ──────────────────────────────────── */}
|
||||||
{activeTab === "preset" && (
|
{activeTab === "preset" && (
|
||||||
<div className="xx-voices-tab-content">
|
<PresetVoiceTab
|
||||||
<VoiceFilterBar
|
searchText={searchText}
|
||||||
searchText={searchText}
|
filterGender={filterGender}
|
||||||
filterGender={filterGender}
|
filterLang={filterLang}
|
||||||
filterLang={filterLang}
|
onSearchChange={setSearchText}
|
||||||
onSearchChange={setSearchText}
|
onGenderChange={setFilterGender}
|
||||||
onGenderChange={setFilterGender}
|
onLangChange={setFilterLang}
|
||||||
onLangChange={setFilterLang}
|
loading={presetLoading}
|
||||||
/>
|
voices={filteredPreset}
|
||||||
|
playingId={playingId}
|
||||||
{presetLoading && (
|
currentTime={currentTime}
|
||||||
<div className="xx-voices-empty">
|
onPlay={handlePlay}
|
||||||
<div className="xx-voices-empty-icon">
|
onPause={handlePause}
|
||||||
<SoundOutlined />
|
onSeek={handleSeek}
|
||||||
</div>
|
onClearFilters={handleClearFilters}
|
||||||
<p>加载预置音色中...</p>
|
/>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!presetLoading && filteredPreset.length > 0 && (
|
|
||||||
<div className="xx-voice-grid">
|
|
||||||
{filteredPreset.map((voice) => (
|
|
||||||
<VoiceCard
|
|
||||||
key={voice.id}
|
|
||||||
id={voice.id}
|
|
||||||
name={voice.name}
|
|
||||||
subtitle={`${genderLabel(voice.gender)} · ${languageLabel(voice.language)} · ${voice.description}`}
|
|
||||||
tags={voice.tags}
|
|
||||||
duration={voice.duration}
|
|
||||||
gender={voice.gender}
|
|
||||||
isPlaying={playingId === voice.id}
|
|
||||||
isSelected={false}
|
|
||||||
currentTime={playingId === voice.id ? currentTime : 0}
|
|
||||||
starred={voice.starred}
|
|
||||||
onPlay={() => handlePlay(voice.id, voice.duration)}
|
|
||||||
onPause={handlePause}
|
|
||||||
onSeek={(time) => handleSeek(voice.id, time, voice.duration)}
|
|
||||||
onToggleStar={() => {}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!presetLoading && filteredPreset.length === 0 && (
|
|
||||||
<div className="xx-voices-empty">
|
|
||||||
<div className="xx-voices-empty-icon">
|
|
||||||
<SoundOutlined />
|
|
||||||
</div>
|
|
||||||
<p>未找到匹配的音色</p>
|
|
||||||
<Button buttonType="ghost" buttonSize="sm" onClick={handleClearFilters}>
|
|
||||||
清除筛选条件
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── 我的克隆 ──────────────────────────────────── */}
|
{/* ── 我的克隆 ──────────────────────────────────── */}
|
||||||
{activeTab === "cloned" && (
|
{activeTab === "cloned" && (
|
||||||
<div className="xx-voices-tab-content">
|
<ClonedVoiceTab
|
||||||
{/* 骨架屏加载 */}
|
loading={cloneLoading}
|
||||||
{cloneLoading && (
|
voices={clonedVoices}
|
||||||
<div className="xx-voice-grid">
|
playingId={playingId}
|
||||||
{Array.from({ length: 6 }).map((_, i) => (
|
currentTime={currentTime}
|
||||||
<CloneCardSkeleton key={i} />
|
onPlay={handleTogglePlay}
|
||||||
))}
|
onPause={handlePause}
|
||||||
</div>
|
onUse={handleCloneUse}
|
||||||
)}
|
onDelete={handleCloneDelete}
|
||||||
|
onRetry={handleCloneRetry}
|
||||||
{/* 卡片列表 */}
|
onShowDetail={handleShowDetail}
|
||||||
{!cloneLoading && clonedVoices.length > 0 && (
|
onOpenClone={() => setCloneModalOpen(true)}
|
||||||
<div className="xx-voice-grid">
|
/>
|
||||||
{clonedVoices.map((voice) => (
|
|
||||||
<CloneVoiceCard
|
|
||||||
key={voice.id}
|
|
||||||
voice={voice}
|
|
||||||
isPlaying={playingId === voice.id}
|
|
||||||
currentTime={playingId === voice.id ? currentTime : 0}
|
|
||||||
onPlay={() => handleClonePlayPause(voice.id, voice.duration)}
|
|
||||||
onPause={handlePause}
|
|
||||||
onUse={() => handleCloneUse(voice)}
|
|
||||||
onDelete={() => handleCloneDelete(voice)}
|
|
||||||
onRetry={() => handleCloneRetry(voice)}
|
|
||||||
onShowDetail={() => handleShowDetail(voice)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 空状态 */}
|
|
||||||
{!cloneLoading && clonedVoices.length === 0 && (
|
|
||||||
<div className="xx-voices-empty">
|
|
||||||
<div className="xx-voices-empty-icon">
|
|
||||||
<UserOutlined />
|
|
||||||
</div>
|
|
||||||
<h3>暂无克隆音色</h3>
|
|
||||||
<p>上传音频素材即可克隆专属音色</p>
|
|
||||||
<Button buttonType="primary" buttonSize="md" onClick={() => setCloneModalOpen(true)}>
|
|
||||||
<PlusOutlined /> 去克隆音色
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 处理中提示 */}
|
|
||||||
{!cloneLoading && clonedVoices.some((v) => v.status === "processing") && (
|
|
||||||
<div className="xx-clone-processing-hint">
|
|
||||||
<RobotOutlined />
|
|
||||||
<span>部分音色正在克隆处理中,完成后将自动出现在列表中。</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── 配音素材 ──────────────────────────────────── */}
|
{/* ── 配音素材 ──────────────────────────────────── */}
|
||||||
{activeTab === "material" && (
|
{activeTab === "material" && (
|
||||||
<div className="xx-voices-tab-content">
|
<MaterialVoiceTab
|
||||||
{/* 骨架屏加载 */}
|
loading={materialLoading}
|
||||||
{materialLoading && (
|
materials={materials as AssetItem[]}
|
||||||
<div className="xx-voice-grid">
|
onOpenUpload={() => setUploadOpen(true)}
|
||||||
{Array.from({ length: 6 }).map((_, i) => (
|
|
||||||
<div key={i} className="vmat-card vmat-card--skeleton">
|
|
||||||
<div className="vmat-thumb" />
|
|
||||||
<div className="vmat-info">
|
|
||||||
<div className="vmat-skeleton-line vmat-skeleton-title" />
|
|
||||||
<div className="vmat-skeleton-line" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 卡片列表 */}
|
|
||||||
{!materialLoading && materials.length > 0 && (
|
|
||||||
<div className="xx-voice-grid">
|
|
||||||
{materials.map((asset: AssetItem) => {
|
|
||||||
const duration = (asset.metadata?.duration as number) || 0
|
|
||||||
const minutes = Math.floor(duration / 60)
|
|
||||||
const seconds = Math.floor(duration % 60)
|
|
||||||
return (
|
|
||||||
<div key={asset.id} className="vmat-card">
|
|
||||||
<div className="vmat-thumb">
|
|
||||||
<AudioOutlined className="vmat-thumb-icon" />
|
|
||||||
<span className="vmat-duration">
|
|
||||||
{minutes}:{seconds.toString().padStart(2, "0")}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="vmat-info">
|
|
||||||
<div className="vmat-name" title={asset.name}>
|
|
||||||
{asset.name}
|
|
||||||
</div>
|
|
||||||
<div className="vmat-meta">
|
|
||||||
<span>
|
|
||||||
{asset.file_size
|
|
||||||
? `${(asset.file_size / 1024 / 1024).toFixed(1)} MB`
|
|
||||||
: "--"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 空状态 */}
|
|
||||||
{!materialLoading && materials.length === 0 && (
|
|
||||||
<div className="xx-voices-empty">
|
|
||||||
<div className="xx-voices-empty-icon">
|
|
||||||
<SoundOutlined />
|
|
||||||
</div>
|
|
||||||
<h3>暂无配音素材</h3>
|
|
||||||
<p>上传您的音频素材,用于视频配音</p>
|
|
||||||
<Button buttonType="primary" buttonSize="md" onClick={() => setUploadOpen(true)}>
|
|
||||||
<UploadOutlined /> 上传音频
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ── 克隆音色弹窗 ──────────────────────────────── */}
|
|
||||||
<CloneModal
|
|
||||||
open={cloneModalOpen}
|
|
||||||
onClose={() => setCloneModalOpen(false)}
|
|
||||||
onSuccess={handleCloneSuccess}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* ── 详情弹窗 ──────────────────────────────────── */}
|
|
||||||
{detailVoice && (
|
|
||||||
<CloneDetailModal
|
|
||||||
voice={detailVoice}
|
|
||||||
onClose={handleCloseDetail}
|
|
||||||
onDelete={() => handleCloneDelete(detailVoice)}
|
|
||||||
onRetry={() => handleCloneRetry(detailVoice)}
|
|
||||||
onUse={() => handleCloneUse(detailVoice)}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── 上传音频弹窗 ──────────────────────────────── */}
|
{/* ── 弹窗集合 ──────────────────────────────────── */}
|
||||||
<UploadVoiceModal
|
<VoiceModals
|
||||||
open={uploadOpen}
|
cloneModalOpen={cloneModalOpen}
|
||||||
|
onCloneClose={() => setCloneModalOpen(false)}
|
||||||
|
onCloneSuccess={handleCloneSuccess}
|
||||||
|
detailVoice={detailVoice}
|
||||||
|
onDetailClose={handleCloseDetail}
|
||||||
|
onDetailDelete={() => detailVoice && handleCloneDelete(detailVoice)}
|
||||||
|
onDetailRetry={() => detailVoice && handleCloneRetry(detailVoice)}
|
||||||
|
onDetailUse={() => detailVoice && handleCloneUse(detailVoice)}
|
||||||
|
uploadOpen={uploadOpen}
|
||||||
uploadFile={uploadFile}
|
uploadFile={uploadFile}
|
||||||
uploadName={uploadName}
|
uploadName={uploadName}
|
||||||
uploadGender={uploadGender}
|
uploadGender={uploadGender}
|
||||||
uploadDesc={uploadDesc}
|
uploadDesc={uploadDesc}
|
||||||
uploadProgress={uploadProgress}
|
uploadProgress={uploadProgress}
|
||||||
onClose={handleUploadClose}
|
onUploadClose={handleUploadClose}
|
||||||
onFileSelect={handleFileSelect}
|
onFileSelect={handleFileSelect}
|
||||||
onFileRemove={handleFileRemove}
|
onFileRemove={handleFileRemove}
|
||||||
onNameChange={setUploadName}
|
onNameChange={setUploadName}
|
||||||
onGenderChange={setUploadGender}
|
onGenderChange={setUploadGender}
|
||||||
onDescChange={setUploadDesc}
|
onDescChange={setUploadDesc}
|
||||||
onUpload={handleUpload}
|
onUpload={handleUpload}
|
||||||
/>
|
ttsOpen={ttsOpen}
|
||||||
|
|
||||||
{/* ── AI 配音弹窗 ───────────────────────────────── */}
|
|
||||||
<TtsModal
|
|
||||||
open={ttsOpen}
|
|
||||||
ttsText={ttsText}
|
ttsText={ttsText}
|
||||||
ttsVoiceId={ttsVoiceId}
|
ttsVoiceId={ttsVoiceId}
|
||||||
ttsSpeed={ttsSpeed}
|
ttsSpeed={ttsSpeed}
|
||||||
@@ -455,24 +270,16 @@ const VoiceLibrary: React.FC = () => {
|
|||||||
ttsAudioUrl={ttsAudioUrl}
|
ttsAudioUrl={ttsAudioUrl}
|
||||||
ttsError={ttsError}
|
ttsError={ttsError}
|
||||||
presetVoices={presetVoices}
|
presetVoices={presetVoices}
|
||||||
onClose={handleTtsClose}
|
onTtsClose={handleTtsClose}
|
||||||
onTextChange={setTtsText}
|
onTtsTextChange={setTtsText}
|
||||||
onVoiceChange={setTtsVoiceId}
|
onTtsVoiceChange={setTtsVoiceId}
|
||||||
onSpeedChange={setTtsSpeed}
|
onTtsSpeedChange={setTtsSpeed}
|
||||||
onSynthesize={handleTtsSynthesize}
|
onTtsSynthesize={handleTtsSynthesize}
|
||||||
onSave={handleTtsSave}
|
onTtsSave={handleTtsSave}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* ── Toast 提示 ────────────────────────────────── */}
|
{/* ── Toast 提示 ────────────────────────────────── */}
|
||||||
{toasts.length > 0 && (
|
<VoiceToasts toasts={toasts} />
|
||||||
<div className="vc-toast-container">
|
|
||||||
{toasts.map((t) => (
|
|
||||||
<div key={t.id} className={`vc-toast vc-toast--${t.type}`}>
|
|
||||||
{t.type === "success" ? "✅" : "❌"} {t.message}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+94
@@ -0,0 +1,94 @@
|
|||||||
|
/**
|
||||||
|
* VoiceLibrary 我的克隆 Tab 内容
|
||||||
|
*/
|
||||||
|
import React from "react"
|
||||||
|
import { UserOutlined, PlusOutlined, RobotOutlined } from "@ant-design/icons"
|
||||||
|
import { Button } from "@/components/ui"
|
||||||
|
import CloneVoiceCard from "./CloneVoiceCard"
|
||||||
|
import CloneCardSkeleton from "./CloneCardSkeleton"
|
||||||
|
import type { ClonedVoiceDisplay } from "../types"
|
||||||
|
|
||||||
|
export interface ClonedVoiceTabProps {
|
||||||
|
loading: boolean
|
||||||
|
voices: ClonedVoiceDisplay[]
|
||||||
|
playingId: string | null
|
||||||
|
currentTime: number
|
||||||
|
onPlay: (voiceId: string, duration: number) => void
|
||||||
|
onPause: () => void
|
||||||
|
onUse: (voice: ClonedVoiceDisplay) => void
|
||||||
|
onDelete: (voice: ClonedVoiceDisplay) => void
|
||||||
|
onRetry: (voice: ClonedVoiceDisplay) => void
|
||||||
|
onShowDetail: (voice: ClonedVoiceDisplay) => void
|
||||||
|
onOpenClone: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ClonedVoiceTab: React.FC<ClonedVoiceTabProps> = ({
|
||||||
|
loading,
|
||||||
|
voices,
|
||||||
|
playingId,
|
||||||
|
currentTime,
|
||||||
|
onPlay,
|
||||||
|
onPause,
|
||||||
|
onUse,
|
||||||
|
onDelete,
|
||||||
|
onRetry,
|
||||||
|
onShowDetail,
|
||||||
|
onOpenClone,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className="xx-voices-tab-content">
|
||||||
|
{/* 骨架屏加载 */}
|
||||||
|
{loading && (
|
||||||
|
<div className="xx-voice-grid">
|
||||||
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
<CloneCardSkeleton key={i} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 卡片列表 */}
|
||||||
|
{!loading && voices.length > 0 && (
|
||||||
|
<div className="xx-voice-grid">
|
||||||
|
{voices.map((voice) => (
|
||||||
|
<CloneVoiceCard
|
||||||
|
key={voice.id}
|
||||||
|
voice={voice}
|
||||||
|
isPlaying={playingId === voice.id}
|
||||||
|
currentTime={playingId === voice.id ? currentTime : 0}
|
||||||
|
onPlay={() => onPlay(voice.id, voice.duration)}
|
||||||
|
onPause={onPause}
|
||||||
|
onUse={() => onUse(voice)}
|
||||||
|
onDelete={() => onDelete(voice)}
|
||||||
|
onRetry={() => onRetry(voice)}
|
||||||
|
onShowDetail={() => onShowDetail(voice)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 空状态 */}
|
||||||
|
{!loading && voices.length === 0 && (
|
||||||
|
<div className="xx-voices-empty">
|
||||||
|
<div className="xx-voices-empty-icon">
|
||||||
|
<UserOutlined />
|
||||||
|
</div>
|
||||||
|
<h3>暂无克隆音色</h3>
|
||||||
|
<p>上传音频素材即可克隆专属音色</p>
|
||||||
|
<Button buttonType="primary" buttonSize="md" onClick={onOpenClone}>
|
||||||
|
<PlusOutlined /> 去克隆音色
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 处理中提示 */}
|
||||||
|
{!loading && voices.some((v) => v.status === "processing") && (
|
||||||
|
<div className="xx-clone-processing-hint">
|
||||||
|
<RobotOutlined />
|
||||||
|
<span>部分音色正在克隆处理中,完成后将自动出现在列表中。</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ClonedVoiceTab
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
/**
|
||||||
|
* VoiceLibrary 配音素材 Tab 内容
|
||||||
|
*/
|
||||||
|
import React from "react"
|
||||||
|
import { SoundOutlined, UploadOutlined, AudioOutlined } from "@ant-design/icons"
|
||||||
|
import { Button } from "@/components/ui"
|
||||||
|
import type { AssetItem } from "@/api/assets"
|
||||||
|
|
||||||
|
export interface MaterialVoiceTabProps {
|
||||||
|
loading: boolean
|
||||||
|
materials: AssetItem[]
|
||||||
|
onOpenUpload: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MaterialVoiceTab: React.FC<MaterialVoiceTabProps> = ({
|
||||||
|
loading,
|
||||||
|
materials,
|
||||||
|
onOpenUpload,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className="xx-voices-tab-content">
|
||||||
|
{/* 骨架屏加载 */}
|
||||||
|
{loading && (
|
||||||
|
<div className="xx-voice-grid">
|
||||||
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
<div key={i} className="vmat-card vmat-card--skeleton">
|
||||||
|
<div className="vmat-thumb" />
|
||||||
|
<div className="vmat-info">
|
||||||
|
<div className="vmat-skeleton-line vmat-skeleton-title" />
|
||||||
|
<div className="vmat-skeleton-line" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 卡片列表 */}
|
||||||
|
{!loading && materials.length > 0 && (
|
||||||
|
<div className="xx-voice-grid">
|
||||||
|
{materials.map((asset: AssetItem) => {
|
||||||
|
const duration = (asset.metadata?.duration as number) || 0
|
||||||
|
const minutes = Math.floor(duration / 60)
|
||||||
|
const seconds = Math.floor(duration % 60)
|
||||||
|
return (
|
||||||
|
<div key={asset.id} className="vmat-card">
|
||||||
|
<div className="vmat-thumb">
|
||||||
|
<AudioOutlined className="vmat-thumb-icon" />
|
||||||
|
<span className="vmat-duration">
|
||||||
|
{minutes}:{seconds.toString().padStart(2, "0")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="vmat-info">
|
||||||
|
<div className="vmat-name" title={asset.name}>
|
||||||
|
{asset.name}
|
||||||
|
</div>
|
||||||
|
<div className="vmat-meta">
|
||||||
|
<span>
|
||||||
|
{asset.file_size ? `${(asset.file_size / 1024 / 1024).toFixed(1)} MB` : "--"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 空状态 */}
|
||||||
|
{!loading && materials.length === 0 && (
|
||||||
|
<div className="xx-voices-empty">
|
||||||
|
<div className="xx-voices-empty-icon">
|
||||||
|
<SoundOutlined />
|
||||||
|
</div>
|
||||||
|
<h3>暂无配音素材</h3>
|
||||||
|
<p>上传您的音频素材,用于视频配音</p>
|
||||||
|
<Button buttonType="primary" buttonSize="md" onClick={onOpenUpload}>
|
||||||
|
<UploadOutlined /> 上传音频
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MaterialVoiceTab
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
/**
|
||||||
|
* VoiceLibrary 预置音色 Tab 内容
|
||||||
|
*/
|
||||||
|
import React from "react"
|
||||||
|
import { SoundOutlined } from "@ant-design/icons"
|
||||||
|
import { Button } from "@/components/ui"
|
||||||
|
import VoiceFilterBar from "./VoiceFilterBar"
|
||||||
|
import VoiceCard from "./VoiceCard"
|
||||||
|
import { genderLabel, languageLabel } from "../utils/format"
|
||||||
|
import type { PresetVoiceDisplay } from "../types"
|
||||||
|
|
||||||
|
export interface PresetVoiceTabProps {
|
||||||
|
searchText: string
|
||||||
|
filterGender: string
|
||||||
|
filterLang: string
|
||||||
|
onSearchChange: (text: string) => void
|
||||||
|
onGenderChange: (gender: string) => void
|
||||||
|
onLangChange: (lang: string) => void
|
||||||
|
loading: boolean
|
||||||
|
voices: PresetVoiceDisplay[]
|
||||||
|
playingId: string | null
|
||||||
|
currentTime: number
|
||||||
|
onPlay: (id: string, duration: number) => void
|
||||||
|
onPause: () => void
|
||||||
|
onSeek: (id: string, time: number, duration: number) => void
|
||||||
|
onClearFilters: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PresetVoiceTab: React.FC<PresetVoiceTabProps> = ({
|
||||||
|
searchText,
|
||||||
|
filterGender,
|
||||||
|
filterLang,
|
||||||
|
onSearchChange,
|
||||||
|
onGenderChange,
|
||||||
|
onLangChange,
|
||||||
|
loading,
|
||||||
|
voices,
|
||||||
|
playingId,
|
||||||
|
currentTime,
|
||||||
|
onPlay,
|
||||||
|
onPause,
|
||||||
|
onSeek,
|
||||||
|
onClearFilters,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className="xx-voices-tab-content">
|
||||||
|
<VoiceFilterBar
|
||||||
|
searchText={searchText}
|
||||||
|
filterGender={filterGender}
|
||||||
|
filterLang={filterLang}
|
||||||
|
onSearchChange={onSearchChange}
|
||||||
|
onGenderChange={onGenderChange}
|
||||||
|
onLangChange={onLangChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{loading && (
|
||||||
|
<div className="xx-voices-empty">
|
||||||
|
<div className="xx-voices-empty-icon">
|
||||||
|
<SoundOutlined />
|
||||||
|
</div>
|
||||||
|
<p>加载预置音色中...</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && voices.length > 0 && (
|
||||||
|
<div className="xx-voice-grid">
|
||||||
|
{voices.map((voice) => (
|
||||||
|
<VoiceCard
|
||||||
|
key={voice.id}
|
||||||
|
id={voice.id}
|
||||||
|
name={voice.name}
|
||||||
|
subtitle={`${genderLabel(voice.gender)} · ${languageLabel(voice.language)} · ${voice.description}`}
|
||||||
|
tags={voice.tags}
|
||||||
|
duration={voice.duration}
|
||||||
|
gender={voice.gender}
|
||||||
|
isPlaying={playingId === voice.id}
|
||||||
|
isSelected={false}
|
||||||
|
currentTime={playingId === voice.id ? currentTime : 0}
|
||||||
|
starred={voice.starred}
|
||||||
|
onPlay={() => onPlay(voice.id, voice.duration)}
|
||||||
|
onPause={onPause}
|
||||||
|
onSeek={(time) => onSeek(voice.id, time, voice.duration)}
|
||||||
|
onToggleStar={() => {}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && voices.length === 0 && (
|
||||||
|
<div className="xx-voices-empty">
|
||||||
|
<div className="xx-voices-empty-icon">
|
||||||
|
<SoundOutlined />
|
||||||
|
</div>
|
||||||
|
<p>未找到匹配的音色</p>
|
||||||
|
<Button buttonType="ghost" buttonSize="sm" onClick={onClearFilters}>
|
||||||
|
清除筛选条件
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PresetVoiceTab
|
||||||
@@ -1,27 +1,12 @@
|
|||||||
import React from "react"
|
import React from "react"
|
||||||
import { RobotOutlined } from "@ant-design/icons"
|
import { Modal } from "antd"
|
||||||
import { Modal, message } from "antd"
|
import { type TtsModalProps, type TtsStatus } from "./tts-modal/types"
|
||||||
import { type PresetVoiceDisplay } from "@/pages/voices/types"
|
import TextInputSection from "./tts-modal/TextInputSection"
|
||||||
import { genderLabel } from "@/pages/voices/utils/format"
|
import VoiceSelector from "./tts-modal/VoiceSelector"
|
||||||
|
import SpeedControl from "./tts-modal/SpeedControl"
|
||||||
export type TtsStatus = "idle" | "synthesizing" | "done" | "error"
|
import SynthesizeButton from "./tts-modal/SynthesizeButton"
|
||||||
|
import ErrorAlert from "./tts-modal/ErrorAlert"
|
||||||
export interface TtsModalProps {
|
import ResultPanel from "./tts-modal/ResultPanel"
|
||||||
open: boolean
|
|
||||||
ttsText: string
|
|
||||||
ttsVoiceId: string
|
|
||||||
ttsSpeed: number
|
|
||||||
ttsStatus: TtsStatus
|
|
||||||
ttsAudioUrl: string | null
|
|
||||||
ttsError: string | null
|
|
||||||
presetVoices: PresetVoiceDisplay[]
|
|
||||||
onClose: () => void
|
|
||||||
onTextChange: (text: string) => void
|
|
||||||
onVoiceChange: (voiceId: string) => void
|
|
||||||
onSpeedChange: (speed: number) => void
|
|
||||||
onSynthesize: () => void
|
|
||||||
onSave: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
/** AI 配音弹窗 */
|
/** AI 配音弹窗 */
|
||||||
const TtsModal: React.FC<TtsModalProps> = ({
|
const TtsModal: React.FC<TtsModalProps> = ({
|
||||||
@@ -50,205 +35,13 @@ const TtsModal: React.FC<TtsModalProps> = ({
|
|||||||
padding: "8px 0",
|
padding: "8px 0",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* 文本输入 */}
|
<TextInputSection value={ttsText} onChange={onTextChange} />
|
||||||
<div>
|
<VoiceSelector value={ttsVoiceId} onChange={onVoiceChange} presetVoices={presetVoices} />
|
||||||
<div
|
<SpeedControl speed={ttsSpeed} onChange={onSpeedChange} />
|
||||||
style={{
|
<SynthesizeButton status={ttsStatus} text={ttsText} onClick={onSynthesize} />
|
||||||
fontSize: 13,
|
{ttsError && <ErrorAlert error={ttsError} />}
|
||||||
color: "var(--text-secondary)",
|
|
||||||
marginBottom: 6,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
输入文本
|
|
||||||
</div>
|
|
||||||
<textarea
|
|
||||||
value={ttsText}
|
|
||||||
onChange={(e) => onTextChange(e.target.value)}
|
|
||||||
placeholder="输入要配音的文本内容..."
|
|
||||||
maxLength={2000}
|
|
||||||
rows={4}
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
padding: "10px 12px",
|
|
||||||
border: "1px solid var(--border-color)",
|
|
||||||
borderRadius: 8,
|
|
||||||
fontSize: 13,
|
|
||||||
background: "var(--bg-primary)",
|
|
||||||
color: "var(--text-primary)",
|
|
||||||
outline: "none",
|
|
||||||
resize: "vertical",
|
|
||||||
fontFamily: "inherit",
|
|
||||||
lineHeight: 1.6,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: 11,
|
|
||||||
color: "var(--text-tertiary)",
|
|
||||||
textAlign: "right",
|
|
||||||
marginTop: 4,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{ttsText.length}/2000
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 音色选择 */}
|
|
||||||
<div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: 13,
|
|
||||||
color: "var(--text-secondary)",
|
|
||||||
marginBottom: 6,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
选择音色
|
|
||||||
</div>
|
|
||||||
<select
|
|
||||||
value={ttsVoiceId}
|
|
||||||
onChange={(e) => onVoiceChange(e.target.value)}
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
padding: "8px 12px",
|
|
||||||
border: "1px solid var(--border-color)",
|
|
||||||
borderRadius: 8,
|
|
||||||
fontSize: 13,
|
|
||||||
background: "var(--bg-primary)",
|
|
||||||
color: "var(--text-primary)",
|
|
||||||
outline: "none",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<option value="">默认音色</option>
|
|
||||||
{presetVoices.map((v) => (
|
|
||||||
<option key={v.id} value={v.id}>
|
|
||||||
{v.name} — {genderLabel(v.gender)}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 语速 */}
|
|
||||||
<div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: 13,
|
|
||||||
color: "var(--text-secondary)",
|
|
||||||
marginBottom: 6,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
语速:{ttsSpeed.toFixed(1)}x
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min={0.5}
|
|
||||||
max={2.0}
|
|
||||||
step={0.1}
|
|
||||||
value={ttsSpeed}
|
|
||||||
onChange={(e) => onSpeedChange(parseFloat(e.target.value))}
|
|
||||||
style={{ width: "100%", accentColor: "var(--primary-color)" }}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
fontSize: 11,
|
|
||||||
color: "var(--text-tertiary)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span>0.5x</span>
|
|
||||||
<span>1.0x</span>
|
|
||||||
<span>2.0x</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 合成按钮 */}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
if (!ttsText.trim()) {
|
|
||||||
message.warning("请输入要合成的文本")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
onSynthesize()
|
|
||||||
}}
|
|
||||||
disabled={ttsStatus === "synthesizing" || !ttsText.trim()}
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
padding: "10px 0",
|
|
||||||
borderRadius: 8,
|
|
||||||
border: "none",
|
|
||||||
background:
|
|
||||||
ttsStatus === "synthesizing" || !ttsText.trim()
|
|
||||||
? "var(--text-tertiary)"
|
|
||||||
: "var(--primary-color)",
|
|
||||||
color: "#fff",
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: 600,
|
|
||||||
cursor: ttsStatus === "synthesizing" || !ttsText.trim() ? "not-allowed" : "pointer",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
gap: 8,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<RobotOutlined />
|
|
||||||
{ttsStatus === "synthesizing" ? "合成中..." : "开始合成"}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* 错误提示 */}
|
|
||||||
{ttsError && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
padding: "10px 12px",
|
|
||||||
background: "var(--error-soft, #fff2f0)",
|
|
||||||
borderRadius: 8,
|
|
||||||
color: "var(--error-color, #ff4d4f)",
|
|
||||||
fontSize: 13,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{ttsError}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 合成结果 */}
|
|
||||||
{ttsStatus === "done" && ttsAudioUrl && (
|
{ttsStatus === "done" && ttsAudioUrl && (
|
||||||
<div
|
<ResultPanel audioUrl={ttsAudioUrl} onSave={onSave} />
|
||||||
style={{
|
|
||||||
padding: 12,
|
|
||||||
background: "var(--bg-secondary)",
|
|
||||||
borderRadius: 8,
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
gap: 10,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: 500,
|
|
||||||
color: "var(--success-color, #52c41a)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
✅ 合成完成
|
|
||||||
</div>
|
|
||||||
<audio controls src={ttsAudioUrl} style={{ width: "100%" }} />
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onSave}
|
|
||||||
style={{
|
|
||||||
padding: "8px 0",
|
|
||||||
borderRadius: 8,
|
|
||||||
border: "1px solid var(--primary-color)",
|
|
||||||
background: "var(--primary-soft)",
|
|
||||||
color: "var(--primary-color)",
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: 600,
|
|
||||||
cursor: "pointer",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
保存到配音库
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
@@ -256,3 +49,5 @@ const TtsModal: React.FC<TtsModalProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default TtsModal
|
export default TtsModal
|
||||||
|
|
||||||
|
export type { TtsModalProps, TtsStatus }
|
||||||
|
|||||||
@@ -1,24 +1,13 @@
|
|||||||
import React from "react"
|
import React from "react"
|
||||||
import { UploadOutlined, SoundOutlined } from "@ant-design/icons"
|
import { Modal } from "antd"
|
||||||
import { Modal, Upload, message } from "antd"
|
import {
|
||||||
import { type VoiceGender } from "@/pages/voices/types"
|
FileUploadZone,
|
||||||
import { formatFileSize } from "@/pages/voices/utils/format"
|
FileInfoCard,
|
||||||
|
UploadProgress,
|
||||||
export interface UploadVoiceModalProps {
|
FormFields,
|
||||||
open: boolean
|
ActionButtons,
|
||||||
uploadFile: File | null
|
} from "./upload-voice-modal"
|
||||||
uploadName: string
|
import type { UploadVoiceModalProps } from "./upload-voice-modal"
|
||||||
uploadGender: VoiceGender
|
|
||||||
uploadDesc: string
|
|
||||||
uploadProgress: number | null
|
|
||||||
onClose: () => void
|
|
||||||
onFileSelect: (file: File) => void
|
|
||||||
onFileRemove: () => void
|
|
||||||
onNameChange: (name: string) => void
|
|
||||||
onGenderChange: (gender: VoiceGender) => void
|
|
||||||
onDescChange: (desc: string) => void
|
|
||||||
onUpload: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 上传音频弹窗 */
|
/** 上传音频弹窗 */
|
||||||
const UploadVoiceModal: React.FC<UploadVoiceModalProps> = ({
|
const UploadVoiceModal: React.FC<UploadVoiceModalProps> = ({
|
||||||
@@ -36,17 +25,20 @@ const UploadVoiceModal: React.FC<UploadVoiceModalProps> = ({
|
|||||||
onDescChange,
|
onDescChange,
|
||||||
onUpload,
|
onUpload,
|
||||||
}) => {
|
}) => {
|
||||||
|
const uploading = uploadProgress !== null
|
||||||
|
const canUpload = !!uploadFile && !!uploadName.trim()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
title="上传音频"
|
title="上传音频"
|
||||||
open={open}
|
open={open}
|
||||||
onCancel={() => {
|
onCancel={() => {
|
||||||
if (uploadProgress !== null) return // 上传中不可关闭
|
if (uploading) return // 上传中不可关闭
|
||||||
onClose()
|
onClose()
|
||||||
}}
|
}}
|
||||||
footer={null}
|
footer={null}
|
||||||
width={520}
|
width={520}
|
||||||
maskClosable={uploadProgress === null}
|
maskClosable={!uploading}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -57,270 +49,36 @@ const UploadVoiceModal: React.FC<UploadVoiceModalProps> = ({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* 拖拽上传区 */}
|
{/* 拖拽上传区 */}
|
||||||
<Upload.Dragger
|
<FileUploadZone
|
||||||
accept="audio/*"
|
disabled={uploading}
|
||||||
maxCount={1}
|
onFileSelect={onFileSelect}
|
||||||
beforeUpload={(file) => {
|
onFileRemove={onFileRemove}
|
||||||
onFileSelect(file)
|
/>
|
||||||
return false
|
|
||||||
}}
|
|
||||||
onRemove={() => {
|
|
||||||
onFileRemove()
|
|
||||||
}}
|
|
||||||
showUploadList={false}
|
|
||||||
disabled={uploadProgress !== null}
|
|
||||||
>
|
|
||||||
<p
|
|
||||||
style={{
|
|
||||||
fontSize: 32,
|
|
||||||
color: "var(--primary-color)",
|
|
||||||
marginBottom: 8,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<UploadOutlined />
|
|
||||||
</p>
|
|
||||||
<p style={{ fontSize: 14, fontWeight: 500, margin: "0 0 4px" }}>
|
|
||||||
点击或拖拽音频文件到此处
|
|
||||||
</p>
|
|
||||||
<p
|
|
||||||
style={{
|
|
||||||
fontSize: 12,
|
|
||||||
color: "var(--text-secondary)",
|
|
||||||
margin: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
支持 MP3、WAV、AAC、FLAC 等格式,最大 200MB
|
|
||||||
</p>
|
|
||||||
</Upload.Dragger>
|
|
||||||
|
|
||||||
{/* 已选文件信息 */}
|
{/* 已选文件信息 */}
|
||||||
{uploadFile && (
|
{uploadFile && <FileInfoCard file={uploadFile} />}
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
padding: "10px 12px",
|
|
||||||
background: "var(--bg-secondary)",
|
|
||||||
borderRadius: 8,
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 10,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SoundOutlined style={{ fontSize: 18, color: "var(--primary-color)" }} />
|
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: 500,
|
|
||||||
overflow: "hidden",
|
|
||||||
textOverflow: "ellipsis",
|
|
||||||
whiteSpace: "nowrap",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{uploadFile.name}
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: 11, color: "var(--text-secondary)" }}>
|
|
||||||
{formatFileSize(uploadFile.size)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 上传进度 */}
|
{/* 上传进度 */}
|
||||||
{uploadProgress !== null && (
|
{uploadProgress !== null && <UploadProgress progress={uploadProgress} />}
|
||||||
<div style={{ textAlign: "center", padding: "8px 0" }}>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: 22,
|
|
||||||
fontWeight: 700,
|
|
||||||
color: "var(--primary-color)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{uploadProgress}%
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: 12, color: "var(--text-secondary)" }}>
|
|
||||||
{uploadProgress < 100 ? "上传中..." : "处理中..."}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
height: 4,
|
|
||||||
background: "var(--bg-tertiary)",
|
|
||||||
borderRadius: 2,
|
|
||||||
marginTop: 8,
|
|
||||||
overflow: "hidden",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
height: "100%",
|
|
||||||
width: `${uploadProgress}%`,
|
|
||||||
background: "var(--primary-color)",
|
|
||||||
borderRadius: 2,
|
|
||||||
transition: "width 0.3s ease",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 名称 */}
|
{/* 表单字段 */}
|
||||||
<div>
|
<FormFields
|
||||||
<div
|
name={uploadName}
|
||||||
style={{
|
gender={uploadGender}
|
||||||
fontSize: 13,
|
desc={uploadDesc}
|
||||||
color: "var(--text-secondary)",
|
disabled={uploading}
|
||||||
marginBottom: 6,
|
onNameChange={onNameChange}
|
||||||
}}
|
onGenderChange={onGenderChange}
|
||||||
>
|
onDescChange={onDescChange}
|
||||||
素材名称
|
/>
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
value={uploadName}
|
|
||||||
onChange={(e) => onNameChange(e.target.value)}
|
|
||||||
placeholder="输入素材名称"
|
|
||||||
maxLength={100}
|
|
||||||
disabled={uploadProgress !== null}
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
padding: "8px 12px",
|
|
||||||
border: "1px solid var(--border-color)",
|
|
||||||
borderRadius: 8,
|
|
||||||
fontSize: 13,
|
|
||||||
background: "var(--bg-primary)",
|
|
||||||
color: "var(--text-primary)",
|
|
||||||
outline: "none",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 性别选择 */}
|
|
||||||
<div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: 13,
|
|
||||||
color: "var(--text-secondary)",
|
|
||||||
marginBottom: 6,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
音色性别
|
|
||||||
</div>
|
|
||||||
<div style={{ display: "flex", gap: 8 }}>
|
|
||||||
{(["female", "male", "child"] as VoiceGender[]).map((g) => (
|
|
||||||
<button
|
|
||||||
key={g}
|
|
||||||
type="button"
|
|
||||||
onClick={() => onGenderChange(g)}
|
|
||||||
disabled={uploadProgress !== null}
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
padding: "6px 0",
|
|
||||||
borderRadius: 8,
|
|
||||||
border: `1px solid ${uploadGender === g ? "var(--primary-color)" : "var(--border-color)"}`,
|
|
||||||
background: uploadGender === g ? "var(--primary-soft)" : "transparent",
|
|
||||||
color: uploadGender === g ? "var(--primary-color)" : "var(--text-secondary)",
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: uploadGender === g ? 600 : 400,
|
|
||||||
cursor: uploadProgress !== null ? "not-allowed" : "pointer",
|
|
||||||
transition: "all 0.2s",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{g === "female" ? "女声" : g === "male" ? "男声" : "童声"}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 描述 */}
|
|
||||||
<div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: 13,
|
|
||||||
color: "var(--text-secondary)",
|
|
||||||
marginBottom: 6,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
音色描述(可选)
|
|
||||||
</div>
|
|
||||||
<textarea
|
|
||||||
value={uploadDesc}
|
|
||||||
onChange={(e) => onDescChange(e.target.value)}
|
|
||||||
placeholder="描述这个音色的特点..."
|
|
||||||
maxLength={500}
|
|
||||||
rows={2}
|
|
||||||
disabled={uploadProgress !== null}
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
padding: "8px 12px",
|
|
||||||
border: "1px solid var(--border-color)",
|
|
||||||
borderRadius: 8,
|
|
||||||
fontSize: 13,
|
|
||||||
background: "var(--bg-primary)",
|
|
||||||
color: "var(--text-primary)",
|
|
||||||
outline: "none",
|
|
||||||
resize: "vertical",
|
|
||||||
fontFamily: "inherit",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 操作按钮 */}
|
{/* 操作按钮 */}
|
||||||
<div
|
<ActionButtons
|
||||||
style={{
|
uploading={uploading}
|
||||||
display: "flex",
|
canUpload={canUpload}
|
||||||
justifyContent: "flex-end",
|
onCancel={onClose}
|
||||||
gap: 10,
|
onUpload={onUpload}
|
||||||
paddingTop: 4,
|
/>
|
||||||
}}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClose}
|
|
||||||
disabled={uploadProgress !== null}
|
|
||||||
style={{
|
|
||||||
padding: "8px 20px",
|
|
||||||
borderRadius: 8,
|
|
||||||
border: "1px solid var(--border-color)",
|
|
||||||
background: "transparent",
|
|
||||||
fontSize: 13,
|
|
||||||
cursor: uploadProgress !== null ? "not-allowed" : "pointer",
|
|
||||||
color: "var(--text-secondary)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
取消
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
if (!uploadFile) {
|
|
||||||
message.warning("请先选择音频文件")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!uploadName.trim()) {
|
|
||||||
message.warning("请输入素材名称")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
onUpload()
|
|
||||||
}}
|
|
||||||
disabled={!uploadFile || !uploadName.trim() || uploadProgress !== null}
|
|
||||||
style={{
|
|
||||||
padding: "8px 20px",
|
|
||||||
borderRadius: 8,
|
|
||||||
border: "none",
|
|
||||||
background:
|
|
||||||
!uploadFile || !uploadName.trim() || uploadProgress !== null
|
|
||||||
? "var(--text-tertiary)"
|
|
||||||
: "var(--primary-color)",
|
|
||||||
color: "#fff",
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: 600,
|
|
||||||
cursor:
|
|
||||||
!uploadFile || !uploadName.trim() || uploadProgress !== null
|
|
||||||
? "not-allowed"
|
|
||||||
: "pointer",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{uploadProgress !== null ? "上传中..." : "开始上传"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
/**
|
||||||
|
* VoiceLibrary 弹窗集合
|
||||||
|
*/
|
||||||
|
import React from "react"
|
||||||
|
import type { ClonedVoiceDisplay, PresetVoiceDisplay, VoiceGender } from "../types"
|
||||||
|
import type { VoiceClone } from "@/api/voice-clone"
|
||||||
|
import type { TtsStatus } from "./TtsModal"
|
||||||
|
import CloneModal from "@/components/voice/CloneModal"
|
||||||
|
import CloneDetailModal from "./CloneDetailModal"
|
||||||
|
import UploadVoiceModal from "./UploadVoiceModal"
|
||||||
|
import TtsModal from "./TtsModal"
|
||||||
|
|
||||||
|
export interface VoiceModalsProps {
|
||||||
|
/* 克隆音色弹窗 */
|
||||||
|
cloneModalOpen: boolean
|
||||||
|
onCloneClose: () => void
|
||||||
|
onCloneSuccess: (voice: VoiceClone) => void
|
||||||
|
|
||||||
|
/* 克隆详情弹窗 */
|
||||||
|
detailVoice: ClonedVoiceDisplay | null
|
||||||
|
onDetailClose: () => void
|
||||||
|
onDetailDelete: () => void
|
||||||
|
onDetailRetry: () => void
|
||||||
|
onDetailUse: () => void
|
||||||
|
|
||||||
|
/* 上传音频弹窗 */
|
||||||
|
uploadOpen: boolean
|
||||||
|
uploadFile: File | null
|
||||||
|
uploadName: string
|
||||||
|
uploadGender: VoiceGender
|
||||||
|
uploadDesc: string
|
||||||
|
uploadProgress: number | null
|
||||||
|
onUploadClose: () => void
|
||||||
|
onFileSelect: (file: File) => void
|
||||||
|
onFileRemove: () => void
|
||||||
|
onNameChange: (name: string) => void
|
||||||
|
onGenderChange: (gender: VoiceGender) => void
|
||||||
|
onDescChange: (desc: string) => void
|
||||||
|
onUpload: () => void
|
||||||
|
|
||||||
|
/* TTS 弹窗 */
|
||||||
|
ttsOpen: boolean
|
||||||
|
ttsText: string
|
||||||
|
ttsVoiceId: string
|
||||||
|
ttsSpeed: number
|
||||||
|
ttsStatus: TtsStatus
|
||||||
|
ttsAudioUrl: string | null
|
||||||
|
ttsError: string | null
|
||||||
|
presetVoices: PresetVoiceDisplay[]
|
||||||
|
onTtsClose: () => void
|
||||||
|
onTtsTextChange: (text: string) => void
|
||||||
|
onTtsVoiceChange: (id: string) => void
|
||||||
|
onTtsSpeedChange: (speed: number) => void
|
||||||
|
onTtsSynthesize: () => void
|
||||||
|
onTtsSave: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const VoiceModals: React.FC<VoiceModalsProps> = ({
|
||||||
|
cloneModalOpen,
|
||||||
|
onCloneClose,
|
||||||
|
onCloneSuccess,
|
||||||
|
detailVoice,
|
||||||
|
onDetailClose,
|
||||||
|
onDetailDelete,
|
||||||
|
onDetailRetry,
|
||||||
|
onDetailUse,
|
||||||
|
uploadOpen,
|
||||||
|
uploadFile,
|
||||||
|
uploadName,
|
||||||
|
uploadGender,
|
||||||
|
uploadDesc,
|
||||||
|
uploadProgress,
|
||||||
|
onUploadClose,
|
||||||
|
onFileSelect,
|
||||||
|
onFileRemove,
|
||||||
|
onNameChange,
|
||||||
|
onGenderChange,
|
||||||
|
onDescChange,
|
||||||
|
onUpload,
|
||||||
|
ttsOpen,
|
||||||
|
ttsText,
|
||||||
|
ttsVoiceId,
|
||||||
|
ttsSpeed,
|
||||||
|
ttsStatus,
|
||||||
|
ttsAudioUrl,
|
||||||
|
ttsError,
|
||||||
|
presetVoices,
|
||||||
|
onTtsClose,
|
||||||
|
onTtsTextChange,
|
||||||
|
onTtsVoiceChange,
|
||||||
|
onTtsSpeedChange,
|
||||||
|
onTtsSynthesize,
|
||||||
|
onTtsSave,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* 克隆音色弹窗 */}
|
||||||
|
<CloneModal open={cloneModalOpen} onClose={onCloneClose} onSuccess={onCloneSuccess} />
|
||||||
|
|
||||||
|
{/* 详情弹窗 */}
|
||||||
|
{detailVoice && (
|
||||||
|
<CloneDetailModal
|
||||||
|
voice={detailVoice}
|
||||||
|
onClose={onDetailClose}
|
||||||
|
onDelete={onDetailDelete}
|
||||||
|
onRetry={onDetailRetry}
|
||||||
|
onUse={onDetailUse}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 上传音频弹窗 */}
|
||||||
|
<UploadVoiceModal
|
||||||
|
open={uploadOpen}
|
||||||
|
uploadFile={uploadFile}
|
||||||
|
uploadName={uploadName}
|
||||||
|
uploadGender={uploadGender}
|
||||||
|
uploadDesc={uploadDesc}
|
||||||
|
uploadProgress={uploadProgress}
|
||||||
|
onClose={onUploadClose}
|
||||||
|
onFileSelect={onFileSelect}
|
||||||
|
onFileRemove={onFileRemove}
|
||||||
|
onNameChange={onNameChange}
|
||||||
|
onGenderChange={onGenderChange}
|
||||||
|
onDescChange={onDescChange}
|
||||||
|
onUpload={onUpload}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* AI 配音弹窗 */}
|
||||||
|
<TtsModal
|
||||||
|
open={ttsOpen}
|
||||||
|
ttsText={ttsText}
|
||||||
|
ttsVoiceId={ttsVoiceId}
|
||||||
|
ttsSpeed={ttsSpeed}
|
||||||
|
ttsStatus={ttsStatus}
|
||||||
|
ttsAudioUrl={ttsAudioUrl}
|
||||||
|
ttsError={ttsError}
|
||||||
|
presetVoices={presetVoices}
|
||||||
|
onClose={onTtsClose}
|
||||||
|
onTextChange={onTtsTextChange}
|
||||||
|
onVoiceChange={onTtsVoiceChange}
|
||||||
|
onSpeedChange={onTtsSpeedChange}
|
||||||
|
onSynthesize={onTtsSynthesize}
|
||||||
|
onSave={onTtsSave}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default VoiceModals
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user