Compare commits
150 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 28a2322f73 | |||
| 0f27bfa999 | |||
| f15bc2f2a2 | |||
| 2273fb329f | |||
| 4bd3da73e3 | |||
| 7674a04a33 | |||
| 9d97e8aacc | |||
| 89639a6d3e | |||
| c4144d9e3f | |||
| de201436ea | |||
| 77a49e3365 | |||
| d7e362a637 | |||
| 028c6613ce | |||
| 696cdda87b | |||
| a85167a529 | |||
| e0b95e69d4 | |||
| 63d4048354 | |||
| e811516c6e | |||
| f9a106f36b | |||
| e902fbd65e | |||
| e8352227af | |||
| ef7596739c | |||
| c2329f3f98 | |||
| 0a0914322c | |||
| 042cae7a61 | |||
| be885eb8f0 | |||
| fd61800be9 | |||
| 183681c07d | |||
| 8d906bac72 | |||
| 8f925e6c65 | |||
| 9732cc51ee | |||
| 815b3758a7 | |||
| eb73eeab69 | |||
| 2325ffbc57 | |||
| faeed6f014 | |||
| 6dc388a794 | |||
| 7ca5b3732f | |||
| 7cdf56a1ac | |||
| fac825b60b | |||
| 409efe141a | |||
| 6e676a07f7 | |||
| e629cf0e69 | |||
| dd17312181 | |||
| 05fbf64697 | |||
| 096dc66e2c | |||
| 1230eaa24b | |||
| 0d7effe88f | |||
| 76fdab4f63 | |||
| 4c31026f81 | |||
| 44ee898aa0 | |||
| 049dd028c0 | |||
| e64e1c2938 | |||
| 93e59df866 | |||
| 577f744912 | |||
| d727220562 | |||
| 0ce9a22b91 | |||
| 28173f1713 | |||
| 23edd869c6 | |||
| a863324942 | |||
| 3859ce1750 | |||
| ba35e3f518 | |||
| ec231ac88f | |||
| ccfaf9aa7e | |||
| fe97f016b2 | |||
| 8a0b9b8878 | |||
| 15f12ead3f | |||
| cfabbd3d61 | |||
| 19ca924087 | |||
| 0830b3ac45 | |||
| cb7daa0674 | |||
| 33fd762eea | |||
| 3eb541ca39 | |||
| b353ac0328 | |||
| 0638c685b6 | |||
| fdcdd4c6ef | |||
| 8378c2e8ff | |||
| 575a83fcaf | |||
| 17ad2235b6 | |||
| 7c47856671 | |||
| 8fa161cfb8 | |||
| cbf481a7fb | |||
| fe485cf0fc | |||
| 04886e0b30 | |||
| cbc7b48a05 | |||
| f10f691e40 | |||
| 4c2509648e | |||
| e6006e849b | |||
| 1076ce4216 | |||
| 39187a0660 | |||
| f0bbedab23 | |||
| 750444c8bb | |||
| 581a146d2f | |||
| eab45e0819 | |||
| e243d70082 | |||
| d4c3743e45 | |||
| 07d9b56fe7 | |||
| 11b3f83368 | |||
| 1dc40b4760 | |||
| 29448aaf9b | |||
| c73c4be367 | |||
| c31bc96855 | |||
| 002384bad4 | |||
| db6b742ebb | |||
| 88ca8b4406 | |||
| 005b34d0dd | |||
| e2ddc679bb | |||
| 39990f7a07 | |||
| 6fcb4cd70c | |||
| 14fefc7b1a | |||
| fa8ab58fc0 | |||
| 4765d83c8b | |||
| 395bc37cf0 | |||
| 15b6e17552 | |||
| a6e147ed30 | |||
| 0d46d71b2d | |||
| 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 |
@@ -458,6 +458,8 @@ jobs:
|
||||
name: Frontend Lint
|
||||
runs-on: ci-l2
|
||||
timeout-minutes: 10
|
||||
needs: check-frontend-only
|
||||
if: needs.check-frontend-only.outputs.skip_frontend != 'true'
|
||||
steps:
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
@@ -576,7 +578,12 @@ jobs:
|
||||
name: PR Build ${{ matrix.service_display }} Image
|
||||
runs-on: runtime-builder
|
||||
timeout-minutes: ${{ matrix.timeout }}
|
||||
if: github.event_name == 'pull_request'
|
||||
needs: check-frontend-only
|
||||
if: |
|
||||
github.event_name == 'pull_request' && (
|
||||
(matrix.service == 'web' && needs.check-frontend-only.outputs.skip_frontend != 'true') ||
|
||||
(matrix.service != 'web' && needs.check-frontend-only.outputs.skip_backend != 'true')
|
||||
)
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -1084,6 +1091,8 @@ jobs:
|
||||
# DooD模式下不能用-v挂载(宿主机路径与CI容器路径不一致)
|
||||
# 改用 docker create + docker cp 方式把代码拷进容器
|
||||
CONTAINER_NAME="staging-e2e-$$"
|
||||
# 强制清理可能残留的同名容器(上一次异常退出时未清理)
|
||||
docker rm -f "$CONTAINER_NAME" 2>/dev/null || true
|
||||
docker create --name "$CONTAINER_NAME" --ipc=host \
|
||||
-e E2E_BASE_URL=https://staging.xiaoxiajianji.com \
|
||||
-e E2E_API_BASE=https://staging-api.xiaoxiajianji.com/api/v1 \
|
||||
@@ -1192,6 +1201,11 @@ jobs:
|
||||
runs-on: runtime-builder
|
||||
timeout-minutes: ${{ matrix.timeout }}
|
||||
needs:
|
||||
- validate-code-quality
|
||||
- validate-type-check
|
||||
- unit-tests
|
||||
- frontend-lint
|
||||
- frontend-unit-test
|
||||
if: startsWith(github.ref, 'refs/tags/v') || (github.event_name == 'push' && github.ref_name == 'main')
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -1265,7 +1279,7 @@ jobs:
|
||||
docker buildx inspect --bootstrap
|
||||
|
||||
- name: Build and push production ${{ matrix.service_display }} image (with retry)
|
||||
shell: sh
|
||||
shell: bash
|
||||
run: |
|
||||
set -eu
|
||||
REGISTRY="xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji"
|
||||
@@ -1718,6 +1732,30 @@ jobs:
|
||||
echo " frontend-lint: $RESULT_FRONTEND_LINT"
|
||||
echo " frontend-unit-test: $RESULT_FRONTEND_UNIT"
|
||||
echo " build-pr: $RESULT_BUILD_PR"
|
||||
|
||||
# 查询 AI Code Review 状态(跨workflow,读commit status)
|
||||
AI_REVIEW_STATUS="pending"
|
||||
AI_REVIEW_DESC=""
|
||||
STATUS_JSON=$(curl -sH "Authorization: token $GITHUB_TOKEN" \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/commits/${PR_HEAD_SHA}/status" 2>/dev/null || true)
|
||||
if [ -n "$STATUS_JSON" ]; then
|
||||
AI_STATUS=$(echo "$STATUS_JSON" | python3 -c "
|
||||
import json,sys
|
||||
try:
|
||||
data=json.load(sys.stdin)
|
||||
for s in data.get('statuses',[]):
|
||||
if 'AI Code Review' in s.get('context',''):
|
||||
print(s['state']+'|'+s.get('description',''))
|
||||
break
|
||||
except: pass
|
||||
" 2>/dev/null)
|
||||
if [ -n "$AI_STATUS" ]; then
|
||||
AI_REVIEW_STATUS="${AI_STATUS%%|*}"
|
||||
AI_REVIEW_DESC="${AI_STATUS#*|}"
|
||||
fi
|
||||
fi
|
||||
echo " ai-code-review: $AI_REVIEW_STATUS ($AI_REVIEW_DESC)"
|
||||
|
||||
echo ""
|
||||
|
||||
# 判断PR类型
|
||||
@@ -1733,11 +1771,13 @@ jobs:
|
||||
"validate-migration:$RESULT_MIGRATION"
|
||||
"frontend-lint:$RESULT_FRONTEND_LINT"
|
||||
"build-pr:$RESULT_BUILD_PR"
|
||||
"ai-code-review:$AI_REVIEW_STATUS"
|
||||
)
|
||||
|
||||
# 后端检查
|
||||
REQUIRED_BACKEND=(
|
||||
"unit-tests:$RESULT_UNIT_TESTS"
|
||||
"integration-tests:$RESULT_INTEGRATION"
|
||||
)
|
||||
|
||||
# 前端检查
|
||||
@@ -1767,6 +1807,11 @@ jobs:
|
||||
for item in "${REQUIRED_GENERAL[@]}"; do
|
||||
name="${item%%:*}"
|
||||
result="${item##*:}"
|
||||
# AI Code Review pending时不阻塞(可能还在跑),等它跑完自然会重跑Gate
|
||||
if [ "$name" = "ai-code-review" ] && [ "$result" = "pending" ]; then
|
||||
echo " ⏳ $name: pending(审查中,暂不阻塞)"
|
||||
continue
|
||||
fi
|
||||
check_job "$name" "$result"
|
||||
done
|
||||
|
||||
@@ -1805,7 +1850,7 @@ jobs:
|
||||
echo "❌ CI Gate: FAILED"
|
||||
echo "失败项: ${FAILED_ITEMS[*]}"
|
||||
echo "gate_result=failure" >> $GITHUB_OUTPUT
|
||||
exit 0
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Report CI trace
|
||||
|
||||
@@ -48,6 +48,7 @@ jobs:
|
||||
GITEA_TOKEN: ${{ secrets.REVIEW_GITEA_TOKEN }}
|
||||
REPO_NAME: ${{ gitea.repository }}
|
||||
PR_NUMBER: ${{ gitea.event.pull_request.number }}
|
||||
PR_HEAD_SHA: ${{ gitea.event.pull_request.head.sha }}
|
||||
# LLM 提供商: coze (扣子原生Bot) / openai (OpenAI兼容)
|
||||
LLM_PROVIDER: "coze"
|
||||
# 扣子模式配置(默认国内站 api.coze.cn)
|
||||
@@ -60,8 +61,9 @@ jobs:
|
||||
LLM_TIMEOUT: "120"
|
||||
run: |
|
||||
python3 scripts/ci_code_review.py
|
||||
# 审查脚本异常不影响 CI 通过
|
||||
continue-on-error: true
|
||||
# 注意:脚本退出码决定job状态
|
||||
# - 有阻塞级问题 → exit 1 → job失败 → 门禁拦截
|
||||
# - 无阻塞级问题/LLM异常 → exit 0 → 通过(fail-open)
|
||||
|
||||
- name: Report CI trace
|
||||
if: always()
|
||||
|
||||
@@ -87,7 +87,7 @@ jobs:
|
||||
runs-on: ci-l2
|
||||
timeout-minutes: 10
|
||||
outputs:
|
||||
report: ${{ steps.smoke.outputs.report }}
|
||||
report: ${{ steps.report.outputs.report }}
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
@@ -201,7 +201,7 @@ jobs:
|
||||
runs-on: ci-l2
|
||||
timeout-minutes: 15
|
||||
outputs:
|
||||
report: ${{ steps.smoke.outputs.report }}
|
||||
report: ${{ steps.e2e.outputs.report }}
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
|
||||
Executable → Regular
+6
-1
@@ -8,6 +8,11 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
|
||||
concurrency:
|
||||
group: pr-automation-${{ gitea.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
auto-approve:
|
||||
name: Auto Approve on CI Green
|
||||
@@ -56,7 +61,7 @@ jobs:
|
||||
name: Auto Merge on CI Green + Approved
|
||||
runs-on: ci-check
|
||||
if: github.event_name == 'pull_request' && !github.event.pull_request.draft && github.event.pull_request.base.ref == 'develop'
|
||||
timeout-minutes: 45 # 长等待模式:等CI全绿后自动合并,不遗漏任何PR
|
||||
timeout-minutes: 3 # 短作业模式:检查一次,不满足就退出,由pr-auto-scan每5分钟定时兜底
|
||||
steps:
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
|
||||
@@ -120,7 +120,7 @@ jobs:
|
||||
PREVIEW_SSH_KEY: ${{ secrets.PREVIEW_SSH_KEY }}
|
||||
run: |
|
||||
set -eux
|
||||
preview_host="${PREVIEW_SSH_HOST:-172.30.18.197}"
|
||||
preview_host="${PREVIEW_SSH_HOST:-47.98.113.167}"
|
||||
preview_user="${PREVIEW_SSH_USER:-deploy}"
|
||||
preview_port="${PREVIEW_SSH_PORT:-22222}"
|
||||
preview_dir="/var/www/preview/pr-${PR_NUMBER}"
|
||||
|
||||
@@ -101,7 +101,7 @@ jobs:
|
||||
# Install dependencies with retry
|
||||
for i in 1 2 3; do
|
||||
npm ci --no-audit --no-fund && break
|
||||
echo "npm ci failed, retry $i/3..."
|
||||
echo "npm install failed, retry $i/3..."
|
||||
[ $i -eq 3 ] && exit 1
|
||||
rm -rf node_modules
|
||||
sleep 5
|
||||
|
||||
@@ -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 { 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 { 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> = ({
|
||||
assets,
|
||||
@@ -87,162 +34,41 @@ const AssetSelector: React.FC<AssetSelectorProps> = ({
|
||||
showBatchSelect = true,
|
||||
compact = false,
|
||||
}) => {
|
||||
/* ── 搜索 & 筛选 ── */
|
||||
const [searchText, setSearchText] = useState("")
|
||||
const [filterType, setFilterType] = useState("")
|
||||
const [filterQuality, setFilterQuality] = useState("")
|
||||
const [viewMode, setViewMode] = useState<ViewMode>("grid")
|
||||
// 搜索 & 筛选
|
||||
const {
|
||||
searchText,
|
||||
filterType,
|
||||
filterQuality,
|
||||
viewMode,
|
||||
filteredAssets,
|
||||
setSearchText,
|
||||
setFilterType,
|
||||
setFilterQuality,
|
||||
setViewMode,
|
||||
} = useAssetFilter(assets)
|
||||
|
||||
/* ── 拖拽状态 ── */
|
||||
const [dragIdx, setDragIdx] = useState<number | null>(null)
|
||||
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null)
|
||||
// 选中操作
|
||||
const { selectedSet, toggleSelect, clearSelection } = useAssetSelection({
|
||||
filteredAssets,
|
||||
selectedIds,
|
||||
onSelectionChange,
|
||||
})
|
||||
|
||||
/* ── 悬浮预览 ── */
|
||||
const [previewAsset, setPreviewAsset] = useState<MediaAsset | null>(null)
|
||||
const [previewPos, setPreviewPos] = useState({ x: 0, y: 0 })
|
||||
const previewTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
// 拖拽排序
|
||||
const { dragIdx, dragOverIdx, handleDragStart, handleDragOver, handleDrop, handleDragEnd } =
|
||||
useDragReorder({
|
||||
filteredAssets,
|
||||
selectedIds,
|
||||
onAssetDragStart,
|
||||
onReorder,
|
||||
})
|
||||
|
||||
/* ── Shift 连选 ── */
|
||||
const lastClickedIdx = useRef<number | null>(null)
|
||||
|
||||
/* ── 过滤后的素材列表 ── */
|
||||
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 { previewAsset, previewPos, handleMouseEnter, handleMouseLeave } = useAssetPreview()
|
||||
|
||||
/* ── 点击卡片 ── */
|
||||
const handleCardClick = useCallback(
|
||||
(asset: MediaAsset, idx: number, e: React.MouseEvent) => {
|
||||
// 如果点击的是 checkbox 区域,不触发卡片点击
|
||||
const target = e.target as HTMLElement
|
||||
if (target.closest("[data-checkbox]")) return
|
||||
toggleSelect(asset, idx, e.shiftKey)
|
||||
@@ -250,253 +76,82 @@ const AssetSelector: React.FC<AssetSelectorProps> = ({
|
||||
[toggleSelect],
|
||||
)
|
||||
|
||||
/* ──────────── 渲染 ──────────── */
|
||||
|
||||
const hasSelection = selectedIds.length > 0
|
||||
|
||||
return (
|
||||
<div className="as-container">
|
||||
{/* ═══ 工具栏 ═══ */}
|
||||
<div className="as-toolbar">
|
||||
<div className="as-toolbar-left">
|
||||
<div className="as-search">
|
||||
<Input
|
||||
placeholder="搜索素材..."
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
prefix="🔍"
|
||||
/>
|
||||
</div>
|
||||
<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>
|
||||
{/* 工具栏 */}
|
||||
<SelectorToolbar
|
||||
searchText={searchText}
|
||||
filterType={filterType}
|
||||
filterQuality={filterQuality}
|
||||
viewMode={viewMode}
|
||||
showQualityFilter={showQualityFilter}
|
||||
onSearchChange={setSearchText}
|
||||
onTypeChange={setFilterType}
|
||||
onQualityChange={setFilterQuality}
|
||||
onViewModeChange={setViewMode}
|
||||
/>
|
||||
|
||||
{/* ═══ 批量操作栏 ═══ */}
|
||||
{/* 批量操作栏 */}
|
||||
{showBatchSelect && hasSelection && (
|
||||
<div className="as-batch-bar">
|
||||
<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>
|
||||
<SelectorBatchBar selectedCount={selectedIds.length} onClear={clearSelection} />
|
||||
)}
|
||||
|
||||
{/* ═══ 素材列表 ═══ */}
|
||||
{/* 素材列表 */}
|
||||
<div className="as-body">
|
||||
{filteredAssets.length === 0 ? (
|
||||
<div className="as-empty">
|
||||
<div className="as-empty-icon">📂</div>
|
||||
<p>暂无素材</p>
|
||||
</div>
|
||||
<EmptyState />
|
||||
) : viewMode === "grid" ? (
|
||||
/* ── 网格视图 ── */
|
||||
/* 网格视图 */
|
||||
<div className={`as-grid${compact ? " compact" : ""}`}>
|
||||
{filteredAssets.map((asset, idx) => {
|
||||
const isSelected = selectedSet.has(asset.id)
|
||||
const isDragging = dragIdx === idx
|
||||
const isDragOver = dragOverIdx === idx
|
||||
const qualityLevel = getQualityLevel(asset.quality_score)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={asset.id}
|
||||
className={[
|
||||
"as-card",
|
||||
isSelected ? "selected" : "",
|
||||
isDragging ? "dragging" : "",
|
||||
isDragOver ? "drag-over" : "",
|
||||
showBatchSelect ? "has-checkbox" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.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>
|
||||
)
|
||||
})}
|
||||
{filteredAssets.map((asset, idx) => (
|
||||
<AssetCard
|
||||
key={asset.id}
|
||||
asset={asset}
|
||||
isSelected={selectedSet.has(asset.id)}
|
||||
isDragging={dragIdx === idx}
|
||||
isDragOver={dragOverIdx === idx}
|
||||
showCheckbox={showBatchSelect}
|
||||
compact={compact}
|
||||
onToggleSelect={(a, shiftKey) => toggleSelect(a, idx, shiftKey)}
|
||||
onCardClick={(a, e) => handleCardClick(a, idx, e)}
|
||||
onDragStart={(e) => handleDragStart(e, idx)}
|
||||
onDragOver={(e) => handleDragOver(e, idx)}
|
||||
onDrop={(e) => handleDrop(e, idx)}
|
||||
onDragEnd={handleDragEnd}
|
||||
onMouseEnter={(e) => handleMouseEnter(asset, e)}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
/* ── 列表视图 ── */
|
||||
/* 列表视图 */
|
||||
<div className="as-list">
|
||||
{filteredAssets.map((asset, idx) => {
|
||||
const isSelected = selectedSet.has(asset.id)
|
||||
const isDragging = dragIdx === idx
|
||||
const isDragOver = dragOverIdx === idx
|
||||
|
||||
return (
|
||||
<div
|
||||
key={asset.id}
|
||||
className={[
|
||||
"as-list-item",
|
||||
isSelected ? "selected" : "",
|
||||
isDragging ? "dragging" : "",
|
||||
isDragOver ? "drag-over" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.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}
|
||||
>
|
||||
{/* 拖拽手柄 */}
|
||||
<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>
|
||||
)
|
||||
})}
|
||||
{filteredAssets.map((asset, idx) => (
|
||||
<AssetListItem
|
||||
key={asset.id}
|
||||
asset={asset}
|
||||
isSelected={selectedSet.has(asset.id)}
|
||||
isDragging={dragIdx === idx}
|
||||
isDragOver={dragOverIdx === idx}
|
||||
showCheckbox={showBatchSelect}
|
||||
onToggleSelect={(a, shiftKey) => toggleSelect(a, idx, shiftKey)}
|
||||
onCardClick={(a, e) => handleCardClick(a, idx, e)}
|
||||
onDragStart={(e) => handleDragStart(e, idx)}
|
||||
onDragOver={(e) => handleDragOver(e, idx)}
|
||||
onDrop={(e) => handleDrop(e, idx)}
|
||||
onDragEnd={handleDragEnd}
|
||||
onMouseEnter={(e) => handleMouseEnter(asset, e)}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ═══ 悬浮预览 ═══ */}
|
||||
{previewAsset && (
|
||||
<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>
|
||||
)}
|
||||
{/* 悬浮预览 */}
|
||||
{previewAsset && <PreviewOverlay asset={previewAsset} position={previewPos} />}
|
||||
</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 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 直接导入
|
||||
*/
|
||||
import React, { useState, useCallback, useRef, useEffect } from "react"
|
||||
import { Modal, Button } from "@/components/ui"
|
||||
import { createVoiceClone, toVoiceClone } from "@/api/voice-clone"
|
||||
import type { VoiceClone } from "@/api/voice-clone"
|
||||
import { uploadAsset } from "@/api/assets"
|
||||
import React from "react"
|
||||
import { Modal } from "@/components/ui"
|
||||
import type { CloneModalProps } from "./types/cloneModal"
|
||||
import useCloneModal from "./hooks/useCloneModal"
|
||||
import InputView from "./clone-modal/InputView"
|
||||
import ProgressView from "./clone-modal/ProgressView"
|
||||
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 [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, setIsRecording] = useState(false)
|
||||
const [recordTime, setRecordTime] = useState(0)
|
||||
const [recordedBlob, setRecordedBlob] = useState<Blob | null>(null)
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const recordTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null)
|
||||
const audioChunksRef = useRef<Blob[]>([])
|
||||
/** 默认音色名称计数器(组件级 ref,避免多实例串号) */
|
||||
const cloneCounterRef = useRef(1)
|
||||
|
||||
/** 生成下一个默认音色名称 */
|
||||
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()
|
||||
const {
|
||||
phase,
|
||||
voiceName,
|
||||
voiceDescription,
|
||||
selectedFile,
|
||||
dragActive,
|
||||
errorMessage,
|
||||
isRecording,
|
||||
recordTime,
|
||||
recordedBlob,
|
||||
canSubmit,
|
||||
isProcessing,
|
||||
setVoiceName,
|
||||
setVoiceDescription,
|
||||
setDragActive,
|
||||
handleFileSelect,
|
||||
handleRecordToggle,
|
||||
handleClose,
|
||||
handleSubmit,
|
||||
} = useCloneModal({ open, onClose, onSuccess })
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@@ -373,228 +51,30 @@ const CloneModal: React.FC<CloneModalProps> = ({ open, onClose, onSuccess }) =>
|
||||
maskClosable={!isProcessing}
|
||||
keyboard={!isProcessing}
|
||||
>
|
||||
{/* ── 输入阶段 ──────────────────────────────────── */}
|
||||
{/* 输入阶段 */}
|
||||
{phase === "input" && (
|
||||
<div className="xx-clonemodal-body">
|
||||
{/* 步骤引导 */}
|
||||
<div className="xx-clonemodal-steps">
|
||||
<div className="xx-clonemodal-step xx-clonemodal-step--active">
|
||||
<div className="xx-clonemodal-step-number">1</div>
|
||||
<span className="xx-clonemodal-step-label">上传/录制音频</span>
|
||||
</div>
|
||||
<div className="xx-clonemodal-step-connector" />
|
||||
<div className="xx-clonemodal-step">
|
||||
<div className="xx-clonemodal-step-number">2</div>
|
||||
<span className="xx-clonemodal-step-label">填写信息</span>
|
||||
</div>
|
||||
<div className="xx-clonemodal-step-connector" />
|
||||
<div className="xx-clonemodal-step">
|
||||
<div className="xx-clonemodal-step-number">3</div>
|
||||
<span className="xx-clonemodal-step-label">提交克隆</span>
|
||||
</div>
|
||||
</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>
|
||||
<InputView
|
||||
voiceName={voiceName}
|
||||
voiceDescription={voiceDescription}
|
||||
selectedFile={selectedFile}
|
||||
dragActive={dragActive}
|
||||
isRecording={isRecording}
|
||||
recordTime={recordTime}
|
||||
recordedBlob={recordedBlob}
|
||||
errorMessage={errorMessage}
|
||||
canSubmit={canSubmit}
|
||||
onVoiceNameChange={setVoiceName}
|
||||
onVoiceDescChange={setVoiceDescription}
|
||||
onDragActiveChange={setDragActive}
|
||||
onFileSelect={handleFileSelect}
|
||||
onRecordToggle={handleRecordToggle}
|
||||
onClose={handleClose}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── 进度阶段(上传中 / 克隆中) ──────────────── */}
|
||||
{isProcessing && (
|
||||
<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>
|
||||
)}
|
||||
{/* 进度 / 完成阶段 */}
|
||||
{phase !== "input" && <ProgressView phase={phase} />}
|
||||
</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,134 @@
|
||||
import { useState, useRef, useCallback, useEffect } from "react"
|
||||
import type { ModalPhase } from "../types/cloneModal"
|
||||
import { MIN_VOICE_NAME_LENGTH, MAX_VOICE_NAME_LENGTH } from "../constants/cloneModal"
|
||||
import useAudioRecorder from "./useAudioRecorder"
|
||||
|
||||
/**
|
||||
* 克隆弹窗表单状态 Hook
|
||||
* 管理表单字段、录音、文件选择、验证逻辑
|
||||
*/
|
||||
export function useCloneFormState({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
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 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])
|
||||
|
||||
/** 选择文件(来自上传或拖拽) */
|
||||
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])
|
||||
|
||||
return {
|
||||
// 状态
|
||||
phase,
|
||||
setPhase,
|
||||
voiceName,
|
||||
setVoiceName,
|
||||
voiceDescription,
|
||||
setVoiceDescription,
|
||||
selectedFile,
|
||||
dragActive,
|
||||
setDragActive,
|
||||
errorMessage,
|
||||
setErrorMessage,
|
||||
// 录音
|
||||
isRecording,
|
||||
recordTime,
|
||||
recordedBlob,
|
||||
// 计算属性
|
||||
hasAudio,
|
||||
canSubmit,
|
||||
isProcessing,
|
||||
// handlers
|
||||
handleFileSelect,
|
||||
handleRecordToggle,
|
||||
handleClose,
|
||||
validateForm,
|
||||
resetState,
|
||||
}
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
import type { CloneModalProps } from "../types/cloneModal"
|
||||
import { useCloneFormState } from "./useCloneFormState"
|
||||
import { useCloneSubmit } from "./useCloneSubmit"
|
||||
|
||||
/**
|
||||
* 音色克隆弹窗主业务 Hook
|
||||
* 组合表单状态 + 提交流程两个子 Hook
|
||||
*/
|
||||
const useCloneModal = ({ open, onClose, onSuccess }: CloneModalProps) => {
|
||||
const formState = useCloneFormState({ open, onClose })
|
||||
|
||||
const { handleSubmit } = useCloneSubmit({
|
||||
voiceName: formState.voiceName,
|
||||
voiceDescription: formState.voiceDescription,
|
||||
selectedFile: formState.selectedFile,
|
||||
recordedBlob: formState.recordedBlob,
|
||||
setPhase: formState.setPhase,
|
||||
setErrorMessage: formState.setErrorMessage,
|
||||
validateForm: formState.validateForm,
|
||||
onSuccess,
|
||||
onClose: formState.handleClose,
|
||||
})
|
||||
|
||||
return {
|
||||
phase: formState.phase,
|
||||
voiceName: formState.voiceName,
|
||||
voiceDescription: formState.voiceDescription,
|
||||
selectedFile: formState.selectedFile,
|
||||
dragActive: formState.dragActive,
|
||||
errorMessage: formState.errorMessage,
|
||||
isRecording: formState.isRecording,
|
||||
recordTime: formState.recordTime,
|
||||
recordedBlob: formState.recordedBlob,
|
||||
canSubmit: formState.canSubmit,
|
||||
isProcessing: formState.isProcessing,
|
||||
setVoiceName: formState.setVoiceName,
|
||||
setVoiceDescription: formState.setVoiceDescription,
|
||||
setDragActive: formState.setDragActive,
|
||||
handleFileSelect: formState.handleFileSelect,
|
||||
handleRecordToggle: formState.handleRecordToggle,
|
||||
handleClose: formState.handleClose,
|
||||
handleSubmit,
|
||||
}
|
||||
}
|
||||
|
||||
export default useCloneModal
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
import { useRef, useCallback, useEffect } from "react"
|
||||
import { createVoiceClone, toVoiceClone } from "@/api/voice-clone"
|
||||
import { uploadAsset, ensureDefaultLibrary } from "@/api/assets"
|
||||
import { getOrCreateDefaultProject } from "@/api/projects"
|
||||
import type { VoiceClone } from "@/api/voice-clone"
|
||||
|
||||
interface UseCloneSubmitOptions {
|
||||
voiceName: string
|
||||
voiceDescription: string
|
||||
selectedFile: File | null
|
||||
recordedBlob: Blob | null
|
||||
setPhase: (phase: "input" | "uploading" | "cloning" | "done") => void
|
||||
setErrorMessage: (msg: string) => void
|
||||
validateForm: () => string | null
|
||||
onSuccess?: (clone: VoiceClone) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 克隆提交流程 Hook
|
||||
* 封装上传 + 克隆 + 完成的三阶段流程
|
||||
*/
|
||||
export function useCloneSubmit({
|
||||
voiceName,
|
||||
voiceDescription,
|
||||
selectedFile,
|
||||
recordedBlob,
|
||||
setPhase,
|
||||
setErrorMessage,
|
||||
validateForm,
|
||||
onSuccess,
|
||||
onClose,
|
||||
}: UseCloneSubmitOptions) {
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
/** 组件卸载时清理定时器 */
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
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",
|
||||
})
|
||||
}
|
||||
|
||||
// 获取默认项目和素材库(后端 /upload 接口必填)
|
||||
const project = await getOrCreateDefaultProject()
|
||||
const library = await ensureDefaultLibrary({ project_id: project.id, kind: "voice" })
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append("file", fileToUpload)
|
||||
formData.append("project_id", project.id)
|
||||
formData.append("library_id", library.id)
|
||||
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))
|
||||
onClose()
|
||||
}, 2000)
|
||||
} catch (err) {
|
||||
setPhase("input")
|
||||
setErrorMessage(err instanceof Error ? err.message : "克隆失败,请重试")
|
||||
}
|
||||
}, [
|
||||
validateForm,
|
||||
selectedFile,
|
||||
recordedBlob,
|
||||
voiceName,
|
||||
voiceDescription,
|
||||
setPhase,
|
||||
setErrorMessage,
|
||||
onSuccess,
|
||||
onClose,
|
||||
])
|
||||
|
||||
return { handleSubmit }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
export const AssetUploadZone: React.FC<AssetUploadZoneProps> = ({
|
||||
uploading,
|
||||
onUpload,
|
||||
}) => {
|
||||
export const AssetUploadZone: React.FC<AssetUploadZoneProps> = ({ uploading, onUpload }) => {
|
||||
return (
|
||||
<Upload.Dragger
|
||||
beforeUpload={(file) => {
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
export { useBatchDelete } from "./useBatchDelete"
|
||||
export { useBatchTag } from "./useBatchTag"
|
||||
export { useBatchClassify } from "./useBatchClassify"
|
||||
export { useBatchMark } from "./useBatchMark"
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
import { useState, useCallback } from "react"
|
||||
import { message } from "antd"
|
||||
import { batchClassifyAssets, type BatchOperationResult } from "@/api/assets"
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
import { useState, useCallback } from "react"
|
||||
import { message } from "antd"
|
||||
import { batchDeleteAssets, type BatchOperationResult } from "@/api/assets"
|
||||
|
||||
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 }
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
import { useState, useCallback } from "react"
|
||||
import { message } from "antd"
|
||||
import { batchMarkAssets, type BatchOperationResult } from "@/api/assets"
|
||||
import type { SmartViewType } from "../../../components/BatchMarkModal"
|
||||
import { SMART_VIEW_LABELS } from "../constants"
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
import { useState, useCallback } from "react"
|
||||
import { message } from "antd"
|
||||
import { batchTagAssets, type BatchOperationResult } from "@/api/assets"
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { useBatchDelete } from "./useBatchDelete"
|
||||
export { useBatchTag } from "./useBatchTag"
|
||||
export { useBatchClassify } from "./useBatchClassify"
|
||||
export { useBatchMark } from "./useBatchMark"
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useState, useCallback } from "react"
|
||||
import { message } from "antd"
|
||||
import { batchClassifyAssets, type BatchOperationResult } from "@/api/assets"
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useState, useCallback } from "react"
|
||||
import { message } from "antd"
|
||||
import { batchDeleteAssets, type BatchOperationResult } from "@/api/assets"
|
||||
|
||||
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 }
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useState, useCallback } from "react"
|
||||
import { message } from "antd"
|
||||
import { batchMarkAssets, type BatchOperationResult } from "@/api/assets"
|
||||
import type { SmartViewType } from "../../../components/BatchMarkModal"
|
||||
import { SMART_VIEW_LABELS } from "../constants"
|
||||
|
||||
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,86 @@
|
||||
import { useState, useCallback } from "react"
|
||||
import { message } from "antd"
|
||||
import { batchTagAssets, type BatchOperationResult } from "@/api/assets"
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* @deprecated 请从 ./batch/ 目录导入子模块
|
||||
* 保持向后兼容,re-export 所有批量操作 Hook
|
||||
*/
|
||||
export { useBatchDelete } from "./batch/useBatchDelete"
|
||||
export { useBatchTag } from "./batch/useBatchTag"
|
||||
export { useBatchClassify } from "./batch/useBatchClassify"
|
||||
export { useBatchMark } from "./batch/useBatchMark"
|
||||
@@ -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 { useQueryClient } from "@tanstack/react-query"
|
||||
import { message } from "antd"
|
||||
import { useSingleOperations, useBatchHelpers } from "./asset-operations/useSingleOperations"
|
||||
import {
|
||||
deleteAsset,
|
||||
getAssetDiagnosis,
|
||||
batchDeleteAssets,
|
||||
batchTagAssets,
|
||||
batchClassifyAssets,
|
||||
batchMarkAssets,
|
||||
type BatchOperationResult,
|
||||
} from "@/api/assets"
|
||||
import type { AssetItem } from "../types"
|
||||
useBatchDelete,
|
||||
useBatchTag,
|
||||
useBatchClassify,
|
||||
useBatchMark,
|
||||
} from "./asset-operations/batch-operations"
|
||||
import type { BatchOperationResult } from "@/api/assets"
|
||||
import type { SmartViewType } from "../components/BatchMarkModal"
|
||||
|
||||
/**
|
||||
* 素材操作 Hook
|
||||
* 封装素材的诊断、删除、批量打标签、批量改分类、批量智能标记等操作,
|
||||
* 以及相关弹窗和结果展示的状态管理
|
||||
*/
|
||||
interface UseAssetOperationsProps {
|
||||
selectedIds: Set<string>
|
||||
setSelectedIds: (ids: Set<string>) => void
|
||||
@@ -29,78 +27,35 @@ export function useAssetOperations({ selectedIds, setSelectedIds }: UseAssetOper
|
||||
/* ── 诊断状态 ── */
|
||||
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 [operationTitle, setOperationTitle] = useState("")
|
||||
|
||||
/* ── 批量操作 loading ── */
|
||||
const [batchLoading, setBatchLoading] = useState(false)
|
||||
/* ── 单个操作 ── */
|
||||
const { handleDiagnose: handleDiagnoseRaw, handleSingleDelete } = useSingleOperations({
|
||||
selectedIds,
|
||||
setSelectedIds,
|
||||
})
|
||||
|
||||
/* ── 刷新数据辅助函数 ── */
|
||||
const invalidateAssets = useCallback(() => {
|
||||
queryClient.invalidateQueries({ queryKey: ["assets"] })
|
||||
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] })
|
||||
}, [queryClient])
|
||||
|
||||
/* ── 诊断 ── */
|
||||
// 包装一下,加上 diagnosingId 状态
|
||||
const handleDiagnose = useCallback(
|
||||
async (asset: AssetItem) => {
|
||||
async (asset: Parameters<typeof handleDiagnoseRaw>[0]) => {
|
||||
setDiagnosingId(asset.id)
|
||||
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}" 诊断失败`)
|
||||
await handleDiagnoseRaw(asset)
|
||||
} finally {
|
||||
setDiagnosingId(null)
|
||||
}
|
||||
},
|
||||
[queryClient],
|
||||
[handleDiagnoseRaw],
|
||||
)
|
||||
|
||||
/* ── 单个素材删除 ── */
|
||||
const handleSingleDelete = useCallback(
|
||||
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],
|
||||
)
|
||||
/* ── 批量操作辅助 ── */
|
||||
const { invalidateAssets } = useBatchHelpers({ queryClient, setSelectedIds })
|
||||
|
||||
/* ── 显示操作结果 ── */
|
||||
const showOperationResult = useCallback(
|
||||
// 包装 showResult 适配子模块的接口
|
||||
const showResult = useCallback(
|
||||
(result: BatchOperationResult, title: string, clearSelection = true) => {
|
||||
setOperationResult(result)
|
||||
setOperationTitle(title)
|
||||
@@ -110,147 +65,23 @@ export function useAssetOperations({ selectedIds, setSelectedIds }: UseAssetOper
|
||||
[setSelectedIds],
|
||||
)
|
||||
|
||||
/* ── 批量删除 ── */
|
||||
const handleBatchDelete = useCallback(async () => {
|
||||
const ids = Array.from(selectedIds)
|
||||
setBatchLoading(true)
|
||||
try {
|
||||
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 { batchLoading: deleteLoading, handleBatchDelete } = useBatchDelete({
|
||||
selectedIds,
|
||||
invalidateAssets,
|
||||
showResult,
|
||||
})
|
||||
|
||||
/* ── 批量打标签 ── */
|
||||
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"] })
|
||||
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])
|
||||
const tagResult = useBatchTag({ selectedIds, queryClient, showResult })
|
||||
const classifyResult = useBatchClassify({ selectedIds, queryClient, showResult })
|
||||
const markResult = useBatchMark({ selectedIds, 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],
|
||||
)
|
||||
|
||||
/* ── 批量改分类 ── */
|
||||
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])
|
||||
// 取任一批量操作的 loading 状态(任意一个在加载都算加载中)
|
||||
const batchLoading =
|
||||
deleteLoading ||
|
||||
tagResult.batchLoading ||
|
||||
classifyResult.batchLoading ||
|
||||
markResult.batchLoading
|
||||
|
||||
/* ── 关闭结果 Drawer ── */
|
||||
const handleResultDrawerClose = useCallback(() => {
|
||||
@@ -267,29 +98,29 @@ export function useAssetOperations({ selectedIds, setSelectedIds }: UseAssetOper
|
||||
// 批量操作 loading
|
||||
batchLoading,
|
||||
// 批量打标签
|
||||
tagModalOpen,
|
||||
setTagModalOpen,
|
||||
batchTagInput,
|
||||
setBatchTagInput,
|
||||
batchTags,
|
||||
setBatchTags,
|
||||
tagMode,
|
||||
setTagMode,
|
||||
handleBatchTag,
|
||||
handleTagInputKeyDown,
|
||||
removeBatchTag,
|
||||
tagModalOpen: tagResult.tagModalOpen,
|
||||
setTagModalOpen: tagResult.setTagModalOpen,
|
||||
batchTagInput: tagResult.batchTagInput,
|
||||
setBatchTagInput: tagResult.setBatchTagInput,
|
||||
batchTags: tagResult.batchTags,
|
||||
setBatchTags: tagResult.setBatchTags,
|
||||
tagMode: tagResult.tagMode,
|
||||
setTagMode: tagResult.setTagMode,
|
||||
handleBatchTag: tagResult.handleBatchTag,
|
||||
handleTagInputKeyDown: tagResult.handleTagInputKeyDown,
|
||||
removeBatchTag: tagResult.removeBatchTag,
|
||||
// 批量改分类
|
||||
classifyModalOpen,
|
||||
setClassifyModalOpen,
|
||||
batchCategory,
|
||||
setBatchCategory,
|
||||
handleBatchClassify,
|
||||
classifyModalOpen: classifyResult.classifyModalOpen,
|
||||
setClassifyModalOpen: classifyResult.setClassifyModalOpen,
|
||||
batchCategory: classifyResult.batchCategory,
|
||||
setBatchCategory: classifyResult.setBatchCategory,
|
||||
handleBatchClassify: classifyResult.handleBatchClassify,
|
||||
// 批量智能标记
|
||||
markModalOpen,
|
||||
setMarkModalOpen,
|
||||
batchSmartView,
|
||||
setBatchSmartView,
|
||||
handleBatchMark,
|
||||
markModalOpen: markResult.markModalOpen,
|
||||
setMarkModalOpen: markResult.setMarkModalOpen,
|
||||
batchSmartView: markResult.batchSmartView as SmartViewType,
|
||||
setBatchSmartView: markResult.setBatchSmartView,
|
||||
handleBatchMark: markResult.handleBatchMark,
|
||||
// 批量删除
|
||||
handleBatchDelete,
|
||||
// 操作结果
|
||||
|
||||
Regular → Executable
+23
-239
@@ -3,137 +3,29 @@
|
||||
* 风险评估 + 基本信息 + 检测项列表 + 匹配片段
|
||||
* 零 antd 依赖
|
||||
*/
|
||||
import React, { useState, useCallback } from "react"
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
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 React from "react"
|
||||
import { Button, Tag } from "@/components/ui"
|
||||
import PageHead from "@/components/layout/PageHead"
|
||||
|
||||
/** 格式化时间(秒 → mm:ss) */
|
||||
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")}`
|
||||
}
|
||||
|
||||
/** 格式化文件大小 */
|
||||
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>
|
||||
)
|
||||
}
|
||||
import { RiskCard } from "./components/RiskCard"
|
||||
import { InfoCard } from "./components/InfoCard"
|
||||
import { SegmentsSection } from "./components/SegmentsSection"
|
||||
import { useDuplicationDetail } from "./hooks/useDuplicationDetail"
|
||||
import { RISK_TAG_VARIANT, RISK_LABEL } from "./constants"
|
||||
import { formatSize, formatDuration } from "./utils"
|
||||
import "./duplication.css"
|
||||
|
||||
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 {
|
||||
data: detail,
|
||||
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")
|
||||
},
|
||||
})
|
||||
toast,
|
||||
riskLevel,
|
||||
similarityPercent,
|
||||
handleRetry,
|
||||
handleDownloadReport,
|
||||
handleBack,
|
||||
} = useDuplicationDetail()
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -155,7 +47,7 @@ const DuplicationDetail: React.FC = () => {
|
||||
<Button
|
||||
buttonType="primary"
|
||||
buttonSize="md"
|
||||
onClick={() => navigate("/app/duplication/results")}
|
||||
onClick={handleBack}
|
||||
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 (
|
||||
<div className="dup-page">
|
||||
{/* Toast */}
|
||||
@@ -194,21 +82,11 @@ const DuplicationDetail: React.FC = () => {
|
||||
}
|
||||
actions={
|
||||
<div className="dup-detail-actions" style={{ display: "flex", gap: 8 }}>
|
||||
<Button
|
||||
buttonType="secondary"
|
||||
buttonSize="md"
|
||||
onClick={() => {
|
||||
showToast("报告下载功能开发中", "warning")
|
||||
}}
|
||||
>
|
||||
<Button buttonType="secondary" buttonSize="md" onClick={handleDownloadReport}>
|
||||
📥 下载报告
|
||||
</Button>
|
||||
{detail.status === "failed" && (
|
||||
<Button
|
||||
buttonType="primary"
|
||||
buttonSize="md"
|
||||
onClick={() => retryMutation.mutate(detail.id)}
|
||||
>
|
||||
<Button buttonType="primary" buttonSize="md" onClick={handleRetry}>
|
||||
🔄 重新查重
|
||||
</Button>
|
||||
)}
|
||||
@@ -218,103 +96,9 @@ const DuplicationDetail: React.FC = () => {
|
||||
|
||||
{/* 内容网格 */}
|
||||
<div className="dup-detail-grid">
|
||||
{/* 风险评估卡片 */}
|
||||
<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>
|
||||
|
||||
{/* 基本信息卡片 */}
|
||||
<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>
|
||||
<RiskCard riskLevel={riskLevel} similarityPercent={similarityPercent} />
|
||||
<InfoCard detail={detail} />
|
||||
<SegmentsSection segments={detail.segments} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
Regular → Executable
+36
-184
@@ -1,116 +1,35 @@
|
||||
/**
|
||||
* 查重上传页面 — V21 设计系统
|
||||
* 左右分栏:拖拽上传区 + 格式说明
|
||||
* 零 antd 依赖
|
||||
*/
|
||||
import React, { useState, useRef, useCallback } from "react"
|
||||
import { useMutation } from "@tanstack/react-query"
|
||||
import { Button, Card, Tag } from "@/components/ui"
|
||||
import { uploadForDuplication } from "@/api/duplication"
|
||||
import React from "react"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import "./duplication.css"
|
||||
import { Card } from "@/components/ui"
|
||||
import PageHead from "@/components/layout/PageHead"
|
||||
|
||||
/** 支持的视频格式 */
|
||||
const ACCEPT_FORMATS = ".mp4,.avi,.mov,.mkv,.wmv,.flv,.webm"
|
||||
const FORMAT_LIST = ["MP4", "AVI", "MOV", "MKV", "WMV", "FLV", "WebM"]
|
||||
/** 最大文件大小:2GB */
|
||||
const MAX_FILE_SIZE = 2 * 1024 * 1024 * 1024
|
||||
|
||||
/** 简易 toast */
|
||||
interface ToastState {
|
||||
message: string
|
||||
type: "success" | "error" | "warning"
|
||||
}
|
||||
import UploadZone from "./duplication-upload/UploadZone"
|
||||
import UploadActions from "./duplication-upload/UploadActions"
|
||||
import UploadProgress from "./duplication-upload/UploadProgress"
|
||||
import UploadResultPanel from "./duplication-upload/UploadResultPanel"
|
||||
import InfoSidebar from "./duplication-upload/InfoSidebar"
|
||||
import { useDuplicationUpload } from "./duplication-upload/useDuplicationUpload"
|
||||
import { ACCEPT_FORMATS } from "./duplication-upload/constants"
|
||||
import "./duplication.css"
|
||||
|
||||
const DuplicationUpload: React.FC = () => {
|
||||
const navigate = useNavigate()
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const [dragging, setDragging] = useState(false)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [uploadResult, setUploadResult] = useState<{
|
||||
id: string
|
||||
message: string
|
||||
} | null>(null)
|
||||
const [toast, setToast] = useState<ToastState | null>(null)
|
||||
|
||||
/** 显示 toast */
|
||||
const showToast = useCallback((message: string, type: "success" | "error" | "warning") => {
|
||||
setToast({ message, type })
|
||||
setTimeout(() => setToast(null), 3000)
|
||||
}, [])
|
||||
|
||||
// 上传查重 mutation
|
||||
const uploadMutation = useMutation({
|
||||
mutationFn: (file: File) => uploadForDuplication(file),
|
||||
onSuccess: (data) => {
|
||||
setUploading(false)
|
||||
setUploadResult({ id: data.id, message: data.message })
|
||||
showToast("查重任务已提交", "success")
|
||||
},
|
||||
onError: () => {
|
||||
setUploading(false)
|
||||
showToast("上传失败,请重试", "error")
|
||||
},
|
||||
})
|
||||
|
||||
/** 校验并上传文件 */
|
||||
const handleFile = useCallback(
|
||||
(file: File) => {
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
showToast("文件大小不能超过 2GB", "error")
|
||||
return
|
||||
}
|
||||
const ext = file.name.toLowerCase().split(".").pop()
|
||||
const allowedExts = ACCEPT_FORMATS.replace(/\./g, "").split(",")
|
||||
if (!allowedExts.includes(ext || "")) {
|
||||
showToast(`不支持的文件格式,支持:${FORMAT_LIST.join("、")}`, "error")
|
||||
return
|
||||
}
|
||||
setUploading(true)
|
||||
setUploadResult(null)
|
||||
uploadMutation.mutate(file)
|
||||
},
|
||||
[uploadMutation, showToast],
|
||||
)
|
||||
|
||||
/** 拖拽事件 */
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setDragging(true)
|
||||
}, [])
|
||||
|
||||
const handleDragLeave = useCallback(() => {
|
||||
setDragging(false)
|
||||
}, [])
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setDragging(false)
|
||||
const file = e.dataTransfer.files[0]
|
||||
if (file) handleFile(file)
|
||||
},
|
||||
[handleFile],
|
||||
)
|
||||
|
||||
/** 点击选择文件 */
|
||||
const handleSelectFile = () => {
|
||||
fileInputRef.current?.click()
|
||||
}
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) handleFile(file)
|
||||
// 重置 input 以便重复选择同一文件
|
||||
e.target.value = ""
|
||||
}
|
||||
|
||||
/** 重置状态 */
|
||||
const handleReset = () => {
|
||||
setUploadResult(null)
|
||||
setUploading(false)
|
||||
}
|
||||
const {
|
||||
fileInputRef,
|
||||
dragging,
|
||||
uploading,
|
||||
uploadResult,
|
||||
toast,
|
||||
handleDragOver,
|
||||
handleDragLeave,
|
||||
handleDrop,
|
||||
handleSelectFile,
|
||||
handleFileChange,
|
||||
handleReset,
|
||||
} = useDuplicationUpload()
|
||||
|
||||
return (
|
||||
<div className="dup-page">
|
||||
@@ -125,25 +44,14 @@ const DuplicationUpload: React.FC = () => {
|
||||
<div className="dup-upload-grid">
|
||||
{/* 左侧:上传区域 */}
|
||||
<Card>
|
||||
{/* 拖拽上传区 */}
|
||||
<div
|
||||
className={`dup-upload-zone ${dragging ? "dragging" : ""} ${uploading ? "disabled" : ""}`}
|
||||
<UploadZone
|
||||
dragging={dragging}
|
||||
uploading={uploading}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onClick={uploading ? undefined : handleSelectFile}
|
||||
>
|
||||
<div className="dup-upload-icon">{uploading ? "⏳" : "📁"}</div>
|
||||
<h3>{uploading ? "正在上传并查重..." : "点击或拖拽视频文件到此区域"}</h3>
|
||||
<p>支持 MP4、AVI、MOV、MKV 等格式,单个文件不超过 2GB</p>
|
||||
<div className="dup-upload-formats">
|
||||
{FORMAT_LIST.map((fmt) => (
|
||||
<Tag key={fmt} variant="info">
|
||||
{fmt}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
onClick={handleSelectFile}
|
||||
/>
|
||||
|
||||
{/* 隐藏的文件 input */}
|
||||
<input
|
||||
@@ -154,77 +62,21 @@ const DuplicationUpload: React.FC = () => {
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
|
||||
{/* 上传按钮 */}
|
||||
<div className="dup-upload-actions">
|
||||
<Button
|
||||
buttonType="primary"
|
||||
buttonSize="md"
|
||||
onClick={handleSelectFile}
|
||||
disabled={uploading}
|
||||
>
|
||||
📂 选择文件
|
||||
</Button>
|
||||
</div>
|
||||
<UploadActions uploading={uploading} onSelectFile={handleSelectFile} />
|
||||
|
||||
{/* 上传进度 */}
|
||||
{uploading && (
|
||||
<div className="dup-progress">
|
||||
<div className="dup-progress-circle">
|
||||
<span className="dup-progress-icon">⏳</span>
|
||||
<span className="dup-progress-text">查重中...</span>
|
||||
</div>
|
||||
<p>正在分析视频内容,请稍候...</p>
|
||||
</div>
|
||||
)}
|
||||
{uploading && <UploadProgress />}
|
||||
|
||||
{/* 上传结果 */}
|
||||
{uploadResult && !uploading && (
|
||||
<div className="dup-result">
|
||||
<div className="dup-result-icon">✅</div>
|
||||
<h3>查重任务已提交</h3>
|
||||
<p>{uploadResult.message}</p>
|
||||
<div className="dup-result-actions">
|
||||
<Button
|
||||
buttonType="primary"
|
||||
buttonSize="md"
|
||||
onClick={() => navigate("/app/duplication/results")}
|
||||
>
|
||||
查看结果
|
||||
</Button>
|
||||
<Button buttonType="secondary" buttonSize="md" onClick={handleReset}>
|
||||
继续上传
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<UploadResultPanel
|
||||
result={uploadResult}
|
||||
onViewResult={() => navigate("/app/duplication/results")}
|
||||
onReset={handleReset}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* 右侧:格式说明 + 提示 */}
|
||||
<div className="dup-info-card">
|
||||
<h3>📋 查重说明</h3>
|
||||
<ul className="dup-info-list">
|
||||
<li>系统会对比您上传的视频与视频库中的已有视频</li>
|
||||
<li>查重完成后,可查看重复片段的具体位置</li>
|
||||
<li>查重过程通常需要几分钟,取决于视频大小</li>
|
||||
<li>高相似度片段建议进行替换或裁剪</li>
|
||||
</ul>
|
||||
|
||||
<h3 style={{ marginTop: 24 }}>🎬 支持格式</h3>
|
||||
<div className="dup-format-tags">
|
||||
{FORMAT_LIST.map((fmt) => (
|
||||
<Tag key={fmt} variant="info">
|
||||
{fmt}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<h3 style={{ marginTop: 24 }}>💡 温馨提示</h3>
|
||||
<ul className="dup-info-list">
|
||||
<li>单个文件不超过 2GB</li>
|
||||
<li>视频时长建议不超过 60 分钟</li>
|
||||
<li>查重结果可在「查重记录」中随时查看</li>
|
||||
</ul>
|
||||
</div>
|
||||
<InfoSidebar />
|
||||
</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>
|
||||
)
|
||||
Regular → Executable
+34
-1
@@ -16,7 +16,28 @@ export const STATUS_CONFIG: Record<
|
||||
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> = {
|
||||
low: "低风险",
|
||||
medium: "中风险",
|
||||
@@ -29,3 +50,15 @@ export const FILTER_OPTIONS: { key: RiskFilter; label: string }[] = [
|
||||
{ key: "medium", label: "中风险" },
|
||||
{ key: "high", label: "高风险" },
|
||||
]
|
||||
|
||||
/** 支持的视频格式 */
|
||||
export const ACCEPT_FORMATS = ".mp4,.avi,.mov,.mkv,.wmv,.flv,.webm"
|
||||
export const FORMAT_LIST = ["MP4", "AVI", "MOV", "MKV", "WMV", "FLV", "WebM"]
|
||||
/** 最大文件大小:2GB */
|
||||
export const MAX_FILE_SIZE = 2 * 1024 * 1024 * 1024
|
||||
|
||||
/** 简易 toast */
|
||||
export interface ToastState {
|
||||
message: string
|
||||
type: "success" | "error" | "warning"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import React from "react"
|
||||
import { Tag } from "@/components/ui"
|
||||
import { FORMAT_LIST } from "./constants"
|
||||
|
||||
/** 右侧说明卡 */
|
||||
const InfoSidebar: React.FC = () => {
|
||||
return (
|
||||
<div className="dup-info-card">
|
||||
<h3>📋 查重说明</h3>
|
||||
<ul className="dup-info-list">
|
||||
<li>系统会对比您上传的视频与视频库中的已有视频</li>
|
||||
<li>查重完成后,可查看重复片段的具体位置</li>
|
||||
<li>查重过程通常需要几分钟,取决于视频大小</li>
|
||||
<li>高相似度片段建议进行替换或裁剪</li>
|
||||
</ul>
|
||||
|
||||
<h3 style={{ marginTop: 24 }}>🎬 支持格式</h3>
|
||||
<div className="dup-format-tags">
|
||||
{FORMAT_LIST.map((fmt) => (
|
||||
<Tag key={fmt} variant="info">
|
||||
{fmt}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<h3 style={{ marginTop: 24 }}>💡 温馨提示</h3>
|
||||
<ul className="dup-info-list">
|
||||
<li>单个文件不超过 2GB</li>
|
||||
<li>视频时长建议不超过 60 分钟</li>
|
||||
<li>查重结果可在「查重记录」中随时查看</li>
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default InfoSidebar
|
||||
@@ -0,0 +1,20 @@
|
||||
import React from "react"
|
||||
import { Button } from "@/components/ui"
|
||||
|
||||
interface UploadActionsProps {
|
||||
uploading: boolean
|
||||
onSelectFile: () => void
|
||||
}
|
||||
|
||||
/** 上传按钮区 */
|
||||
const UploadActions: React.FC<UploadActionsProps> = ({ uploading, onSelectFile }) => {
|
||||
return (
|
||||
<div className="dup-upload-actions">
|
||||
<Button buttonType="primary" buttonSize="md" onClick={onSelectFile} disabled={uploading}>
|
||||
📂 选择文件
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default UploadActions
|
||||
@@ -0,0 +1,16 @@
|
||||
import React from "react"
|
||||
|
||||
/** 上传进度展示 */
|
||||
const UploadProgress: React.FC = () => {
|
||||
return (
|
||||
<div className="dup-progress">
|
||||
<div className="dup-progress-circle">
|
||||
<span className="dup-progress-icon">⏳</span>
|
||||
<span className="dup-progress-text">查重中...</span>
|
||||
</div>
|
||||
<p>正在分析视频内容,请稍候...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default UploadProgress
|
||||
@@ -0,0 +1,30 @@
|
||||
import React from "react"
|
||||
import { Button } from "@/components/ui"
|
||||
import type { UploadResult } from "./constants"
|
||||
|
||||
interface UploadResultPanelProps {
|
||||
result: UploadResult
|
||||
onViewResult: () => void
|
||||
onReset: () => void
|
||||
}
|
||||
|
||||
/** 上传结果展示 */
|
||||
const UploadResultPanel: React.FC<UploadResultPanelProps> = ({ result, onViewResult, onReset }) => {
|
||||
return (
|
||||
<div className="dup-result">
|
||||
<div className="dup-result-icon">✅</div>
|
||||
<h3>查重任务已提交</h3>
|
||||
<p>{result.message}</p>
|
||||
<div className="dup-result-actions">
|
||||
<Button buttonType="primary" buttonSize="md" onClick={onViewResult}>
|
||||
查看结果
|
||||
</Button>
|
||||
<Button buttonType="secondary" buttonSize="md" onClick={onReset}>
|
||||
继续上传
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default UploadResultPanel
|
||||
@@ -0,0 +1,45 @@
|
||||
import React from "react"
|
||||
import { Tag } from "@/components/ui"
|
||||
import { FORMAT_LIST } from "./constants"
|
||||
|
||||
interface UploadZoneProps {
|
||||
dragging: boolean
|
||||
uploading: boolean
|
||||
onDragOver: (e: React.DragEvent) => void
|
||||
onDragLeave: () => void
|
||||
onDrop: (e: React.DragEvent) => void
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
/** 拖拽上传区 */
|
||||
const UploadZone: React.FC<UploadZoneProps> = ({
|
||||
dragging,
|
||||
uploading,
|
||||
onDragOver,
|
||||
onDragLeave,
|
||||
onDrop,
|
||||
onClick,
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={`dup-upload-zone ${dragging ? "dragging" : ""} ${uploading ? "disabled" : ""}`}
|
||||
onDragOver={onDragOver}
|
||||
onDragLeave={onDragLeave}
|
||||
onDrop={onDrop}
|
||||
onClick={uploading ? undefined : onClick}
|
||||
>
|
||||
<div className="dup-upload-icon">{uploading ? "⏳" : "📁"}</div>
|
||||
<h3>{uploading ? "正在上传并查重..." : "点击或拖拽视频文件到此区域"}</h3>
|
||||
<p>支持 MP4、AVI、MOV、MKV 等格式,单个文件不超过 2GB</p>
|
||||
<div className="dup-upload-formats">
|
||||
{FORMAT_LIST.map((fmt) => (
|
||||
<Tag key={fmt} variant="info">
|
||||
{fmt}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default UploadZone
|
||||
@@ -0,0 +1,17 @@
|
||||
/** 支持的视频格式 */
|
||||
export const ACCEPT_FORMATS = ".mp4,.avi,.mov,.mkv,.wmv,.flv,.webm"
|
||||
export const FORMAT_LIST = ["MP4", "AVI", "MOV", "MKV", "WMV", "FLV", "WebM"]
|
||||
/** 最大文件大小:2GB */
|
||||
export const MAX_FILE_SIZE = 2 * 1024 * 1024 * 1024
|
||||
|
||||
/** 简易 toast */
|
||||
export interface ToastState {
|
||||
message: string
|
||||
type: "success" | "error" | "warning"
|
||||
}
|
||||
|
||||
/** 上传结果 */
|
||||
export interface UploadResult {
|
||||
id: string
|
||||
message: string
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { useState, useRef, useCallback } from "react"
|
||||
import { useMutation } from "@tanstack/react-query"
|
||||
import { uploadForDuplication } from "@/api/duplication"
|
||||
import { ACCEPT_FORMATS, FORMAT_LIST, MAX_FILE_SIZE } from "./constants"
|
||||
import type { ToastState, UploadResult } from "./constants"
|
||||
|
||||
/**
|
||||
* 查重上传逻辑 Hook
|
||||
* 封装文件校验、上传 mutation、toast 提示
|
||||
*/
|
||||
export function useDuplicationUpload() {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const [dragging, setDragging] = useState(false)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [uploadResult, setUploadResult] = useState<UploadResult | null>(null)
|
||||
const [toast, setToast] = useState<ToastState | null>(null)
|
||||
|
||||
/** 显示 toast */
|
||||
const showToast = useCallback((message: string, type: "success" | "error" | "warning") => {
|
||||
setToast({ message, type })
|
||||
setTimeout(() => setToast(null), 3000)
|
||||
}, [])
|
||||
|
||||
// 上传查重 mutation
|
||||
const uploadMutation = useMutation({
|
||||
mutationFn: (file: File) => uploadForDuplication(file),
|
||||
onSuccess: (data) => {
|
||||
setUploading(false)
|
||||
setUploadResult({ id: data.id, message: data.message })
|
||||
showToast("查重任务已提交", "success")
|
||||
},
|
||||
onError: () => {
|
||||
setUploading(false)
|
||||
showToast("上传失败,请重试", "error")
|
||||
},
|
||||
})
|
||||
|
||||
/** 校验并上传文件 */
|
||||
const handleFile = useCallback(
|
||||
(file: File) => {
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
showToast("文件大小不能超过 2GB", "error")
|
||||
return
|
||||
}
|
||||
const ext = file.name.toLowerCase().split(".").pop()
|
||||
const allowedExts = ACCEPT_FORMATS.replace(/\./g, "").split(",")
|
||||
if (!allowedExts.includes(ext || "")) {
|
||||
showToast(`不支持的文件格式,支持:${FORMAT_LIST.join("、")}`, "error")
|
||||
return
|
||||
}
|
||||
setUploading(true)
|
||||
setUploadResult(null)
|
||||
uploadMutation.mutate(file)
|
||||
},
|
||||
[uploadMutation, showToast],
|
||||
)
|
||||
|
||||
/** 拖拽事件 */
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setDragging(true)
|
||||
}, [])
|
||||
|
||||
const handleDragLeave = useCallback(() => {
|
||||
setDragging(false)
|
||||
}, [])
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setDragging(false)
|
||||
const file = e.dataTransfer.files[0]
|
||||
if (file) handleFile(file)
|
||||
},
|
||||
[handleFile],
|
||||
)
|
||||
|
||||
/** 点击选择文件 */
|
||||
const handleSelectFile = useCallback(() => {
|
||||
fileInputRef.current?.click()
|
||||
}, [])
|
||||
|
||||
const handleFileChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) handleFile(file)
|
||||
// 重置 input 以便重复选择同一文件
|
||||
e.target.value = ""
|
||||
},
|
||||
[handleFile],
|
||||
)
|
||||
|
||||
/** 重置状态 */
|
||||
const handleReset = useCallback(() => {
|
||||
setUploadResult(null)
|
||||
setUploading(false)
|
||||
}, [])
|
||||
|
||||
return {
|
||||
// refs
|
||||
fileInputRef,
|
||||
// 状态
|
||||
dragging,
|
||||
uploading,
|
||||
uploadResult,
|
||||
toast,
|
||||
// 事件
|
||||
handleDragOver,
|
||||
handleDragLeave,
|
||||
handleDrop,
|
||||
handleSelectFile,
|
||||
handleFileChange,
|
||||
handleReset,
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
/** 格式化时间(秒 → 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) => {
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`
|
||||
|
||||
@@ -1,238 +1,6 @@
|
||||
/**
|
||||
* 生成进度弹窗 — 任务 2.17
|
||||
* 三阶段 UI:setup(配置)→ progress(进度轮询)→ completed / failed(结果)
|
||||
* V21 设计系统,CSS 类名前缀 ep-gen-
|
||||
* 生成进度弹窗 — 入口文件(向后兼容)
|
||||
* 实际实现已移至 ./generation-progress-modal/ 目录
|
||||
*/
|
||||
import React, { useEffect, useRef } from "react"
|
||||
import { Modal, Button } from "@/components/ui"
|
||||
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
|
||||
export { default } from "./generation-progress-modal"
|
||||
export type { GenPhase, GenerationProgressModalProps } from "./generation-progress-modal"
|
||||
|
||||
Regular → Executable
+21
-130
@@ -1,15 +1,11 @@
|
||||
/**
|
||||
* 绿幕抠像配置面板
|
||||
* 5 种颜色预设 + 自定义颜色 + 相似度/边缘平滑/溢色抑制
|
||||
*/
|
||||
import React, { useCallback } from "react"
|
||||
import { Drawer, Switch } from "antd"
|
||||
import type { ChromaKeyConfig, ChromaKeyColorPreset } from "../types"
|
||||
import {
|
||||
DEFAULT_CHROMA_KEY_CONFIG,
|
||||
CHROMA_KEY_PRESET_LABELS,
|
||||
CHROMA_KEY_PRESET_COLORS,
|
||||
} from "../types"
|
||||
import { DEFAULT_CHROMA_KEY_CONFIG, CHROMA_KEY_PRESET_COLORS } from "../types"
|
||||
import { GreenScreenPresets } from "./green-screen/GreenScreenPresets"
|
||||
import { GreenScreenCustomColor } from "./green-screen/GreenScreenCustomColor"
|
||||
import { GreenScreenSliders } from "./green-screen/GreenScreenSliders"
|
||||
import { GreenScreenPreview } from "./green-screen/GreenScreenPreview"
|
||||
|
||||
interface GreenScreenPanelProps {
|
||||
open: boolean
|
||||
@@ -18,9 +14,6 @@ interface GreenScreenPanelProps {
|
||||
onChange: (config: ChromaKeyConfig) => void
|
||||
}
|
||||
|
||||
/** 预设列表 */
|
||||
const PRESET_LIST: ChromaKeyColorPreset[] = ["green", "blue", "red", "pure_green", "soft_green"]
|
||||
|
||||
const GreenScreenPanel: React.FC<GreenScreenPanelProps> = ({ open, onClose, config, onChange }) => {
|
||||
const update = useCallback(
|
||||
(partial: Partial<ChromaKeyConfig>) => {
|
||||
@@ -33,7 +26,6 @@ const GreenScreenPanel: React.FC<GreenScreenPanelProps> = ({ open, onClose, conf
|
||||
onChange({ ...DEFAULT_CHROMA_KEY_CONFIG, enabled: config.enabled })
|
||||
}, [config.enabled, onChange])
|
||||
|
||||
/** 选择颜色预设时同步更新 color 字段 */
|
||||
const handlePresetSelect = useCallback(
|
||||
(preset: ChromaKeyColorPreset) => {
|
||||
update({
|
||||
@@ -44,10 +36,9 @@ const GreenScreenPanel: React.FC<GreenScreenPanelProps> = ({ open, onClose, conf
|
||||
[update],
|
||||
)
|
||||
|
||||
/** 自定义颜色变化时清除预设标记 */
|
||||
const handleColorChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
update({ color: e.target.value })
|
||||
(color: string) => {
|
||||
update({ color })
|
||||
},
|
||||
[update],
|
||||
)
|
||||
@@ -61,7 +52,6 @@ const GreenScreenPanel: React.FC<GreenScreenPanelProps> = ({ open, onClose, conf
|
||||
onClose={onClose}
|
||||
className="green-screen-panel-drawer"
|
||||
>
|
||||
{/* 顶部开关 */}
|
||||
<div className="green-header">
|
||||
<span className="green-header-label">启用绿幕抠像</span>
|
||||
<Switch
|
||||
@@ -71,123 +61,24 @@ const GreenScreenPanel: React.FC<GreenScreenPanelProps> = ({ open, onClose, conf
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 颜色预设 */}
|
||||
<div className="green-section">
|
||||
<div className="green-section-title">颜色预设</div>
|
||||
<div className="green-presets">
|
||||
{PRESET_LIST.map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
className={`green-preset-btn${config.color_preset === p ? " active" : ""}`}
|
||||
onClick={() => handlePresetSelect(p)}
|
||||
>
|
||||
<span
|
||||
className="green-preset-dot"
|
||||
style={{ background: CHROMA_KEY_PRESET_COLORS[p] }}
|
||||
/>
|
||||
<span className="green-preset-label">{CHROMA_KEY_PRESET_LABELS[p]}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<GreenScreenPresets
|
||||
selectedPreset={config.color_preset}
|
||||
onPresetSelect={handlePresetSelect}
|
||||
/>
|
||||
|
||||
{/* 自定义颜色 */}
|
||||
<div className="green-section">
|
||||
<div className="green-section-title">自定义颜色</div>
|
||||
<div className="green-color-row">
|
||||
<input
|
||||
type="color"
|
||||
className="green-color-picker"
|
||||
value={config.color}
|
||||
onChange={handleColorChange}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
className="green-color-hex"
|
||||
value={config.color}
|
||||
onChange={handleColorChange}
|
||||
placeholder="#00FF00"
|
||||
/>
|
||||
<div className="green-color-swatch" style={{ background: config.color }} />
|
||||
</div>
|
||||
</div>
|
||||
<GreenScreenCustomColor color={config.color} onColorChange={handleColorChange} />
|
||||
|
||||
{/* 参数调节 */}
|
||||
<div className="green-section">
|
||||
<div className="green-section-title">参数调节</div>
|
||||
<GreenScreenSliders
|
||||
similarity={config.similarity}
|
||||
blend={config.blend}
|
||||
spill={config.spill}
|
||||
onSimilarityChange={(v) => update({ similarity: v })}
|
||||
onBlendChange={(v) => update({ blend: v })}
|
||||
onSpillChange={(v) => update({ spill: v })}
|
||||
/>
|
||||
|
||||
{/* 相似度 */}
|
||||
<div className="green-slider-row">
|
||||
<div className="green-slider-header">
|
||||
<span className="green-slider-label">相似度</span>
|
||||
<span className="green-slider-value">{config.similarity}%</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
className="green-slider"
|
||||
min={0}
|
||||
max={100}
|
||||
value={config.similarity}
|
||||
onChange={(e) => update({ similarity: Number(e.target.value) })}
|
||||
/>
|
||||
<div className="green-slider-desc">越大容忍的色差范围越广</div>
|
||||
</div>
|
||||
<GreenScreenPreview color={config.color} blend={config.blend} />
|
||||
|
||||
{/* 边缘平滑 */}
|
||||
<div className="green-slider-row">
|
||||
<div className="green-slider-header">
|
||||
<span className="green-slider-label">边缘平滑</span>
|
||||
<span className="green-slider-value">{config.blend}%</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
className="green-slider"
|
||||
min={0}
|
||||
max={100}
|
||||
value={config.blend}
|
||||
onChange={(e) => update({ blend: Number(e.target.value) })}
|
||||
/>
|
||||
<div className="green-slider-desc">越大边缘越柔和自然</div>
|
||||
</div>
|
||||
|
||||
{/* 溢色抑制 */}
|
||||
<div className="green-slider-row">
|
||||
<div className="green-slider-header">
|
||||
<span className="green-slider-label">溢色抑制</span>
|
||||
<span className="green-slider-value">{config.spill}%</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
className="green-slider"
|
||||
min={0}
|
||||
max={100}
|
||||
value={config.spill}
|
||||
onChange={(e) => update({ spill: Number(e.target.value) })}
|
||||
/>
|
||||
<div className="green-slider-desc">去除边缘颜色溢出</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 预览 */}
|
||||
<div className="green-section">
|
||||
<div className="green-section-title">效果预览</div>
|
||||
<div className="green-preview-box">
|
||||
<div className="green-preview-bg" style={{ background: config.color, opacity: 0.3 }} />
|
||||
<div className="green-preview-subject">
|
||||
<div className="green-preview-circle" />
|
||||
<div className="green-preview-text">主体</div>
|
||||
</div>
|
||||
<div
|
||||
className="green-preview-edge"
|
||||
style={{
|
||||
borderColor: config.color,
|
||||
filter: `blur(${config.blend / 10}px)`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 底部重置 */}
|
||||
<div className="green-footer">
|
||||
<button className="green-reset-btn" onClick={handleReset}>
|
||||
重置参数
|
||||
|
||||
Regular → Executable
+14
-58
@@ -1,18 +1,16 @@
|
||||
/**
|
||||
* 字幕样式配置面板 — Drawer 形式
|
||||
* 字幕开关(手动 / ASR 自动识别)、字体大小、颜色、描边/阴影、位置、ASR 语言
|
||||
*/
|
||||
import React from "react"
|
||||
import { Drawer, Slider, ColorPicker, Select } from "antd"
|
||||
import type { Color } from "antd/es/color-picker"
|
||||
import type { SubtitleStyleConfig } from "@/pages/editing-planner/types/subtitle"
|
||||
import {
|
||||
POSITION_OPTIONS,
|
||||
FONT_OPTIONS,
|
||||
ANIMATION_OPTIONS,
|
||||
ASR_LANGUAGE_OPTIONS,
|
||||
} from "@/pages/editing-planner/constants/subtitleStyle"
|
||||
import SubtitlePreview from "./subtitle-style/SubtitlePreview"
|
||||
import { SubtitleModeSwitch } from "./subtitle-style/SubtitleModeSwitch"
|
||||
import { SubtitlePositionSelector } from "./subtitle-style/SubtitlePositionSelector"
|
||||
import { SubtitleEffectButtons } from "./subtitle-style/SubtitleEffectButtons"
|
||||
|
||||
interface SubtitleStylePanelProps {
|
||||
open: boolean
|
||||
@@ -40,7 +38,6 @@ const SubtitleStylePanel: React.FC<SubtitleStylePanelProps> = ({
|
||||
onClose={onClose}
|
||||
className="subtitle-style-drawer"
|
||||
>
|
||||
{/* ── 字幕开关 ── */}
|
||||
<div className="sub-field">
|
||||
<div className="sub-toggle-row">
|
||||
<span className="sub-label">启用字幕</span>
|
||||
@@ -55,26 +52,11 @@ const SubtitleStylePanel: React.FC<SubtitleStylePanelProps> = ({
|
||||
|
||||
{config.enabled && (
|
||||
<>
|
||||
{/* ── 模式切换 ── */}
|
||||
<div className="sub-field">
|
||||
<label className="sub-label">字幕来源</label>
|
||||
<div className="sub-mode-switch">
|
||||
<button
|
||||
className={`sub-mode-btn${config.mode === "manual" ? " active" : ""}`}
|
||||
onClick={() => update({ mode: "manual" })}
|
||||
>
|
||||
✏️ 手动输入
|
||||
</button>
|
||||
<button
|
||||
className={`sub-mode-btn${config.mode === "asr" ? " active" : ""}`}
|
||||
onClick={() => update({ mode: "asr" })}
|
||||
>
|
||||
🤖 ASR 识别
|
||||
</button>
|
||||
</div>
|
||||
<SubtitleModeSwitch mode={config.mode} onModeChange={(mode) => update({ mode })} />
|
||||
</div>
|
||||
|
||||
{/* ── ASR 语言(仅 ASR 模式) ── */}
|
||||
{config.mode === "asr" && (
|
||||
<div className="sub-field">
|
||||
<label className="sub-label">识别语言</label>
|
||||
@@ -88,7 +70,6 @@ const SubtitleStylePanel: React.FC<SubtitleStylePanelProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── 字体大小 ── */}
|
||||
<div className="sub-field">
|
||||
<label className="sub-label">
|
||||
字体大小 <span className="sub-value">{config.fontSize}px</span>
|
||||
@@ -101,7 +82,6 @@ const SubtitleStylePanel: React.FC<SubtitleStylePanelProps> = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── 字体颜色 ── */}
|
||||
<div className="sub-field">
|
||||
<label className="sub-label">字体颜色</label>
|
||||
<div className="sub-color-row">
|
||||
@@ -113,7 +93,6 @@ const SubtitleStylePanel: React.FC<SubtitleStylePanelProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 字体 ── */}
|
||||
<div className="sub-field">
|
||||
<label className="sub-label">字体</label>
|
||||
<Select
|
||||
@@ -125,46 +104,24 @@ const SubtitleStylePanel: React.FC<SubtitleStylePanelProps> = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── 字幕位置 ── */}
|
||||
<div className="sub-field">
|
||||
<label className="sub-label">位置</label>
|
||||
<div className="sub-position-group">
|
||||
{POSITION_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
className={`sub-position-btn${config.position === opt.value ? " active" : ""}`}
|
||||
onClick={() =>
|
||||
update({
|
||||
position: opt.value as SubtitleStyleConfig["position"],
|
||||
})
|
||||
}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<SubtitlePositionSelector
|
||||
position={config.position}
|
||||
onPositionChange={(position) => update({ position })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── 描边 / 阴影 ── */}
|
||||
<div className="sub-field">
|
||||
<label className="sub-label">效果</label>
|
||||
<div className="sub-effect-btns">
|
||||
<button
|
||||
className={`sub-effect-btn${config.stroke ? " active" : ""}`}
|
||||
onClick={() => update({ stroke: !config.stroke })}
|
||||
>
|
||||
S 描边
|
||||
</button>
|
||||
<button
|
||||
className={`sub-effect-btn${config.shadow ? " active" : ""}`}
|
||||
onClick={() => update({ shadow: !config.shadow })}
|
||||
>
|
||||
☁ 阴影
|
||||
</button>
|
||||
</div>
|
||||
<SubtitleEffectButtons
|
||||
stroke={config.stroke}
|
||||
shadow={config.shadow}
|
||||
onStrokeChange={(stroke) => update({ stroke })}
|
||||
onShadowChange={(shadow) => update({ shadow })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── 动画 ── */}
|
||||
<div className="sub-field">
|
||||
<label className="sub-label">动画效果</label>
|
||||
<Select
|
||||
@@ -179,7 +136,6 @@ const SubtitleStylePanel: React.FC<SubtitleStylePanelProps> = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── 预览 ── */}
|
||||
<SubtitlePreview config={config} />
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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 {
|
||||
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 { DEFAULT_PIXELS_PER_SECOND } from "../constants/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 { TimeRuler } from "./timeline/TimeRuler"
|
||||
import { AddClipPicker } from "./timeline/AddClipPicker"
|
||||
@@ -55,25 +47,6 @@ interface TimelinePanelProps {
|
||||
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> = ({
|
||||
clips,
|
||||
selectedClipId,
|
||||
@@ -91,143 +64,51 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
|
||||
onSeek,
|
||||
totalDuration: totalDurationProp,
|
||||
}) => {
|
||||
const [dragIdx, setDragIdx] = useState<number | null>(null)
|
||||
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null)
|
||||
const dragRef = useRef<number | null>(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,
|
||||
})
|
||||
/* ── 缩放 & 时长 ── */
|
||||
const pps = pixelsPerSecond ?? DEFAULT_PIXELS_PER_SECOND
|
||||
const totalDuration = totalDurationProp ?? clips.reduce((s, c) => s + c.duration, 0)
|
||||
|
||||
/* ── 裁剪拖拽状态 ── */
|
||||
const [trimDrag, setTrimDrag] = useState<TrimDragState | null>(null)
|
||||
const [trimPreview, setTrimPreview] = useState<{
|
||||
clipId: string
|
||||
startTime: number
|
||||
endTime: number
|
||||
duration: number
|
||||
x: number
|
||||
y: number
|
||||
} | null>(null)
|
||||
/* ── 裁剪拖拽 ── */
|
||||
const { trimDrag, trimPreview, handleTrimHandleMouseDown } = useTrimDrag(clips, pps, onClipTrim)
|
||||
|
||||
/* ── 右键菜单 ── */
|
||||
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null)
|
||||
const contextMenuRef = useRef<HTMLDivElement>(null)
|
||||
/* ── 片段拖拽排序 ── */
|
||||
const {
|
||||
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 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 ── */
|
||||
useEffect(() => {
|
||||
if (!playheadDragging) return
|
||||
@@ -271,165 +152,6 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
|
||||
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 (
|
||||
<div className="ep-timeline-area">
|
||||
{/* 时间线头部 */}
|
||||
@@ -443,7 +165,7 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
|
||||
<div className="ep-timeline-zoom">
|
||||
<button
|
||||
className="ep-zoom-btn"
|
||||
onClick={() => onZoomChange?.(Math.max(MIN_PIXELS_PER_SECOND, pps - ZOOM_STEP))}
|
||||
onClick={() => onZoomChange?.(Math.max(10, pps - 10))}
|
||||
title="缩小"
|
||||
>
|
||||
−
|
||||
@@ -451,15 +173,15 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
|
||||
<input
|
||||
type="range"
|
||||
className="ep-zoom-slider"
|
||||
min={MIN_PIXELS_PER_SECOND}
|
||||
max={MAX_PIXELS_PER_SECOND}
|
||||
min={10}
|
||||
max={120}
|
||||
step={5}
|
||||
value={pps}
|
||||
onChange={(e) => onZoomChange?.(Number(e.target.value))}
|
||||
/>
|
||||
<button
|
||||
className="ep-zoom-btn"
|
||||
onClick={() => onZoomChange?.(Math.min(MAX_PIXELS_PER_SECOND, pps + ZOOM_STEP))}
|
||||
onClick={() => onZoomChange?.(Math.min(120, pps + 10))}
|
||||
title="放大"
|
||||
>
|
||||
+
|
||||
@@ -583,8 +305,6 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
|
||||
availableTypes={availableTypes}
|
||||
addType={addType}
|
||||
addDuration={addDuration}
|
||||
minDuration={MIN_ADD_DURATION}
|
||||
maxDuration={MAX_ADD_DURATION}
|
||||
onTypeChange={setAddType}
|
||||
onDurationChange={setAddDuration}
|
||||
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
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
import React from "react"
|
||||
|
||||
interface GreenScreenCustomColorProps {
|
||||
color: string
|
||||
onColorChange: (color: string) => void
|
||||
}
|
||||
|
||||
export const GreenScreenCustomColor: React.FC<GreenScreenCustomColorProps> = ({
|
||||
color,
|
||||
onColorChange,
|
||||
}) => {
|
||||
const handleColorInput = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onColorChange(e.target.value)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="green-section">
|
||||
<div className="green-section-title">自定义颜色</div>
|
||||
<div className="green-color-row">
|
||||
<input
|
||||
type="color"
|
||||
className="green-color-picker"
|
||||
value={color}
|
||||
onChange={handleColorInput}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
className="green-color-hex"
|
||||
value={color}
|
||||
onChange={handleColorInput}
|
||||
placeholder="#00FF00"
|
||||
/>
|
||||
<div className="green-color-swatch" style={{ background: color }} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
import React from "react"
|
||||
import type { ChromaKeyColorPreset } from "../../types"
|
||||
import { CHROMA_KEY_PRESET_LABELS, CHROMA_KEY_PRESET_COLORS } from "../../types"
|
||||
|
||||
interface GreenScreenPresetsProps {
|
||||
selectedPreset: ChromaKeyColorPreset | null
|
||||
onPresetSelect: (preset: ChromaKeyColorPreset) => void
|
||||
}
|
||||
|
||||
const PRESET_LIST: ChromaKeyColorPreset[] = ["green", "blue", "red", "pure_green", "soft_green"]
|
||||
|
||||
export const GreenScreenPresets: React.FC<GreenScreenPresetsProps> = ({
|
||||
selectedPreset,
|
||||
onPresetSelect,
|
||||
}) => {
|
||||
return (
|
||||
<div className="green-section">
|
||||
<div className="green-section-title">颜色预设</div>
|
||||
<div className="green-presets">
|
||||
{PRESET_LIST.map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
className={`green-preset-btn${selectedPreset === p ? " active" : ""}`}
|
||||
onClick={() => onPresetSelect(p)}
|
||||
>
|
||||
<span
|
||||
className="green-preset-dot"
|
||||
style={{ background: CHROMA_KEY_PRESET_COLORS[p] }}
|
||||
/>
|
||||
<span className="green-preset-label">{CHROMA_KEY_PRESET_LABELS[p]}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
import React from "react"
|
||||
|
||||
interface GreenScreenPreviewProps {
|
||||
color: string
|
||||
blend: number
|
||||
}
|
||||
|
||||
export const GreenScreenPreview: React.FC<GreenScreenPreviewProps> = ({ color, blend }) => {
|
||||
return (
|
||||
<div className="green-section">
|
||||
<div className="green-section-title">效果预览</div>
|
||||
<div className="green-preview-box">
|
||||
<div className="green-preview-bg" style={{ background: color, opacity: 0.3 }} />
|
||||
<div className="green-preview-subject">
|
||||
<div className="green-preview-circle" />
|
||||
<div className="green-preview-text">主体</div>
|
||||
</div>
|
||||
<div
|
||||
className="green-preview-edge"
|
||||
style={{
|
||||
borderColor: color,
|
||||
filter: `blur(${blend / 10}px)`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
import React from "react"
|
||||
|
||||
interface SliderConfig {
|
||||
label: string
|
||||
value: number
|
||||
description: string
|
||||
onChange: (value: number) => void
|
||||
}
|
||||
|
||||
interface GreenScreenSlidersProps {
|
||||
similarity: number
|
||||
blend: number
|
||||
spill: number
|
||||
onSimilarityChange: (value: number) => void
|
||||
onBlendChange: (value: number) => void
|
||||
onSpillChange: (value: number) => void
|
||||
}
|
||||
|
||||
const SliderRow: React.FC<SliderConfig> = ({ label, value, description, onChange }) => (
|
||||
<div className="green-slider-row">
|
||||
<div className="green-slider-header">
|
||||
<span className="green-slider-label">{label}</span>
|
||||
<span className="green-slider-value">{value}%</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
className="green-slider"
|
||||
min={0}
|
||||
max={100}
|
||||
value={value}
|
||||
onChange={(e) => onChange(Number(e.target.value))}
|
||||
/>
|
||||
<div className="green-slider-desc">{description}</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
export const GreenScreenSliders: React.FC<GreenScreenSlidersProps> = ({
|
||||
similarity,
|
||||
blend,
|
||||
spill,
|
||||
onSimilarityChange,
|
||||
onBlendChange,
|
||||
onSpillChange,
|
||||
}) => {
|
||||
return (
|
||||
<div className="green-section">
|
||||
<div className="green-section-title">参数调节</div>
|
||||
<SliderRow
|
||||
label="相似度"
|
||||
value={similarity}
|
||||
description="越大容忍的色差范围越广"
|
||||
onChange={onSimilarityChange}
|
||||
/>
|
||||
<SliderRow
|
||||
label="边缘平滑"
|
||||
value={blend}
|
||||
description="越大边缘越柔和自然"
|
||||
onChange={onBlendChange}
|
||||
/>
|
||||
<SliderRow
|
||||
label="溢色抑制"
|
||||
value={spill}
|
||||
description="去除边缘颜色溢出"
|
||||
onChange={onSpillChange}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
import React from "react"
|
||||
|
||||
interface SubtitleEffectButtonsProps {
|
||||
stroke: boolean
|
||||
shadow: boolean
|
||||
onStrokeChange: (enabled: boolean) => void
|
||||
onShadowChange: (enabled: boolean) => void
|
||||
}
|
||||
|
||||
export const SubtitleEffectButtons: React.FC<SubtitleEffectButtonsProps> = ({
|
||||
stroke,
|
||||
shadow,
|
||||
onStrokeChange,
|
||||
onShadowChange,
|
||||
}) => {
|
||||
return (
|
||||
<div className="sub-effect-btns">
|
||||
<button
|
||||
className={`sub-effect-btn${stroke ? " active" : ""}`}
|
||||
onClick={() => onStrokeChange(!stroke)}
|
||||
>
|
||||
S 描边
|
||||
</button>
|
||||
<button
|
||||
className={`sub-effect-btn${shadow ? " active" : ""}`}
|
||||
onClick={() => onShadowChange(!shadow)}
|
||||
>
|
||||
☁ 阴影
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
import React from "react"
|
||||
|
||||
interface SubtitleModeSwitchProps {
|
||||
mode: "manual" | "asr"
|
||||
onModeChange: (mode: "manual" | "asr") => void
|
||||
}
|
||||
|
||||
export const SubtitleModeSwitch: React.FC<SubtitleModeSwitchProps> = ({ mode, onModeChange }) => {
|
||||
return (
|
||||
<div className="sub-mode-switch">
|
||||
<button
|
||||
className={`sub-mode-btn${mode === "manual" ? " active" : ""}`}
|
||||
onClick={() => onModeChange("manual")}
|
||||
>
|
||||
✏️ 手动输入
|
||||
</button>
|
||||
<button
|
||||
className={`sub-mode-btn${mode === "asr" ? " active" : ""}`}
|
||||
onClick={() => onModeChange("asr")}
|
||||
>
|
||||
🤖 ASR 识别
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Executable
+27
@@ -0,0 +1,27 @@
|
||||
import React from "react"
|
||||
import type { SubtitleStyleConfig } from "@/pages/editing-planner/types/subtitle"
|
||||
import { POSITION_OPTIONS } from "@/pages/editing-planner/constants/subtitleStyle"
|
||||
|
||||
interface SubtitlePositionSelectorProps {
|
||||
position: SubtitleStyleConfig["position"]
|
||||
onPositionChange: (position: SubtitleStyleConfig["position"]) => void
|
||||
}
|
||||
|
||||
export const SubtitlePositionSelector: React.FC<SubtitlePositionSelectorProps> = ({
|
||||
position,
|
||||
onPositionChange,
|
||||
}) => {
|
||||
return (
|
||||
<div className="sub-position-group">
|
||||
{POSITION_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
className={`sub-position-btn${position === opt.value ? " active" : ""}`}
|
||||
onClick={() => onPositionChange(opt.value as SubtitleStyleConfig["position"])}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { useState, useRef, useCallback, useEffect, useMemo, useLayoutEffect } from "react"
|
||||
import type { ClipType } from "../../types"
|
||||
import { DEFAULT_ADD_DURATION, ADD_PICKER_WIDTH, TRACK_GAP } from "../../constants/timeline"
|
||||
|
||||
interface UseAddPickerOptions {
|
||||
currentMode: string
|
||||
onAddClip: (type: ClipType, duration: number) => void
|
||||
}
|
||||
|
||||
export function useAddPicker({ currentMode, onAddClip }: UseAddPickerOptions) {
|
||||
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 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])
|
||||
|
||||
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])
|
||||
|
||||
const handleConfirmAdd = useCallback(() => {
|
||||
onAddClip(addType, addDuration)
|
||||
setShowAddPicker(false)
|
||||
}, [onAddClip, addType, addDuration])
|
||||
|
||||
return {
|
||||
showAddPicker,
|
||||
setShowAddPicker,
|
||||
pickerRef,
|
||||
addCardRef,
|
||||
pickerPos,
|
||||
availableTypes,
|
||||
addType,
|
||||
addDuration,
|
||||
setAddType,
|
||||
setAddDuration,
|
||||
handleTogglePicker,
|
||||
handleConfirmAdd,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useState, useRef, useCallback, useEffect } from "react"
|
||||
|
||||
interface ContextMenuState {
|
||||
x: number
|
||||
y: number
|
||||
clipId: string
|
||||
}
|
||||
|
||||
interface UseContextMenuOptions {
|
||||
onClipSplit?: (clipId: string, splitRatio: number) => void
|
||||
onClipResetTrim?: (clipId: string) => void
|
||||
onClipRemove?: (clipId: string) => void
|
||||
}
|
||||
|
||||
export function useContextMenu({
|
||||
onClipSplit,
|
||||
onClipResetTrim,
|
||||
onClipRemove,
|
||||
}: UseContextMenuOptions) {
|
||||
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null)
|
||||
const contextMenuRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
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])
|
||||
|
||||
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])
|
||||
|
||||
return {
|
||||
contextMenu,
|
||||
setContextMenu,
|
||||
contextMenuRef,
|
||||
handleContextMenu,
|
||||
handleContextSplit,
|
||||
handleContextResetTrim,
|
||||
handleContextDelete,
|
||||
}
|
||||
}
|
||||
@@ -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,38 @@
|
||||
import { useCallback } from "react"
|
||||
import { message } from "antd"
|
||||
import { useQueryClient, useMutation } from "@tanstack/react-query"
|
||||
import { createClipsFromAssets } from "@/api/template-editor"
|
||||
|
||||
const QUERY_KEY = "editPlanClips"
|
||||
|
||||
export function useClipImport(planId: string | undefined) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const invalidate = () => {
|
||||
queryClient.invalidateQueries({ queryKey: [QUERY_KEY, planId] })
|
||||
}
|
||||
|
||||
const importFromAssetsMutation = useMutation({
|
||||
mutationFn: (assetIds: string[]) => createClipsFromAssets(planId!, assetIds),
|
||||
onSuccess: (res) => {
|
||||
invalidate()
|
||||
message.success(`已导入 ${res.created_count} 个素材片段`)
|
||||
},
|
||||
onError: () => {
|
||||
message.error("导入素材失败")
|
||||
},
|
||||
})
|
||||
|
||||
const importFromAssets = useCallback(
|
||||
(assetIds: string[]) => {
|
||||
if (!planId || assetIds.length === 0) return
|
||||
importFromAssetsMutation.mutate(assetIds)
|
||||
},
|
||||
[planId, importFromAssetsMutation],
|
||||
)
|
||||
|
||||
return {
|
||||
importFromAssets,
|
||||
isImporting: importFromAssetsMutation.isPending,
|
||||
}
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
import { useCallback } from "react"
|
||||
import { message } from "antd"
|
||||
import { useQueryClient, useMutation } from "@tanstack/react-query"
|
||||
import type { ClipReorderItem } from "@/api/template-editor"
|
||||
import { reorderEditPlanClips } from "@/api/template-editor"
|
||||
|
||||
const QUERY_KEY = "editPlanClips"
|
||||
|
||||
export function useClipReorder(planId: string | undefined) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const invalidate = () => {
|
||||
queryClient.invalidateQueries({ queryKey: [QUERY_KEY, planId] })
|
||||
}
|
||||
|
||||
const reorderMutation = useMutation({
|
||||
mutationFn: (items: ClipReorderItem[]) => reorderEditPlanClips(planId!, items),
|
||||
onSuccess: () => {
|
||||
invalidate()
|
||||
},
|
||||
onError: () => {
|
||||
message.error("排序失败")
|
||||
invalidate()
|
||||
},
|
||||
})
|
||||
|
||||
const reorderClips = useCallback(
|
||||
(items: ClipReorderItem[]) => {
|
||||
if (!planId || items.length === 0) return
|
||||
reorderMutation.mutate(items)
|
||||
},
|
||||
[planId, reorderMutation],
|
||||
)
|
||||
|
||||
return {
|
||||
reorderClips,
|
||||
isReordering: reorderMutation.isPending,
|
||||
}
|
||||
}
|
||||
Regular → Executable
+7
-56
@@ -1,19 +1,15 @@
|
||||
import { useCallback } from "react"
|
||||
import { message } from "antd"
|
||||
import { useQueryClient, useMutation } from "@tanstack/react-query"
|
||||
import type {
|
||||
CreateEditPlanClipRequest,
|
||||
UpdateEditPlanClipRequest,
|
||||
ClipReorderItem,
|
||||
} from "@/api/template-editor"
|
||||
import type { CreateEditPlanClipRequest, UpdateEditPlanClipRequest } from "@/api/template-editor"
|
||||
import {
|
||||
createEditPlanClip,
|
||||
updateEditPlanClip,
|
||||
deleteEditPlanClip,
|
||||
reorderEditPlanClips,
|
||||
batchDeleteEditPlanClips,
|
||||
createClipsFromAssets,
|
||||
} from "@/api/template-editor"
|
||||
import { useClipReorder } from "./useClipReorder"
|
||||
import { useClipImport } from "./useClipImport"
|
||||
|
||||
const QUERY_KEY = "editPlanClips"
|
||||
|
||||
@@ -24,10 +20,6 @@ interface UseEditPlanClipMutationsOptions {
|
||||
clipsLength: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑计划片段 CRUD Hook
|
||||
* 封装创建、更新、删除、批量删除、重排序、素材导入等操作
|
||||
*/
|
||||
export function useEditPlanClipMutations({
|
||||
planId,
|
||||
selectedClipId,
|
||||
@@ -40,7 +32,6 @@ export function useEditPlanClipMutations({
|
||||
queryClient.invalidateQueries({ queryKey: [QUERY_KEY, planId] })
|
||||
}
|
||||
|
||||
/* ── 创建片段 ── */
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: CreateEditPlanClipRequest) => createEditPlanClip(planId!, data),
|
||||
onSuccess: () => {
|
||||
@@ -61,7 +52,6 @@ export function useEditPlanClipMutations({
|
||||
[planId, clipsLength, createMutation],
|
||||
)
|
||||
|
||||
/* ── 更新片段 ── */
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ clipId, data }: { clipId: string; data: UpdateEditPlanClipRequest }) =>
|
||||
updateEditPlanClip(planId!, clipId, data),
|
||||
@@ -81,7 +71,6 @@ export function useEditPlanClipMutations({
|
||||
[planId, updateMutation],
|
||||
)
|
||||
|
||||
/* ── 删除片段 ── */
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (clipId: string) => deleteEditPlanClip(planId!, clipId),
|
||||
onSuccess: () => {
|
||||
@@ -104,7 +93,6 @@ export function useEditPlanClipMutations({
|
||||
[planId, selectedClipId, setSelectedClipId, deleteMutation],
|
||||
)
|
||||
|
||||
/* ── 批量删除 ── */
|
||||
const batchDeleteMutation = useMutation({
|
||||
mutationFn: (clipIds: string[]) => batchDeleteEditPlanClips(planId!, clipIds),
|
||||
onSuccess: (res) => {
|
||||
@@ -127,45 +115,8 @@ export function useEditPlanClipMutations({
|
||||
[planId, selectedClipId, setSelectedClipId, batchDeleteMutation],
|
||||
)
|
||||
|
||||
/* ── 重排序 ── */
|
||||
const reorderMutation = useMutation({
|
||||
mutationFn: (items: ClipReorderItem[]) => reorderEditPlanClips(planId!, items),
|
||||
onSuccess: () => {
|
||||
invalidate()
|
||||
},
|
||||
onError: () => {
|
||||
message.error("排序失败")
|
||||
invalidate()
|
||||
},
|
||||
})
|
||||
|
||||
const reorderClips = useCallback(
|
||||
(items: ClipReorderItem[]) => {
|
||||
if (!planId || items.length === 0) return
|
||||
reorderMutation.mutate(items)
|
||||
},
|
||||
[planId, reorderMutation],
|
||||
)
|
||||
|
||||
/* ── 从素材批量导入 ── */
|
||||
const importFromAssetsMutation = useMutation({
|
||||
mutationFn: (assetIds: string[]) => createClipsFromAssets(planId!, assetIds),
|
||||
onSuccess: (res) => {
|
||||
invalidate()
|
||||
message.success(`已导入 ${res.created_count} 个素材片段`)
|
||||
},
|
||||
onError: () => {
|
||||
message.error("导入素材失败")
|
||||
},
|
||||
})
|
||||
|
||||
const importFromAssets = useCallback(
|
||||
(assetIds: string[]) => {
|
||||
if (!planId || assetIds.length === 0) return
|
||||
importFromAssetsMutation.mutate(assetIds)
|
||||
},
|
||||
[planId, importFromAssetsMutation],
|
||||
)
|
||||
const { reorderClips, isReordering } = useClipReorder(planId)
|
||||
const { importFromAssets, isImporting } = useClipImport(planId)
|
||||
|
||||
return {
|
||||
addClip,
|
||||
@@ -177,7 +128,7 @@ export function useEditPlanClipMutations({
|
||||
isCreating: createMutation.isPending,
|
||||
isUpdating: updateMutation.isPending,
|
||||
isDeleting: deleteMutation.isPending,
|
||||
isReordering: reorderMutation.isPending,
|
||||
isImporting: importFromAssetsMutation.isPending,
|
||||
isReordering,
|
||||
isImporting,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useState } from "react"
|
||||
import type { ClipData, ClipType } from "../types"
|
||||
import { useContextMenu } from "./timeline-menus/useContextMenu"
|
||||
import { useAddPicker } from "./timeline-menus/useAddPicker"
|
||||
|
||||
/**
|
||||
* 时间线菜单 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 [hoveredClipId, setHoveredClipId] = useState<string | null>(null)
|
||||
|
||||
const {
|
||||
contextMenu,
|
||||
contextMenuRef,
|
||||
handleContextMenu,
|
||||
handleContextSplit,
|
||||
handleContextResetTrim,
|
||||
handleContextDelete,
|
||||
} = useContextMenu({ onClipSplit, onClipResetTrim, onClipRemove })
|
||||
|
||||
const {
|
||||
showAddPicker,
|
||||
pickerRef,
|
||||
addCardRef,
|
||||
pickerPos,
|
||||
availableTypes,
|
||||
addType,
|
||||
addDuration,
|
||||
setAddType,
|
||||
setAddDuration,
|
||||
handleTogglePicker,
|
||||
handleConfirmAdd,
|
||||
} = useAddPicker({ currentMode, onAddClip })
|
||||
|
||||
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 还原
|
||||
* 7 步向导:选择模板 → 选择素材 → 生成预览 → 选择标题 → 选择配音 → 选择封面 → 确认生成
|
||||
* 左右布局:左侧 generate-form + 右侧 generate-preview
|
||||
* 主组件仅保留共享状态、步骤切换、整体布局
|
||||
* 各 Step 的 UI 与业务逻辑拆分至 components/ + hooks/
|
||||
* 生成核心逻辑封装在 useGenerateVideo hook
|
||||
* 主组件仅保留整体布局与事件编排
|
||||
* 状态管理 → hooks/useGenerateFormState
|
||||
* 步骤导航 → hooks/useStepNavigation
|
||||
* 步骤内容 → components/GenerateStepContent
|
||||
* 底部按钮 → components/GenerateStepActions
|
||||
* 生成核心逻辑 → hooks/useGenerateVideo
|
||||
*/
|
||||
import React, { useState, useEffect, useMemo } from "react"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { message, Modal } from "antd"
|
||||
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 React from "react"
|
||||
import { Modal, message } from "antd"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import type { VoiceClone } from "@/api/voice-clone"
|
||||
import { useCloneProgress } from "@/hooks/useCloneProgress"
|
||||
import CloneModal from "@/components/voice/CloneModal"
|
||||
import { useSearchParams, useNavigate } from "react-router-dom"
|
||||
import GenerateHeader from "./components/GenerateHeader"
|
||||
import GenerateStepsBar from "./components/GenerateStepsBar"
|
||||
import GenerateResultPanel from "./components/GenerateResultPanel"
|
||||
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 { DEFAULT_COVER_SETTINGS } from "./constants"
|
||||
import type { TitleSettings } from "./types"
|
||||
import GenerateStepContent from "./components/GenerateStepContent"
|
||||
import GenerateStepActions from "./components/GenerateStepActions"
|
||||
import { useGenerateFormState } from "./hooks/useGenerateFormState"
|
||||
import { useStepNavigation } from "./hooks/useStepNavigation"
|
||||
import { useGenerateVideo } from "./hooks/useGenerateVideo"
|
||||
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 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 [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) => {
|
||||
addClone(voice)
|
||||
setCloneModalOpen(false)
|
||||
message.success("音色克隆成功!")
|
||||
}
|
||||
|
||||
/* ── 步骤导航 ── */
|
||||
const { goNext, goPrev } = useStepNavigation({
|
||||
currentStep,
|
||||
setCurrentStep,
|
||||
selectedTemplate,
|
||||
materialMode,
|
||||
selectedMaterials,
|
||||
smartSelectedIds,
|
||||
titleSettings,
|
||||
})
|
||||
|
||||
/* ── 视频生成核心逻辑 ── */
|
||||
const {
|
||||
generating,
|
||||
@@ -256,136 +119,6 @@ const GeneratePage: React.FC = () => {
|
||||
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-form">
|
||||
{/* 当前步骤 */}
|
||||
{renderCurrentStep()}
|
||||
<GenerateStepContent
|
||||
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}
|
||||
/>
|
||||
|
||||
{/* 底部操作按钮 */}
|
||||
<div className="xx-step-actions">
|
||||
<button className="xx-btn xx-btn-ghost" onClick={goPrev} disabled={currentStep === 1}>
|
||||
← 上一步
|
||||
</button>
|
||||
{currentStep < 7 ? (
|
||||
<button className="xx-btn xx-btn-primary" onClick={goNext}>
|
||||
下一步 →
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="xx-btn xx-btn-primary"
|
||||
onClick={handleGenerate}
|
||||
disabled={generating || (generated && !generateError)}
|
||||
>
|
||||
<ThunderboltOutlined />
|
||||
{generating
|
||||
? "生成中…"
|
||||
: generated && !generateError
|
||||
? "已生成"
|
||||
: generateError
|
||||
? "🔄 重新生成"
|
||||
: "✨ 确认生成"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<GenerateStepActions
|
||||
currentStep={currentStep}
|
||||
onPrev={goPrev}
|
||||
onNext={goNext}
|
||||
onGenerate={handleGenerate}
|
||||
generating={generating}
|
||||
generated={generated}
|
||||
generateError={generateError}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ════ 右侧:生成结果 ════ */}
|
||||
|
||||
Regular → Executable
+11
-102
@@ -1,37 +1,21 @@
|
||||
/**
|
||||
* 智能剪辑右侧生成结果面板
|
||||
*/
|
||||
import React from "react"
|
||||
import { Typography } from "antd"
|
||||
import {
|
||||
PlayCircleOutlined,
|
||||
CloseCircleOutlined,
|
||||
DownloadOutlined,
|
||||
ShareAltOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import { PlayCircleOutlined, CloseCircleOutlined } from "@ant-design/icons"
|
||||
import type { GeneratedVideo } from "@/api/template-editor"
|
||||
import { formatDuration } from "@/api/voice-clone"
|
||||
import { ProgressIndicator } from "./result-panel/ProgressIndicator"
|
||||
import { ResultVideoCard } from "./result-panel/ResultVideoCard"
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
interface GenerateResultPanelProps {
|
||||
/** 是否已生成完成 */
|
||||
generated: boolean
|
||||
/** 是否正在生成中 */
|
||||
generating: boolean
|
||||
/** 生成进度(0-100) */
|
||||
progress: number
|
||||
/** 生成错误信息 */
|
||||
generateError: string | null
|
||||
/** 生成的视频列表 */
|
||||
generatedVideos: GeneratedVideo[]
|
||||
/** 点击视频卡片预览回调 */
|
||||
onVideoPreview: (video: GeneratedVideo) => void
|
||||
/** 下载回调 */
|
||||
onDownload: () => void
|
||||
/** 分享回调 */
|
||||
onShare: () => void
|
||||
/** 前往成片库回调 */
|
||||
onGoToLibrary: () => void
|
||||
}
|
||||
|
||||
@@ -55,45 +39,8 @@ const GenerateResultPanel: React.FC<GenerateResultPanelProps> = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 生成中进度 */}
|
||||
{generating && (
|
||||
<div className="xx-result-progress">
|
||||
<div className="xx-progress-circle">
|
||||
<svg viewBox="0 0 80 80">
|
||||
<circle
|
||||
cx="40"
|
||||
cy="40"
|
||||
r="36"
|
||||
fill="none"
|
||||
stroke="var(--border-color)"
|
||||
strokeWidth="6"
|
||||
/>
|
||||
<circle
|
||||
cx="40"
|
||||
cy="40"
|
||||
r="36"
|
||||
fill="none"
|
||||
stroke="var(--primary-color)"
|
||||
strokeWidth="6"
|
||||
strokeDasharray={`${Math.round(progress) * 2.26} 226`}
|
||||
strokeLinecap="round"
|
||||
transform="rotate(-90 40 40)"
|
||||
/>
|
||||
</svg>
|
||||
<span className="xx-progress-percent">{Math.round(progress)}%</span>
|
||||
</div>
|
||||
<div className="xx-progress-text">
|
||||
<Text strong style={{ fontSize: 14, display: "block", marginBottom: 4 }}>
|
||||
正在生成视频
|
||||
</Text>
|
||||
<Text style={{ fontSize: 12, color: "var(--text-secondary)" }}>
|
||||
AI 正在处理素材,请稍候…
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{generating && <ProgressIndicator progress={progress} />}
|
||||
|
||||
{/* 生成失败 */}
|
||||
{generateError && !generating && (
|
||||
<div className="xx-result-empty">
|
||||
<CloseCircleOutlined style={{ fontSize: 40, color: "#ff4d4f", marginBottom: 12 }} />
|
||||
@@ -106,7 +53,6 @@ const GenerateResultPanel: React.FC<GenerateResultPanelProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 空状态 */}
|
||||
{!generated && !generating && !generateError && (
|
||||
<div className="xx-result-empty">
|
||||
<PlayCircleOutlined
|
||||
@@ -121,54 +67,17 @@ const GenerateResultPanel: React.FC<GenerateResultPanelProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 生成结果卡片列表 */}
|
||||
{generated && generatedVideos.length > 0 && (
|
||||
<div className="xx-video-grid">
|
||||
{generatedVideos.map((video, idx) => (
|
||||
<div
|
||||
<ResultVideoCard
|
||||
key={video.id || idx}
|
||||
className="xx-video-card"
|
||||
onClick={() => onVideoPreview(video)}
|
||||
>
|
||||
<div className="xx-video-thumb">
|
||||
{video.thumbnail_url ? (
|
||||
<img src={video.thumbnail_url} alt="" />
|
||||
) : (
|
||||
<div className="xx-video-thumb-placeholder">
|
||||
<PlayCircleOutlined style={{ fontSize: 32, opacity: 0.5 }} />
|
||||
</div>
|
||||
)}
|
||||
<div className="xx-video-play-overlay">
|
||||
<PlayCircleOutlined style={{ fontSize: 36, color: "#fff" }} />
|
||||
</div>
|
||||
{video.duration && (
|
||||
<span className="xx-video-duration">{formatDuration(video.duration)}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="xx-video-info">
|
||||
<div className="xx-video-title">视频 {idx + 1}</div>
|
||||
<div className="xx-video-actions">
|
||||
<button
|
||||
className="xx-video-action-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onDownload()
|
||||
}}
|
||||
>
|
||||
<DownloadOutlined />
|
||||
</button>
|
||||
<button
|
||||
className="xx-video-action-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onShare()
|
||||
}}
|
||||
>
|
||||
<ShareAltOutlined />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
video={video}
|
||||
index={idx}
|
||||
onPreview={onVideoPreview}
|
||||
onDownload={onDownload}
|
||||
onShare={onShare}
|
||||
/>
|
||||
))}
|
||||
</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
|
||||
Regular → Executable
+16
-101
@@ -1,9 +1,9 @@
|
||||
/**
|
||||
* Step 6 封面设置组件
|
||||
*/
|
||||
import React from "react"
|
||||
import type { CoverConfig } from "../../editing-planner/types"
|
||||
import { useStep6Cover } from "../hooks/useStep6Cover"
|
||||
import { CoverModeSelector } from "./cover-settings/CoverModeSelector"
|
||||
import { FrameCoverPicker } from "./cover-settings/FrameCoverPicker"
|
||||
import { UploadCoverPicker } from "./cover-settings/UploadCoverPicker"
|
||||
|
||||
interface Step6CoverSettingsProps {
|
||||
coverSettings: CoverConfig
|
||||
@@ -28,7 +28,6 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
|
||||
<div className="xx-form-section">
|
||||
<h3>🖼️ 选择封面</h3>
|
||||
|
||||
{/* 启用开关 */}
|
||||
<div className="xx-cover-header">
|
||||
<span className="xx-cover-header-label">启用自定义封面</span>
|
||||
<label className="xx-switch">
|
||||
@@ -43,22 +42,14 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
|
||||
|
||||
{coverSettings.enabled && (
|
||||
<>
|
||||
{/* 模式选择 */}
|
||||
<div className="xx-section-title">封面来源</div>
|
||||
<div className="xx-cover-mode-tabs">
|
||||
{(["auto", "frame", "upload"] as const).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
className={`xx-cover-mode-tab${coverSettings.mode === m ? " active" : ""}`}
|
||||
onClick={() => setMode(m)}
|
||||
>
|
||||
<span className="xx-cover-mode-icon">{COVER_MODE_ICONS[m]}</span>
|
||||
<span className="xx-cover-mode-label">{COVER_MODE_LABELS[m]}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<CoverModeSelector
|
||||
mode={coverSettings.mode}
|
||||
onModeChange={setMode}
|
||||
modeLabels={COVER_MODE_LABELS}
|
||||
modeIcons={COVER_MODE_ICONS}
|
||||
/>
|
||||
|
||||
{/* 智能封面 */}
|
||||
{coverSettings.mode === "auto" && (
|
||||
<div className="xx-cover-auto">
|
||||
<div className="xx-cover-auto-desc">
|
||||
@@ -71,95 +62,19 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 抽帧选封面 */}
|
||||
{coverSettings.mode === "frame" && (
|
||||
<div className="xx-cover-frame">
|
||||
<div className="xx-cover-frame-preview">
|
||||
<div className="xx-cover-frame-placeholder">
|
||||
<span className="xx-cover-frame-icon">🎞️</span>
|
||||
<span className="xx-cover-frame-time">
|
||||
{formatTime(coverSettings.frame_time)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="xx-cover-frame-slider">
|
||||
<div className="xx-cover-frame-slider-header">
|
||||
<span>拖动选择封面帧</span>
|
||||
<span className="xx-cover-frame-value">
|
||||
{formatTime(coverSettings.frame_time)}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={Math.max(totalDuration, 1)}
|
||||
step={0.1}
|
||||
value={coverSettings.frame_time}
|
||||
onChange={(e) => setFrameTime(Number(e.target.value))}
|
||||
className="xx-cover-range"
|
||||
/>
|
||||
<div className="xx-cover-frame-range">
|
||||
<span>00:00</span>
|
||||
<span>{formatTime(totalDuration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="xx-cover-frame-quick">
|
||||
<span className="xx-cover-quick-label">快捷选帧:</span>
|
||||
{[0, 0.25, 0.5, 0.75].map((ratio) => {
|
||||
const t = totalDuration * ratio
|
||||
return (
|
||||
<button
|
||||
key={ratio}
|
||||
className="xx-cover-quick-btn"
|
||||
onClick={() => setFrameTime(t)}
|
||||
>
|
||||
{formatTime(t)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<FrameCoverPicker
|
||||
frameTime={coverSettings.frame_time}
|
||||
totalDuration={totalDuration}
|
||||
formatTime={formatTime}
|
||||
onFrameTimeChange={setFrameTime}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 上传封面 */}
|
||||
{coverSettings.mode === "upload" && (
|
||||
<div className="xx-cover-upload">
|
||||
<div
|
||||
className="xx-cover-upload-area"
|
||||
onClick={() => {
|
||||
const input = document.getElementById("cover-upload-input")
|
||||
input?.click()
|
||||
}}
|
||||
>
|
||||
{coverSettings.upload_url ? (
|
||||
<div className="xx-cover-upload-preview">
|
||||
<img src={coverSettings.upload_url} alt="封面预览" />
|
||||
<div className="xx-cover-upload-overlay">点击更换</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="xx-cover-upload-placeholder">
|
||||
<span style={{ fontSize: 32 }}>📤</span>
|
||||
<span className="xx-cover-upload-text">点击上传封面图片</span>
|
||||
<span className="xx-cover-upload-hint">支持 JPG / PNG,建议 16:9 比例</span>
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
id="cover-upload-input"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
style={{ display: "none" }}
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) {
|
||||
handleUpload(file)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<UploadCoverPicker uploadUrl={coverSettings.upload_url} onUpload={handleUpload} />
|
||||
)}
|
||||
|
||||
{/* 封面预览 */}
|
||||
<div className="xx-section-title">封面预览</div>
|
||||
<div className="xx-cover-preview-box">
|
||||
{coverSettings.upload_url ? (
|
||||
|
||||
Regular → Executable
+24
-130
@@ -2,19 +2,14 @@
|
||||
* Step 7 确认生成组件
|
||||
*/
|
||||
import React from "react"
|
||||
import {
|
||||
LoadingOutlined,
|
||||
CheckCircleFilled,
|
||||
CloseCircleOutlined,
|
||||
MinusOutlined,
|
||||
PlusOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import type { EditingTemplate } from "@/api/editing-planner"
|
||||
import type { GeneratedVideo } from "@/api/template-editor"
|
||||
import type { CoverConfig } from "../../editing-planner/types"
|
||||
import type { VoiceClone } from "@/api/voice-clone"
|
||||
import type { PresetVoiceItem } from "@/api/voices"
|
||||
import { useStep7Generate } from "../hooks/useStep7Generate"
|
||||
import SummaryCard from "./step7-confirm/SummaryCard"
|
||||
import GenerationStatus from "./step7-confirm/GenerationStatus"
|
||||
|
||||
interface Step7ConfirmGenerateProps {
|
||||
templates: EditingTemplate[]
|
||||
@@ -64,129 +59,28 @@ const Step7ConfirmGenerate: React.FC<Step7ConfirmGenerateProps> = (props) => {
|
||||
return (
|
||||
<div className="xx-form-section">
|
||||
<h3>✨ 确认生成</h3>
|
||||
<div className="xx-summary-card">
|
||||
<div className="xx-summary-row">
|
||||
<span className="xx-summary-label">模板</span>
|
||||
<span className="xx-summary-value">{templateName}</span>
|
||||
</div>
|
||||
<div className="xx-summary-row">
|
||||
<span className="xx-summary-label">素材</span>
|
||||
<span className="xx-summary-value">{materialSummary}</span>
|
||||
</div>
|
||||
<div className="xx-summary-row">
|
||||
<span className="xx-summary-label">标题</span>
|
||||
<span className="xx-summary-value">{title || "未选择"}</span>
|
||||
</div>
|
||||
<div className="xx-summary-row">
|
||||
<span className="xx-summary-label">配音</span>
|
||||
<span className="xx-summary-value">{voiceName}</span>
|
||||
</div>
|
||||
<div className="xx-summary-row">
|
||||
<span className="xx-summary-label">封面</span>
|
||||
<span className="xx-summary-value">{coverSummary}</span>
|
||||
</div>
|
||||
<div className="xx-summary-row">
|
||||
<span className="xx-summary-label">生成数量</span>
|
||||
<span className="xx-summary-value">
|
||||
<div className="xx-count-stepper">
|
||||
<button
|
||||
className="xx-count-stepper-btn"
|
||||
disabled={generateCount <= 1 || generating}
|
||||
onClick={handleDecrement}
|
||||
>
|
||||
<MinusOutlined />
|
||||
</button>
|
||||
<span className="xx-count-stepper-value">{generateCount}</span>
|
||||
<button
|
||||
className="xx-count-stepper-btn"
|
||||
disabled={generateCount >= 10 || generating}
|
||||
onClick={handleIncrement}
|
||||
>
|
||||
<PlusOutlined />
|
||||
</button>
|
||||
<span className="xx-count-stepper-hint">条视频</span>
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 生成进度 / 结果反馈 */}
|
||||
{(generating || generated || generateError) && (
|
||||
<div style={{ marginTop: 16 }}>
|
||||
{generating && (
|
||||
<div className="xx-gen-progress-card">
|
||||
<div className="xx-gen-progress-header">
|
||||
<div className="xx-gen-progress-icon">
|
||||
<LoadingOutlined />
|
||||
</div>
|
||||
<div className="xx-gen-progress-info">
|
||||
<div className="xx-gen-progress-phase">
|
||||
{getGenerationPhase(progress).icon} {getGenerationPhase(progress).label}
|
||||
</div>
|
||||
<div className="xx-gen-progress-sub">预计还需 1-2 分钟,请稍候…</div>
|
||||
</div>
|
||||
<div className="xx-gen-progress-percent">{Math.round(progress)}%</div>
|
||||
</div>
|
||||
<div className="xx-gen-progress-bar">
|
||||
<div
|
||||
className="xx-gen-progress-bar-fill"
|
||||
style={{ width: `${Math.min(Math.round(progress), 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="xx-gen-progress-tip">
|
||||
💡 生成过程中可以切换到其他页面操作,完成后会自动通知
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{generated && !generating && (
|
||||
<div className="xx-gen-success-card">
|
||||
<div className="xx-gen-success-icon">
|
||||
<CheckCircleFilled style={{ fontSize: 32, color: "#52c41a" }} />
|
||||
</div>
|
||||
<div className="xx-gen-success-info">
|
||||
<div className="xx-gen-success-title">视频生成完成!</div>
|
||||
<div className="xx-gen-success-sub">
|
||||
共生成 {generatedVideos.length} 条视频,可在右侧预览或前往成片库查看
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="xx-btn xx-btn-primary xx-btn-sm"
|
||||
onClick={handleScrollToPreview}
|
||||
>
|
||||
查看结果
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{generateError && !generating && (
|
||||
<div className="xx-gen-error-card">
|
||||
<div className="xx-gen-error-icon">
|
||||
<CloseCircleOutlined style={{ fontSize: 28, color: "#ef4444" }} />
|
||||
</div>
|
||||
<div className="xx-gen-error-info">
|
||||
<div className="xx-gen-error-title">生成失败</div>
|
||||
<div className="xx-gen-error-msg">
|
||||
{typeof generateError === "string"
|
||||
? generateError
|
||||
: JSON.stringify(generateError)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<button type="button" className="xx-btn xx-btn-primary xx-btn-sm" onClick={onRetry}>
|
||||
🔄 重试
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="xx-btn xx-btn-ghost xx-btn-sm"
|
||||
onClick={onDismissError}
|
||||
>
|
||||
知道了
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<SummaryCard
|
||||
templateName={templateName}
|
||||
materialSummary={materialSummary}
|
||||
title={title}
|
||||
voiceName={voiceName}
|
||||
coverSummary={coverSummary}
|
||||
generateCount={generateCount}
|
||||
generating={generating}
|
||||
onDecrement={handleDecrement}
|
||||
onIncrement={handleIncrement}
|
||||
/>
|
||||
<GenerationStatus
|
||||
generating={generating}
|
||||
generated={generated}
|
||||
generateError={generateError}
|
||||
progress={progress}
|
||||
generatedVideos={generatedVideos}
|
||||
getGenerationPhase={getGenerationPhase}
|
||||
onScrollToPreview={handleScrollToPreview}
|
||||
onRetry={onRetry}
|
||||
onDismissError={onDismissError}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
import React from "react"
|
||||
import type { CoverMode } from "../../../editing-planner/types"
|
||||
|
||||
interface CoverModeSelectorProps {
|
||||
mode: CoverMode
|
||||
onModeChange: (mode: CoverMode) => void
|
||||
modeLabels: Record<CoverMode, string>
|
||||
modeIcons: Record<CoverMode, string>
|
||||
}
|
||||
|
||||
export const CoverModeSelector: React.FC<CoverModeSelectorProps> = ({
|
||||
mode,
|
||||
onModeChange,
|
||||
modeLabels,
|
||||
modeIcons,
|
||||
}) => {
|
||||
const modes: CoverMode[] = ["auto", "frame", "upload"]
|
||||
return (
|
||||
<div className="xx-cover-mode-tabs">
|
||||
{modes.map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
className={`xx-cover-mode-tab${mode === m ? " active" : ""}`}
|
||||
onClick={() => onModeChange(m)}
|
||||
>
|
||||
<span className="xx-cover-mode-icon">{modeIcons[m]}</span>
|
||||
<span className="xx-cover-mode-label">{modeLabels[m]}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import React from "react"
|
||||
|
||||
interface FrameCoverPickerProps {
|
||||
frameTime: number
|
||||
totalDuration: number
|
||||
formatTime: (seconds: number) => string
|
||||
onFrameTimeChange: (time: number) => void
|
||||
}
|
||||
|
||||
export const FrameCoverPicker: React.FC<FrameCoverPickerProps> = ({
|
||||
frameTime,
|
||||
totalDuration,
|
||||
formatTime,
|
||||
onFrameTimeChange,
|
||||
}) => {
|
||||
const quickRatios = [0, 0.25, 0.5, 0.75]
|
||||
|
||||
return (
|
||||
<div className="xx-cover-frame">
|
||||
<div className="xx-cover-frame-preview">
|
||||
<div className="xx-cover-frame-placeholder">
|
||||
<span className="xx-cover-frame-icon">🎞️</span>
|
||||
<span className="xx-cover-frame-time">{formatTime(frameTime)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="xx-cover-frame-slider">
|
||||
<div className="xx-cover-frame-slider-header">
|
||||
<span>拖动选择封面帧</span>
|
||||
<span className="xx-cover-frame-value">{formatTime(frameTime)}</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={Math.max(totalDuration, 1)}
|
||||
step={0.1}
|
||||
value={frameTime}
|
||||
onChange={(e) => onFrameTimeChange(Number(e.target.value))}
|
||||
className="xx-cover-range"
|
||||
/>
|
||||
<div className="xx-cover-frame-range">
|
||||
<span>00:00</span>
|
||||
<span>{formatTime(totalDuration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="xx-cover-frame-quick">
|
||||
<span className="xx-cover-quick-label">快捷选帧:</span>
|
||||
{quickRatios.map((ratio) => {
|
||||
const t = totalDuration * ratio
|
||||
return (
|
||||
<button key={ratio} className="xx-cover-quick-btn" onClick={() => onFrameTimeChange(t)}>
|
||||
{formatTime(t)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
import React from "react"
|
||||
|
||||
interface UploadCoverPickerProps {
|
||||
uploadUrl: string
|
||||
onUpload: (file: File) => void
|
||||
}
|
||||
|
||||
export const UploadCoverPicker: React.FC<UploadCoverPickerProps> = ({ uploadUrl, onUpload }) => {
|
||||
const handleClick = () => {
|
||||
const input = document.getElementById("cover-upload-input")
|
||||
input?.click()
|
||||
}
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) {
|
||||
onUpload(file)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="xx-cover-upload">
|
||||
<div className="xx-cover-upload-area" onClick={handleClick}>
|
||||
{uploadUrl ? (
|
||||
<div className="xx-cover-upload-preview">
|
||||
<img src={uploadUrl} alt="封面预览" />
|
||||
<div className="xx-cover-upload-overlay">点击更换</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="xx-cover-upload-placeholder">
|
||||
<span style={{ fontSize: 32 }}>📤</span>
|
||||
<span className="xx-cover-upload-text">点击上传封面图片</span>
|
||||
<span className="xx-cover-upload-hint">支持 JPG / PNG,建议 16:9 比例</span>
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
id="cover-upload-input"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
style={{ display: "none" }}
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import React from "react"
|
||||
import { Typography } from "antd"
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
interface ProgressIndicatorProps {
|
||||
progress: number
|
||||
}
|
||||
|
||||
export const ProgressIndicator: React.FC<ProgressIndicatorProps> = ({ progress }) => {
|
||||
return (
|
||||
<div className="xx-result-progress">
|
||||
<div className="xx-progress-circle">
|
||||
<svg viewBox="0 0 80 80">
|
||||
<circle cx="40" cy="40" r="36" fill="none" stroke="var(--border-color)" strokeWidth="6" />
|
||||
<circle
|
||||
cx="40"
|
||||
cy="40"
|
||||
r="36"
|
||||
fill="none"
|
||||
stroke="var(--primary-color)"
|
||||
strokeWidth="6"
|
||||
strokeDasharray={`${Math.round(progress) * 2.26} 226`}
|
||||
strokeLinecap="round"
|
||||
transform="rotate(-90 40 40)"
|
||||
/>
|
||||
</svg>
|
||||
<span className="xx-progress-percent">{Math.round(progress)}%</span>
|
||||
</div>
|
||||
<div className="xx-progress-text">
|
||||
<Text strong style={{ fontSize: 14, display: "block", marginBottom: 4 }}>
|
||||
正在生成视频
|
||||
</Text>
|
||||
<Text style={{ fontSize: 12, color: "var(--text-secondary)" }}>
|
||||
AI 正在处理素材,请稍候…
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user