Compare commits

..

1 Commits

Author SHA1 Message Date
xiaoxia 862340ace5 fix(ci): 修复CI Gate失败时返回exit 0而非exit 1的P0 Bug
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 37s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 1m13s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m29s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m39s
CI/CD Pipeline / Validate - Code Quality (pull_request) Failing after 2m44s
AI Code Review / AI Code Review (pull_request) Successful in 1m17s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m56s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m31s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m25s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 5m19s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 7m24s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Failing after 4m18s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 2m3s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 46m54s
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Failing after 5s
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
ACR Cleanup / ACR Image Cleanup (pull_request_target) Has been cancelled
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 37s
2026-07-28 10:13:32 +08:00
145 changed files with 4323 additions and 24272 deletions
+2 -46
View File
@@ -458,8 +458,6 @@ jobs:
name: Frontend Lint name: Frontend Lint
runs-on: ci-l2 runs-on: ci-l2
timeout-minutes: 10 timeout-minutes: 10
needs: check-frontend-only
if: needs.check-frontend-only.outputs.skip_frontend != 'true'
steps: steps:
- name: Checkout code - name: Checkout code
shell: sh shell: sh
@@ -578,12 +576,7 @@ jobs:
name: PR Build ${{ matrix.service_display }} Image name: PR Build ${{ matrix.service_display }} Image
runs-on: runtime-builder runs-on: runtime-builder
timeout-minutes: ${{ matrix.timeout }} timeout-minutes: ${{ matrix.timeout }}
needs: check-frontend-only if: github.event_name == 'pull_request'
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: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
@@ -1091,8 +1084,6 @@ jobs:
# DooD模式下不能用-v挂载(宿主机路径与CI容器路径不一致) # DooD模式下不能用-v挂载(宿主机路径与CI容器路径不一致)
# 改用 docker create + docker cp 方式把代码拷进容器 # 改用 docker create + docker cp 方式把代码拷进容器
CONTAINER_NAME="staging-e2e-$$" CONTAINER_NAME="staging-e2e-$$"
# 强制清理可能残留的同名容器(上一次异常退出时未清理)
docker rm -f "$CONTAINER_NAME" 2>/dev/null || true
docker create --name "$CONTAINER_NAME" --ipc=host \ docker create --name "$CONTAINER_NAME" --ipc=host \
-e E2E_BASE_URL=https://staging.xiaoxiajianji.com \ -e E2E_BASE_URL=https://staging.xiaoxiajianji.com \
-e E2E_API_BASE=https://staging-api.xiaoxiajianji.com/api/v1 \ -e E2E_API_BASE=https://staging-api.xiaoxiajianji.com/api/v1 \
@@ -1201,11 +1192,6 @@ jobs:
runs-on: runtime-builder runs-on: runtime-builder
timeout-minutes: ${{ matrix.timeout }} timeout-minutes: ${{ matrix.timeout }}
needs: 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') if: startsWith(github.ref, 'refs/tags/v') || (github.event_name == 'push' && github.ref_name == 'main')
strategy: strategy:
fail-fast: false fail-fast: false
@@ -1279,7 +1265,7 @@ jobs:
docker buildx inspect --bootstrap docker buildx inspect --bootstrap
- name: Build and push production ${{ matrix.service_display }} image (with retry) - name: Build and push production ${{ matrix.service_display }} image (with retry)
shell: bash shell: sh
run: | run: |
set -eu set -eu
REGISTRY="xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji" REGISTRY="xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji"
@@ -1732,30 +1718,6 @@ jobs:
echo " frontend-lint: $RESULT_FRONTEND_LINT" echo " frontend-lint: $RESULT_FRONTEND_LINT"
echo " frontend-unit-test: $RESULT_FRONTEND_UNIT" echo " frontend-unit-test: $RESULT_FRONTEND_UNIT"
echo " build-pr: $RESULT_BUILD_PR" 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 "" echo ""
# 判断PR类型 # 判断PR类型
@@ -1771,7 +1733,6 @@ jobs:
"validate-migration:$RESULT_MIGRATION" "validate-migration:$RESULT_MIGRATION"
"frontend-lint:$RESULT_FRONTEND_LINT" "frontend-lint:$RESULT_FRONTEND_LINT"
"build-pr:$RESULT_BUILD_PR" "build-pr:$RESULT_BUILD_PR"
"ai-code-review:$AI_REVIEW_STATUS"
) )
# 后端检查 # 后端检查
@@ -1806,11 +1767,6 @@ jobs:
for item in "${REQUIRED_GENERAL[@]}"; do for item in "${REQUIRED_GENERAL[@]}"; do
name="${item%%:*}" name="${item%%:*}"
result="${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" check_job "$name" "$result"
done done
+2 -2
View File
@@ -87,7 +87,7 @@ jobs:
runs-on: ci-l2 runs-on: ci-l2
timeout-minutes: 10 timeout-minutes: 10
outputs: outputs:
report: ${{ steps.report.outputs.report }} report: ${{ steps.smoke.outputs.report }}
steps: steps:
- name: Checkout code - name: Checkout code
@@ -201,7 +201,7 @@ jobs:
runs-on: ci-l2 runs-on: ci-l2
timeout-minutes: 15 timeout-minutes: 15
outputs: outputs:
report: ${{ steps.e2e.outputs.report }} report: ${{ steps.smoke.outputs.report }}
steps: steps:
- name: Checkout code - name: Checkout code
+1 -6
View File
@@ -8,11 +8,6 @@ on:
permissions: permissions:
contents: read contents: read
concurrency:
group: pr-automation-${{ gitea.event.pull_request.number }}
cancel-in-progress: true
jobs: jobs:
auto-approve: auto-approve:
name: Auto Approve on CI Green name: Auto Approve on CI Green
@@ -61,7 +56,7 @@ jobs:
name: Auto Merge on CI Green + Approved name: Auto Merge on CI Green + Approved
runs-on: ci-check runs-on: ci-check
if: github.event_name == 'pull_request' && !github.event.pull_request.draft && github.event.pull_request.base.ref == 'develop' if: github.event_name == 'pull_request' && !github.event.pull_request.draft && github.event.pull_request.base.ref == 'develop'
timeout-minutes: 3 # 短作业模式:检查一次,不满足就退出,由pr-auto-scan每5分钟定时兜底 timeout-minutes: 45 # 长等待模式:等CI全绿后自动合并,不遗漏任何PR
steps: steps:
- name: Checkout code - name: Checkout code
shell: sh shell: sh
+1 -1
View File
@@ -120,7 +120,7 @@ jobs:
PREVIEW_SSH_KEY: ${{ secrets.PREVIEW_SSH_KEY }} PREVIEW_SSH_KEY: ${{ secrets.PREVIEW_SSH_KEY }}
run: | run: |
set -eux set -eux
preview_host="${PREVIEW_SSH_HOST:-47.98.113.167}" preview_host="${PREVIEW_SSH_HOST:-172.30.18.197}"
preview_user="${PREVIEW_SSH_USER:-deploy}" preview_user="${PREVIEW_SSH_USER:-deploy}"
preview_port="${PREVIEW_SSH_PORT:-22222}" preview_port="${PREVIEW_SSH_PORT:-22222}"
preview_dir="/var/www/preview/pr-${PR_NUMBER}" preview_dir="/var/www/preview/pr-${PR_NUMBER}"
+1 -1
View File
@@ -101,7 +101,7 @@ jobs:
# Install dependencies with retry # Install dependencies with retry
for i in 1 2 3; do for i in 1 2 3; do
npm ci --no-audit --no-fund && break npm ci --no-audit --no-fund && break
echo "npm install failed, retry $i/3..." echo "npm ci failed, retry $i/3..."
[ $i -eq 3 ] && exit 1 [ $i -eq 3 ] && exit 1
rm -rf node_modules rm -rf node_modules
sleep 5 sleep 5
@@ -1,134 +0,0 @@
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,
}
}
+202 -33
View File
@@ -1,44 +1,213 @@
import type { CloneModalProps } from "../types/cloneModal" import { useState, useRef, useCallback, useEffect } from "react"
import { useCloneFormState } from "./useCloneFormState" import { createVoiceClone, toVoiceClone } from "@/api/voice-clone"
import { useCloneSubmit } from "./useCloneSubmit" import { uploadAsset } from "@/api/assets"
import type { ModalPhase, CloneModalProps } from "../types/cloneModal"
import { MIN_VOICE_NAME_LENGTH, MAX_VOICE_NAME_LENGTH } from "../constants/cloneModal"
import useAudioRecorder from "./useAudioRecorder"
interface UseCloneModalReturn {
phase: ModalPhase
voiceName: string
voiceDescription: string
selectedFile: File | null
dragActive: boolean
errorMessage: string
isRecording: boolean
recordTime: number
recordedBlob: Blob | null
canSubmit: boolean
isProcessing: boolean
setVoiceName: (value: string) => void
setVoiceDescription: (value: string) => void
setDragActive: (active: boolean) => void
handleFileSelect: (file: File | null, error: string) => void
handleRecordToggle: () => void
handleClose: () => void
handleSubmit: () => void
}
/** /**
* 音色克隆弹窗主业务 Hook * 音色克隆弹窗主业务 Hook
* 组合表单状态 + 提交流程两个子 Hook
*/ */
const useCloneModal = ({ open, onClose, onSuccess }: CloneModalProps) => { const useCloneModal = ({ open, onClose, onSuccess }: CloneModalProps): UseCloneModalReturn => {
const formState = useCloneFormState({ open, onClose }) 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 { handleSubmit } = useCloneSubmit({ const { isRecording, recordTime, recordedBlob, toggleRecording, resetRecording } =
voiceName: formState.voiceName, useAudioRecorder()
voiceDescription: formState.voiceDescription,
selectedFile: formState.selectedFile, const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
recordedBlob: formState.recordedBlob, /** 默认音色名称计数器 */
setPhase: formState.setPhase, const cloneCounterRef = useRef(1)
setErrorMessage: formState.setErrorMessage,
validateForm: formState.validateForm, const getNextDefaultName = useCallback((): string => {
const name = `我的声音 ${cloneCounterRef.current}`
cloneCounterRef.current += 1
return name
}, [])
const hasAudio = selectedFile !== null || recordedBlob !== null
const canSubmit =
voiceName.trim().length >= MIN_VOICE_NAME_LENGTH &&
voiceName.trim().length <= MAX_VOICE_NAME_LENGTH &&
hasAudio
const isProcessing = phase === "uploading" || phase === "cloning"
/** 重置弹窗状态 */
const resetState = useCallback(() => {
setPhase("input")
setVoiceName(getNextDefaultName())
setVoiceDescription("")
setSelectedFile(null)
setDragActive(false)
setErrorMessage("")
resetRecording()
}, [getNextDefaultName, resetRecording])
/** 关闭弹窗 */
const handleClose = useCallback(() => {
resetState()
onClose()
}, [resetState, onClose])
/** 弹窗打开时重置状态 */
useEffect(() => {
if (open) {
resetState()
}
}, [open, resetState])
/** 组件卸载时清理定时器 */
useEffect(() => {
return () => {
if (timerRef.current) clearTimeout(timerRef.current)
}
}, [])
/** 选择文件(来自上传或拖拽) */
const handleFileSelect = useCallback(
(file: File | null, error: string) => {
if (error) {
setErrorMessage(error)
setSelectedFile(null)
} else {
setErrorMessage("")
setSelectedFile(file)
// 清除录音
resetRecording()
}
},
[resetRecording],
)
/** 录音切换 */
const handleRecordToggle = useCallback(() => {
setErrorMessage("")
if (isRecording) {
toggleRecording()
} else {
// 开始录制前清除已选文件
setSelectedFile(null)
toggleRecording()
}
}, [isRecording, toggleRecording])
/** 表单验证 */
const validateForm = useCallback((): string | null => {
const name = voiceName.trim()
if (!name) {
return "请输入音色名称"
}
if (name.length < MIN_VOICE_NAME_LENGTH || name.length > MAX_VOICE_NAME_LENGTH) {
return `音色名称需在 ${MIN_VOICE_NAME_LENGTH}-${MAX_VOICE_NAME_LENGTH} 个字符之间`
}
if (!hasAudio) {
return "请上传音频文件或录制一段声音"
}
return null
}, [voiceName, hasAudio])
/** 提交克隆 */
const handleSubmit = useCallback(async () => {
const formError = validateForm()
if (formError) {
setErrorMessage(formError)
return
}
setErrorMessage("")
try {
// 阶段 1:上传音频
setPhase("uploading")
let fileToUpload: File
if (selectedFile) {
fileToUpload = selectedFile
} else {
fileToUpload = new File([recordedBlob!], `recorded-${Date.now()}.webm`, {
type: "audio/webm",
})
}
const formData = new FormData()
formData.append("file", fileToUpload)
const uploadResult = await uploadAsset(formData)
// 阶段 2:克隆
setPhase("cloning")
const result = await createVoiceClone({
name: voiceName.trim(),
description: voiceDescription.trim() || undefined,
audio_url: uploadResult.url,
})
// 阶段 3:完成
setPhase("done")
// 2秒后自动关闭
timerRef.current = setTimeout(() => {
onSuccess?.(toVoiceClone(result))
handleClose()
}, 2000)
} catch (err) {
setPhase("input")
setErrorMessage(err instanceof Error ? err.message : "克隆失败,请重试")
}
}, [
validateForm,
selectedFile,
recordedBlob,
voiceName,
voiceDescription,
onSuccess, onSuccess,
onClose: formState.handleClose, handleClose,
}) ])
return { return {
phase: formState.phase, phase,
voiceName: formState.voiceName, voiceName,
voiceDescription: formState.voiceDescription, voiceDescription,
selectedFile: formState.selectedFile, selectedFile,
dragActive: formState.dragActive, dragActive,
errorMessage: formState.errorMessage, errorMessage,
isRecording: formState.isRecording, isRecording,
recordTime: formState.recordTime, recordTime,
recordedBlob: formState.recordedBlob, recordedBlob,
canSubmit: formState.canSubmit, canSubmit,
isProcessing: formState.isProcessing, isProcessing,
setVoiceName: formState.setVoiceName, setVoiceName,
setVoiceDescription: formState.setVoiceDescription, setVoiceDescription,
setDragActive: formState.setDragActive, setDragActive,
handleFileSelect: formState.handleFileSelect, handleFileSelect,
handleRecordToggle: formState.handleRecordToggle, handleRecordToggle,
handleClose: formState.handleClose, handleClose,
handleSubmit, handleSubmit,
} }
} }
@@ -1,108 +0,0 @@
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 }
}
+184 -36
View File
@@ -1,35 +1,116 @@
/** /**
* 查重上传页面 — V21 设计系统 * 查重上传页面 — V21 设计系统
* 左右分栏:拖拽上传区 + 格式说明 * 左右分栏:拖拽上传区 + 格式说明
* 零 antd 依赖
*/ */
import React from "react" 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 { useNavigate } from "react-router-dom" import { useNavigate } from "react-router-dom"
import { Card } from "@/components/ui"
import PageHead from "@/components/layout/PageHead"
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" import "./duplication.css"
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"
}
const DuplicationUpload: React.FC = () => { const DuplicationUpload: React.FC = () => {
const navigate = useNavigate() const navigate = useNavigate()
const { const fileInputRef = useRef<HTMLInputElement>(null)
fileInputRef, const [dragging, setDragging] = useState(false)
dragging, const [uploading, setUploading] = useState(false)
uploading, const [uploadResult, setUploadResult] = useState<{
uploadResult, id: string
toast, message: string
handleDragOver, } | null>(null)
handleDragLeave, const [toast, setToast] = useState<ToastState | null>(null)
handleDrop,
handleSelectFile, /** 显示 toast */
handleFileChange, const showToast = useCallback((message: string, type: "success" | "error" | "warning") => {
handleReset, setToast({ message, type })
} = useDuplicationUpload() 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)
}
return ( return (
<div className="dup-page"> <div className="dup-page">
@@ -44,14 +125,25 @@ const DuplicationUpload: React.FC = () => {
<div className="dup-upload-grid"> <div className="dup-upload-grid">
{/* 左侧:上传区域 */} {/* 左侧:上传区域 */}
<Card> <Card>
<UploadZone {/* 拖拽上传区 */}
dragging={dragging} <div
uploading={uploading} className={`dup-upload-zone ${dragging ? "dragging" : ""} ${uploading ? "disabled" : ""}`}
onDragOver={handleDragOver} onDragOver={handleDragOver}
onDragLeave={handleDragLeave} onDragLeave={handleDragLeave}
onDrop={handleDrop} onDrop={handleDrop}
onClick={handleSelectFile} onClick={uploading ? undefined : handleSelectFile}
/> >
<div className="dup-upload-icon">{uploading ? "⏳" : "📁"}</div>
<h3>{uploading ? "正在上传并查重..." : "点击或拖拽视频文件到此区域"}</h3>
<p> MP4AVIMOVMKV 2GB</p>
<div className="dup-upload-formats">
{FORMAT_LIST.map((fmt) => (
<Tag key={fmt} variant="info">
{fmt}
</Tag>
))}
</div>
</div>
{/* 隐藏的文件 input */} {/* 隐藏的文件 input */}
<input <input
@@ -62,21 +154,77 @@ const DuplicationUpload: React.FC = () => {
onChange={handleFileChange} onChange={handleFileChange}
/> />
<UploadActions uploading={uploading} onSelectFile={handleSelectFile} /> {/* 上传按钮 */}
<div className="dup-upload-actions">
<Button
buttonType="primary"
buttonSize="md"
onClick={handleSelectFile}
disabled={uploading}
>
📂
</Button>
</div>
{uploading && <UploadProgress />} {/* 上传进度 */}
{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>
)}
{/* 上传结果 */}
{uploadResult && !uploading && ( {uploadResult && !uploading && (
<UploadResultPanel <div className="dup-result">
result={uploadResult} <div className="dup-result-icon"></div>
onViewResult={() => navigate("/app/duplication/results")} <h3></h3>
onReset={handleReset} <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>
)} )}
</Card> </Card>
{/* 右侧:格式说明 + 提示 */} {/* 右侧:格式说明 + 提示 */}
<InfoSidebar /> <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>
</div> </div>
</div> </div>
) )
+1 -7
View File
@@ -51,13 +51,7 @@ export const FILTER_OPTIONS: { key: RiskFilter; label: string }[] = [
{ key: "high", label: "高风险" }, { key: "high", label: "高风险" },
] ]
/** 支持的视频格式 */ /** Toast 类型 */
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 { export interface ToastState {
message: string message: string
type: "success" | "error" | "warning" type: "success" | "error" | "warning"
@@ -1,36 +0,0 @@
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
@@ -1,20 +0,0 @@
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
@@ -1,16 +0,0 @@
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
@@ -1,30 +0,0 @@
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
@@ -1,45 +0,0 @@
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> MP4AVIMOVMKV 2GB</p>
<div className="dup-upload-formats">
{FORMAT_LIST.map((fmt) => (
<Tag key={fmt} variant="info">
{fmt}
</Tag>
))}
</div>
</div>
)
}
export default UploadZone
@@ -1,17 +0,0 @@
/** 支持的视频格式 */
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
}
@@ -1,115 +0,0 @@
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,
}
}
+130 -21
View File
@@ -1,11 +1,15 @@
/**
* 绿幕抠像配置面板
* 5 种颜色预设 + 自定义颜色 + 相似度/边缘平滑/溢色抑制
*/
import React, { useCallback } from "react" import React, { useCallback } from "react"
import { Drawer, Switch } from "antd" import { Drawer, Switch } from "antd"
import type { ChromaKeyConfig, ChromaKeyColorPreset } from "../types" import type { ChromaKeyConfig, ChromaKeyColorPreset } from "../types"
import { DEFAULT_CHROMA_KEY_CONFIG, CHROMA_KEY_PRESET_COLORS } from "../types" import {
import { GreenScreenPresets } from "./green-screen/GreenScreenPresets" DEFAULT_CHROMA_KEY_CONFIG,
import { GreenScreenCustomColor } from "./green-screen/GreenScreenCustomColor" CHROMA_KEY_PRESET_LABELS,
import { GreenScreenSliders } from "./green-screen/GreenScreenSliders" CHROMA_KEY_PRESET_COLORS,
import { GreenScreenPreview } from "./green-screen/GreenScreenPreview" } from "../types"
interface GreenScreenPanelProps { interface GreenScreenPanelProps {
open: boolean open: boolean
@@ -14,6 +18,9 @@ interface GreenScreenPanelProps {
onChange: (config: ChromaKeyConfig) => void 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 GreenScreenPanel: React.FC<GreenScreenPanelProps> = ({ open, onClose, config, onChange }) => {
const update = useCallback( const update = useCallback(
(partial: Partial<ChromaKeyConfig>) => { (partial: Partial<ChromaKeyConfig>) => {
@@ -26,6 +33,7 @@ const GreenScreenPanel: React.FC<GreenScreenPanelProps> = ({ open, onClose, conf
onChange({ ...DEFAULT_CHROMA_KEY_CONFIG, enabled: config.enabled }) onChange({ ...DEFAULT_CHROMA_KEY_CONFIG, enabled: config.enabled })
}, [config.enabled, onChange]) }, [config.enabled, onChange])
/** 选择颜色预设时同步更新 color 字段 */
const handlePresetSelect = useCallback( const handlePresetSelect = useCallback(
(preset: ChromaKeyColorPreset) => { (preset: ChromaKeyColorPreset) => {
update({ update({
@@ -36,9 +44,10 @@ const GreenScreenPanel: React.FC<GreenScreenPanelProps> = ({ open, onClose, conf
[update], [update],
) )
/** 自定义颜色变化时清除预设标记 */
const handleColorChange = useCallback( const handleColorChange = useCallback(
(color: string) => { (e: React.ChangeEvent<HTMLInputElement>) => {
update({ color }) update({ color: e.target.value })
}, },
[update], [update],
) )
@@ -52,6 +61,7 @@ const GreenScreenPanel: React.FC<GreenScreenPanelProps> = ({ open, onClose, conf
onClose={onClose} onClose={onClose}
className="green-screen-panel-drawer" className="green-screen-panel-drawer"
> >
{/* 顶部开关 */}
<div className="green-header"> <div className="green-header">
<span className="green-header-label">绿</span> <span className="green-header-label">绿</span>
<Switch <Switch
@@ -61,24 +71,123 @@ const GreenScreenPanel: React.FC<GreenScreenPanelProps> = ({ open, onClose, conf
/> />
</div> </div>
<GreenScreenPresets {/* 颜色预设 */}
selectedPreset={config.color_preset} <div className="green-section">
onPresetSelect={handlePresetSelect} <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>
<GreenScreenCustomColor color={config.color} onColorChange={handleColorChange} /> {/* 自定义颜色 */}
<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>
<GreenScreenSliders {/* 参数调节 */}
similarity={config.similarity} <div className="green-section">
blend={config.blend} <div className="green-section-title"></div>
spill={config.spill}
onSimilarityChange={(v) => update({ similarity: v })}
onBlendChange={(v) => update({ blend: v })}
onSpillChange={(v) => update({ spill: v })}
/>
<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.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>
{/* 边缘平滑 */}
<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"> <div className="green-footer">
<button className="green-reset-btn" onClick={handleReset}> <button className="green-reset-btn" onClick={handleReset}>
@@ -1,37 +0,0 @@
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>
)
}
@@ -1,36 +0,0 @@
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>
)
}
@@ -1,28 +0,0 @@
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>
)
}
@@ -1,68 +0,0 @@
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>
)
}
@@ -1,116 +0,0 @@
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,
}
}
@@ -1,68 +0,0 @@
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,
}
}
@@ -1,38 +0,0 @@
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,
}
}
@@ -1,39 +0,0 @@
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,
}
}
@@ -1,15 +1,19 @@
import { useCallback } from "react" import { useCallback } from "react"
import { message } from "antd" import { message } from "antd"
import { useQueryClient, useMutation } from "@tanstack/react-query" import { useQueryClient, useMutation } from "@tanstack/react-query"
import type { CreateEditPlanClipRequest, UpdateEditPlanClipRequest } from "@/api/template-editor" import type {
CreateEditPlanClipRequest,
UpdateEditPlanClipRequest,
ClipReorderItem,
} from "@/api/template-editor"
import { import {
createEditPlanClip, createEditPlanClip,
updateEditPlanClip, updateEditPlanClip,
deleteEditPlanClip, deleteEditPlanClip,
reorderEditPlanClips,
batchDeleteEditPlanClips, batchDeleteEditPlanClips,
createClipsFromAssets,
} from "@/api/template-editor" } from "@/api/template-editor"
import { useClipReorder } from "./useClipReorder"
import { useClipImport } from "./useClipImport"
const QUERY_KEY = "editPlanClips" const QUERY_KEY = "editPlanClips"
@@ -20,6 +24,10 @@ interface UseEditPlanClipMutationsOptions {
clipsLength: number clipsLength: number
} }
/**
* 编辑计划片段 CRUD Hook
* 封装创建、更新、删除、批量删除、重排序、素材导入等操作
*/
export function useEditPlanClipMutations({ export function useEditPlanClipMutations({
planId, planId,
selectedClipId, selectedClipId,
@@ -32,6 +40,7 @@ export function useEditPlanClipMutations({
queryClient.invalidateQueries({ queryKey: [QUERY_KEY, planId] }) queryClient.invalidateQueries({ queryKey: [QUERY_KEY, planId] })
} }
/* ── 创建片段 ── */
const createMutation = useMutation({ const createMutation = useMutation({
mutationFn: (data: CreateEditPlanClipRequest) => createEditPlanClip(planId!, data), mutationFn: (data: CreateEditPlanClipRequest) => createEditPlanClip(planId!, data),
onSuccess: () => { onSuccess: () => {
@@ -52,6 +61,7 @@ export function useEditPlanClipMutations({
[planId, clipsLength, createMutation], [planId, clipsLength, createMutation],
) )
/* ── 更新片段 ── */
const updateMutation = useMutation({ const updateMutation = useMutation({
mutationFn: ({ clipId, data }: { clipId: string; data: UpdateEditPlanClipRequest }) => mutationFn: ({ clipId, data }: { clipId: string; data: UpdateEditPlanClipRequest }) =>
updateEditPlanClip(planId!, clipId, data), updateEditPlanClip(planId!, clipId, data),
@@ -71,6 +81,7 @@ export function useEditPlanClipMutations({
[planId, updateMutation], [planId, updateMutation],
) )
/* ── 删除片段 ── */
const deleteMutation = useMutation({ const deleteMutation = useMutation({
mutationFn: (clipId: string) => deleteEditPlanClip(planId!, clipId), mutationFn: (clipId: string) => deleteEditPlanClip(planId!, clipId),
onSuccess: () => { onSuccess: () => {
@@ -93,6 +104,7 @@ export function useEditPlanClipMutations({
[planId, selectedClipId, setSelectedClipId, deleteMutation], [planId, selectedClipId, setSelectedClipId, deleteMutation],
) )
/* ── 批量删除 ── */
const batchDeleteMutation = useMutation({ const batchDeleteMutation = useMutation({
mutationFn: (clipIds: string[]) => batchDeleteEditPlanClips(planId!, clipIds), mutationFn: (clipIds: string[]) => batchDeleteEditPlanClips(planId!, clipIds),
onSuccess: (res) => { onSuccess: (res) => {
@@ -115,8 +127,45 @@ export function useEditPlanClipMutations({
[planId, selectedClipId, setSelectedClipId, batchDeleteMutation], [planId, selectedClipId, setSelectedClipId, batchDeleteMutation],
) )
const { reorderClips, isReordering } = useClipReorder(planId) /* ── 重排序 ── */
const { importFromAssets, isImporting } = useClipImport(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],
)
/* ── 从素材批量导入 ── */
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 { return {
addClip, addClip,
@@ -128,7 +177,7 @@ export function useEditPlanClipMutations({
isCreating: createMutation.isPending, isCreating: createMutation.isPending,
isUpdating: updateMutation.isPending, isUpdating: updateMutation.isPending,
isDeleting: deleteMutation.isPending, isDeleting: deleteMutation.isPending,
isReordering, isReordering: reorderMutation.isPending,
isImporting, isImporting: importFromAssetsMutation.isPending,
} }
} }
+149 -24
View File
@@ -1,7 +1,12 @@
import { useState } from "react" import { useState, useRef, useCallback, useEffect, useMemo, useLayoutEffect } from "react"
import type { ClipData, ClipType } from "../types" import type { ClipData, ClipType } from "../types"
import { useContextMenu } from "./timeline-menus/useContextMenu" import { DEFAULT_ADD_DURATION, ADD_PICKER_WIDTH, TRACK_GAP } from "../constants/timeline"
import { useAddPicker } from "./timeline-menus/useAddPicker"
interface ContextMenuState {
x: number
y: number
clipId: string
}
/** /**
* 时间线菜单 Hook * 时间线菜单 Hook
@@ -15,30 +20,150 @@ export const useTimelineMenus = (
onClipResetTrim?: (clipId: string) => void, onClipResetTrim?: (clipId: string) => void,
onClipRemove?: (clipId: string) => void, onClipRemove?: (clipId: string) => void,
) => { ) => {
/* ── 右键菜单 ── */
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null)
const contextMenuRef = useRef<HTMLDivElement>(null)
/* ── 添加片段面板 ── */
const [showAddPicker, setShowAddPicker] = useState(false)
const pickerRef = useRef<HTMLDivElement>(null)
const addCardRef = useRef<HTMLDivElement>(null)
const [pickerPos, setPickerPos] = useState<{ top: number; right: number }>({ top: 0, right: 0 })
/* ── 悬停的片段 ID(显示裁剪手柄) ── */
const [hoveredClipId, setHoveredClipId] = useState<string | null>(null) const [hoveredClipId, setHoveredClipId] = useState<string | null>(null)
const { /* ── 根据模式决定可选类型 ── */
contextMenu, const availableTypes: ClipType[] = useMemo(
contextMenuRef, () =>
handleContextMenu, currentMode === "voice_over" ? ["voice"] : currentMode === "pip" ? ["pip"] : ["voice", "pip"],
handleContextSplit, [currentMode],
handleContextResetTrim, )
handleContextDelete,
} = useContextMenu({ onClipSplit, onClipResetTrim, onClipRemove })
const { /* ── 默认添加类型:跟随模式 ── */
showAddPicker, const defaultAddType: ClipType = useMemo(() => {
pickerRef, if (currentMode === "voice_over") return "voice"
addCardRef, if (currentMode === "pip") return "pip"
pickerPos, return "voice"
availableTypes, }, [currentMode])
addType,
addDuration, /* ── "+" 卡片:类型+时长选择状态 ── */
setAddType, const [addType, setAddType] = useState<ClipType>(defaultAddType)
setAddDuration, const [addDuration, setAddDuration] = useState<number>(DEFAULT_ADD_DURATION)
handleTogglePicker,
handleConfirmAdd, /* ── 模式切换时自动同步默认添加类型 ── */
} = useAddPicker({ currentMode, onAddClip }) useEffect(() => {
if (!availableTypes.includes(addType)) {
setAddType(defaultAddType)
}
}, [currentMode, addType, availableTypes, defaultAddType])
/* ── 计算 picker 初始位置 ── */
const updatePickerPosition = useCallback(() => {
if (!addCardRef.current) return
const rect = addCardRef.current.getBoundingClientRect()
const vw = window.innerWidth
const roughHeight = 180
let top = rect.top - TRACK_GAP - roughHeight
if (top < 8) top = 8
let right = vw - rect.right
if (rect.right - ADD_PICKER_WIDTH < 8) {
right = vw - ADD_PICKER_WIDTH - 8
}
setPickerPos({ top, right })
}, [])
const handleTogglePicker = useCallback(() => {
if (!showAddPicker) {
setAddType(defaultAddType)
updatePickerPosition()
}
setShowAddPicker((v) => !v)
}, [showAddPicker, defaultAddType, updatePickerPosition])
/* ── 渲染后精确边界校正 ── */
useLayoutEffect(() => {
if (!showAddPicker || !pickerRef.current || !addCardRef.current) return
const pickerEl = pickerRef.current
const addRect = addCardRef.current.getBoundingClientRect()
const pickerH = pickerEl.offsetHeight
const vh = window.innerHeight
const vw = window.innerWidth
let top = addRect.top - TRACK_GAP - pickerH
if (top < 8) {
top = addRect.bottom + TRACK_GAP
if (top + pickerH > vh - 8) {
top = vh - 8 - pickerH
if (top < 8) top = 8
}
}
let right = vw - addRect.right
const pickerRect = pickerEl.getBoundingClientRect()
if (pickerRect.left < 8) {
right = vw - ADD_PICKER_WIDTH - 8
}
setPickerPos({ top, right })
}, [showAddPicker])
/* ── 点击外部关闭添加面板 ── */
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (pickerRef.current && !pickerRef.current.contains(e.target as Node)) {
setShowAddPicker(false)
}
}
if (showAddPicker) {
document.addEventListener("mousedown", handleClickOutside)
}
return () => document.removeEventListener("mousedown", handleClickOutside)
}, [showAddPicker])
/* ── 点击外部关闭右键菜单 ── */
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (contextMenuRef.current && !contextMenuRef.current.contains(e.target as Node)) {
setContextMenu(null)
}
}
if (contextMenu) {
document.addEventListener("mousedown", handleClickOutside)
}
return () => document.removeEventListener("mousedown", handleClickOutside)
}, [contextMenu])
/* ── 确认添加片段 ── */
const handleConfirmAdd = useCallback(() => {
onAddClip(addType, addDuration)
setShowAddPicker(false)
}, [onAddClip, addType, addDuration])
/* ── 右键菜单操作 ── */
const handleContextMenu = useCallback((e: React.MouseEvent, clipId: string) => {
e.preventDefault()
e.stopPropagation()
setContextMenu({ x: e.clientX, y: e.clientY, clipId })
}, [])
const handleContextSplit = useCallback(() => {
if (!contextMenu) return
onClipSplit?.(contextMenu.clipId, 0.5)
setContextMenu(null)
}, [contextMenu, onClipSplit])
const handleContextResetTrim = useCallback(() => {
if (!contextMenu) return
onClipResetTrim?.(contextMenu.clipId)
setContextMenu(null)
}, [contextMenu, onClipResetTrim])
const handleContextDelete = useCallback(() => {
if (!contextMenu) return
onClipRemove?.(contextMenu.clipId)
setContextMenu(null)
}, [contextMenu, onClipRemove])
return { return {
// 右键菜单 // 右键菜单
+102 -11
View File
@@ -1,21 +1,37 @@
/**
* 智能剪辑右侧生成结果面板
*/
import React from "react" import React from "react"
import { Typography } from "antd" import { Typography } from "antd"
import { PlayCircleOutlined, CloseCircleOutlined } from "@ant-design/icons" import {
PlayCircleOutlined,
CloseCircleOutlined,
DownloadOutlined,
ShareAltOutlined,
} from "@ant-design/icons"
import type { GeneratedVideo } from "@/api/template-editor" import type { GeneratedVideo } from "@/api/template-editor"
import { ProgressIndicator } from "./result-panel/ProgressIndicator" import { formatDuration } from "@/api/voice-clone"
import { ResultVideoCard } from "./result-panel/ResultVideoCard"
const { Text } = Typography const { Text } = Typography
interface GenerateResultPanelProps { interface GenerateResultPanelProps {
/** 是否已生成完成 */
generated: boolean generated: boolean
/** 是否正在生成中 */
generating: boolean generating: boolean
/** 生成进度(0-100 */
progress: number progress: number
/** 生成错误信息 */
generateError: string | null generateError: string | null
/** 生成的视频列表 */
generatedVideos: GeneratedVideo[] generatedVideos: GeneratedVideo[]
/** 点击视频卡片预览回调 */
onVideoPreview: (video: GeneratedVideo) => void onVideoPreview: (video: GeneratedVideo) => void
/** 下载回调 */
onDownload: () => void onDownload: () => void
/** 分享回调 */
onShare: () => void onShare: () => void
/** 前往成片库回调 */
onGoToLibrary: () => void onGoToLibrary: () => void
} }
@@ -39,8 +55,45 @@ const GenerateResultPanel: React.FC<GenerateResultPanelProps> = ({
)} )}
</div> </div>
{generating && <ProgressIndicator progress={progress} />} {/* 生成中进度 */}
{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>
)}
{/* 生成失败 */}
{generateError && !generating && ( {generateError && !generating && (
<div className="xx-result-empty"> <div className="xx-result-empty">
<CloseCircleOutlined style={{ fontSize: 40, color: "#ff4d4f", marginBottom: 12 }} /> <CloseCircleOutlined style={{ fontSize: 40, color: "#ff4d4f", marginBottom: 12 }} />
@@ -53,6 +106,7 @@ const GenerateResultPanel: React.FC<GenerateResultPanelProps> = ({
</div> </div>
)} )}
{/* 空状态 */}
{!generated && !generating && !generateError && ( {!generated && !generating && !generateError && (
<div className="xx-result-empty"> <div className="xx-result-empty">
<PlayCircleOutlined <PlayCircleOutlined
@@ -67,17 +121,54 @@ const GenerateResultPanel: React.FC<GenerateResultPanelProps> = ({
</div> </div>
)} )}
{/* 生成结果卡片列表 */}
{generated && generatedVideos.length > 0 && ( {generated && generatedVideos.length > 0 && (
<div className="xx-video-grid"> <div className="xx-video-grid">
{generatedVideos.map((video, idx) => ( {generatedVideos.map((video, idx) => (
<ResultVideoCard <div
key={video.id || idx} key={video.id || idx}
video={video} className="xx-video-card"
index={idx} onClick={() => onVideoPreview(video)}
onPreview={onVideoPreview} >
onDownload={onDownload} <div className="xx-video-thumb">
onShare={onShare} {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>
))} ))}
</div> </div>
)} )}
+101 -16
View File
@@ -1,9 +1,9 @@
/**
* Step 6 封面设置组件
*/
import React from "react" import React from "react"
import type { CoverConfig } from "../../editing-planner/types" import type { CoverConfig } from "../../editing-planner/types"
import { useStep6Cover } from "../hooks/useStep6Cover" 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 { interface Step6CoverSettingsProps {
coverSettings: CoverConfig coverSettings: CoverConfig
@@ -28,6 +28,7 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
<div className="xx-form-section"> <div className="xx-form-section">
<h3>🖼 </h3> <h3>🖼 </h3>
{/* 启用开关 */}
<div className="xx-cover-header"> <div className="xx-cover-header">
<span className="xx-cover-header-label"></span> <span className="xx-cover-header-label"></span>
<label className="xx-switch"> <label className="xx-switch">
@@ -42,14 +43,22 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
{coverSettings.enabled && ( {coverSettings.enabled && (
<> <>
{/* 模式选择 */}
<div className="xx-section-title"></div> <div className="xx-section-title"></div>
<CoverModeSelector <div className="xx-cover-mode-tabs">
mode={coverSettings.mode} {(["auto", "frame", "upload"] as const).map((m) => (
onModeChange={setMode} <button
modeLabels={COVER_MODE_LABELS} key={m}
modeIcons={COVER_MODE_ICONS} 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>
{/* 智能封面 */}
{coverSettings.mode === "auto" && ( {coverSettings.mode === "auto" && (
<div className="xx-cover-auto"> <div className="xx-cover-auto">
<div className="xx-cover-auto-desc"> <div className="xx-cover-auto-desc">
@@ -62,19 +71,95 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
</div> </div>
)} )}
{/* 抽帧选封面 */}
{coverSettings.mode === "frame" && ( {coverSettings.mode === "frame" && (
<FrameCoverPicker <div className="xx-cover-frame">
frameTime={coverSettings.frame_time} <div className="xx-cover-frame-preview">
totalDuration={totalDuration} <div className="xx-cover-frame-placeholder">
formatTime={formatTime} <span className="xx-cover-frame-icon">🎞</span>
onFrameTimeChange={setFrameTime} <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>
)} )}
{/* 上传封面 */}
{coverSettings.mode === "upload" && ( {coverSettings.mode === "upload" && (
<UploadCoverPicker uploadUrl={coverSettings.upload_url} onUpload={handleUpload} /> <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>
)} )}
{/* 封面预览 */}
<div className="xx-section-title"></div> <div className="xx-section-title"></div>
<div className="xx-cover-preview-box"> <div className="xx-cover-preview-box">
{coverSettings.upload_url ? ( {coverSettings.upload_url ? (
+130 -24
View File
@@ -2,14 +2,19 @@
* Step 7 确认生成组件 * Step 7 确认生成组件
*/ */
import React from "react" import React from "react"
import {
LoadingOutlined,
CheckCircleFilled,
CloseCircleOutlined,
MinusOutlined,
PlusOutlined,
} from "@ant-design/icons"
import type { EditingTemplate } from "@/api/editing-planner" import type { EditingTemplate } from "@/api/editing-planner"
import type { GeneratedVideo } from "@/api/template-editor" import type { GeneratedVideo } from "@/api/template-editor"
import type { CoverConfig } from "../../editing-planner/types" import type { CoverConfig } from "../../editing-planner/types"
import type { VoiceClone } from "@/api/voice-clone" import type { VoiceClone } from "@/api/voice-clone"
import type { PresetVoiceItem } from "@/api/voices" import type { PresetVoiceItem } from "@/api/voices"
import { useStep7Generate } from "../hooks/useStep7Generate" import { useStep7Generate } from "../hooks/useStep7Generate"
import SummaryCard from "./step7-confirm/SummaryCard"
import GenerationStatus from "./step7-confirm/GenerationStatus"
interface Step7ConfirmGenerateProps { interface Step7ConfirmGenerateProps {
templates: EditingTemplate[] templates: EditingTemplate[]
@@ -59,28 +64,129 @@ const Step7ConfirmGenerate: React.FC<Step7ConfirmGenerateProps> = (props) => {
return ( return (
<div className="xx-form-section"> <div className="xx-form-section">
<h3> </h3> <h3> </h3>
<SummaryCard <div className="xx-summary-card">
templateName={templateName} <div className="xx-summary-row">
materialSummary={materialSummary} <span className="xx-summary-label"></span>
title={title} <span className="xx-summary-value">{templateName}</span>
voiceName={voiceName} </div>
coverSummary={coverSummary} <div className="xx-summary-row">
generateCount={generateCount} <span className="xx-summary-label"></span>
generating={generating} <span className="xx-summary-value">{materialSummary}</span>
onDecrement={handleDecrement} </div>
onIncrement={handleIncrement} <div className="xx-summary-row">
/> <span className="xx-summary-label"></span>
<GenerationStatus <span className="xx-summary-value">{title || "未选择"}</span>
generating={generating} </div>
generated={generated} <div className="xx-summary-row">
generateError={generateError} <span className="xx-summary-label"></span>
progress={progress} <span className="xx-summary-value">{voiceName}</span>
generatedVideos={generatedVideos} </div>
getGenerationPhase={getGenerationPhase} <div className="xx-summary-row">
onScrollToPreview={handleScrollToPreview} <span className="xx-summary-label"></span>
onRetry={onRetry} <span className="xx-summary-value">{coverSummary}</span>
onDismissError={onDismissError} </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>
)}
</div> </div>
) )
} }
@@ -1,32 +0,0 @@
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>
)
}
@@ -1,58 +0,0 @@
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>
)
}
@@ -1,46 +0,0 @@
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>
)
}
@@ -1,40 +0,0 @@
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>
)
}
@@ -1,63 +0,0 @@
import React from "react"
import { PlayCircleOutlined, DownloadOutlined, ShareAltOutlined } from "@ant-design/icons"
import type { GeneratedVideo } from "@/api/template-editor"
import { formatDuration } from "@/api/voice-clone"
interface ResultVideoCardProps {
video: GeneratedVideo
index: number
onPreview: (video: GeneratedVideo) => void
onDownload: () => void
onShare: () => void
}
export const ResultVideoCard: React.FC<ResultVideoCardProps> = ({
video,
index,
onPreview,
onDownload,
onShare,
}) => {
return (
<div className="xx-video-card" onClick={() => onPreview(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"> {index + 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>
)
}
@@ -1,106 +0,0 @@
import React from "react"
import { LoadingOutlined, CheckCircleFilled, CloseCircleOutlined } from "@ant-design/icons"
import type { GeneratedVideo } from "@/api/template-editor"
interface GenerationStatusProps {
generating: boolean
generated: boolean
generateError: string | null
progress: number
generatedVideos: GeneratedVideo[]
getGenerationPhase: (progress: number) => { icon: string; label: string }
onScrollToPreview: () => void
onRetry: () => void
onDismissError: () => void
}
const GenerationStatus: React.FC<GenerationStatusProps> = ({
generating,
generated,
generateError,
progress,
generatedVideos,
getGenerationPhase,
onScrollToPreview,
onRetry,
onDismissError,
}) => {
if (!generating && !generated && !generateError) return null
return (
<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={onScrollToPreview}
>
</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>
)
}
export default GenerationStatus
@@ -1,76 +0,0 @@
import React from "react"
import { MinusOutlined, PlusOutlined } from "@ant-design/icons"
interface SummaryCardProps {
templateName: string
materialSummary: string
title: string
voiceName: string
coverSummary: string
generateCount: number
generating: boolean
onDecrement: () => void
onIncrement: () => void
}
const SummaryCard: React.FC<SummaryCardProps> = ({
templateName,
materialSummary,
title,
voiceName,
coverSummary,
generateCount,
generating,
onDecrement,
onIncrement,
}) => {
return (
<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={onDecrement}
>
<MinusOutlined />
</button>
<span className="xx-count-stepper-value">{generateCount}</span>
<button
className="xx-count-stepper-btn"
disabled={generateCount >= 10 || generating}
onClick={onIncrement}
>
<PlusOutlined />
</button>
<span className="xx-count-stepper-hint"></span>
</div>
</span>
</div>
</div>
)
}
export default SummaryCard
@@ -1,41 +0,0 @@
import { useState, useEffect } from "react"
import { useQuery } from "@tanstack/react-query"
import { getAssets, getAssetLibraries } from "@/api/assets"
import type { AssetItem } from "@/api/assets"
/**
* 素材库加载 Hook
* 管理素材库列表、当前选中库、素材列表加载
*/
export function useMaterialLibrary() {
/* ── 素材库数据 API ── */
const { data: libraries = [] } = useQuery({
queryKey: ["asset-libraries"],
queryFn: getAssetLibraries,
})
const [selectedLibraryId, setSelectedLibraryId] = useState<string>("")
// 自动选中第一个视频库
useEffect(() => {
if (libraries.length > 0 && !selectedLibraryId) {
setSelectedLibraryId(libraries[0].id)
}
}, [libraries, selectedLibraryId])
const { data: materials = { items: [], total: 0 }, isLoading: materialsLoading } = useQuery<{
items: AssetItem[]
total: number
}>({
queryKey: ["generate-assets", selectedLibraryId],
queryFn: () => getAssets(selectedLibraryId),
enabled: !!selectedLibraryId,
})
return {
libraries,
selectedLibraryId,
setSelectedLibraryId,
materials,
materialsLoading,
}
}
@@ -1,143 +0,0 @@
import { useState, useCallback, useMemo } from "react"
import { message } from "antd"
import type { AssetItem } from "@/api/assets"
import { SMART_MATCH_REASONS } from "../../constants"
interface SmartMatchedResult {
asset: AssetItem
matchScore: number
matchReason: string
}
interface UseSmartMatchOptions {
materials: { items: AssetItem[]; total: number }
smartSelectedIds: string[]
onSmartSelectedIdsChange: (ids: string[]) => void
}
/**
* 智能素材匹配 Hook
* 封装 AI 匹配、换一批、全选/清空等逻辑
*/
export function useSmartMatch({
materials,
smartSelectedIds,
onSmartSelectedIdsChange,
}: UseSmartMatchOptions) {
const [smartMatchInput, setSmartMatchInput] = useState("")
const [smartMatching, setSmartMatching] = useState(false)
const [smartMatchedResults, setSmartMatchedResults] = useState<SmartMatchedResult[]>([])
const [hasMatched, setHasMatched] = useState(false)
/* ── 智能素材匹配 ── */
const handleSmartMatch = useCallback(async () => {
if (!smartMatchInput.trim()) {
message.warning("请先输入视频内容描述")
return
}
if (materials.items.length === 0) {
message.warning("当前视频库暂无素材")
return
}
setSmartMatching(true)
setHasMatched(true)
// 模拟 AI 匹配延迟
await new Promise((resolve) => setTimeout(resolve, 1500))
// 从素材库中随机选取 5-8 个作为推荐结果
const shuffled = [...materials.items].sort(() => Math.random() - 0.5)
const count = Math.min(shuffled.length, 5 + Math.floor(Math.random() * 4))
const picked = shuffled.slice(0, count)
const results = picked.map((asset, idx) => ({
asset,
matchScore: Math.round(85 + Math.random() * 14), // 85-99 分
matchReason:
SMART_MATCH_REASONS[idx % SMART_MATCH_REASONS.length] +
(Math.random() > 0.5 ? ",画面质感优秀" : ""),
}))
// 按匹配度从高到低排序
results.sort((a, b) => b.matchScore - a.matchScore)
setSmartMatchedResults(results)
// 默认选中匹配度 >= 90 的素材
const defaultSelected = results.filter((r) => r.matchScore >= 90).map((r) => r.asset.id)
onSmartSelectedIdsChange(
defaultSelected.length > 0 ? defaultSelected : results.slice(0, 3).map((r) => r.asset.id),
)
setSmartMatching(false)
}, [smartMatchInput, materials.items, onSmartSelectedIdsChange])
const handleToggleSmartSelect = useCallback(
(assetId: string) => {
onSmartSelectedIdsChange(
smartSelectedIds.includes(assetId)
? smartSelectedIds.filter((id) => id !== assetId)
: [...smartSelectedIds, assetId],
)
},
[smartSelectedIds, onSmartSelectedIdsChange],
)
const handleRefreshMatch = useCallback(async () => {
if (materials.items.length <= 5) {
message.info("视频库素材较少,无法换一批")
return
}
setSmartMatching(true)
await new Promise((resolve) => setTimeout(resolve, 800))
const remaining = materials.items.filter(
(m) => !smartMatchedResults.some((r) => r.asset.id === m.id),
)
const shuffled = [...remaining].sort(() => Math.random() - 0.5)
const count = Math.min(shuffled.length, 5 + Math.floor(Math.random() * 3))
const picked = shuffled.slice(0, count)
const results = picked.map((asset, idx) => ({
asset,
matchScore: Math.round(80 + Math.random() * 19),
matchReason:
SMART_MATCH_REASONS[(idx + 2) % SMART_MATCH_REASONS.length] +
(Math.random() > 0.5 ? ",节奏明快" : ""),
}))
results.sort((a, b) => b.matchScore - a.matchScore)
setSmartMatchedResults(results)
onSmartSelectedIdsChange([])
setSmartMatching(false)
}, [materials.items, smartMatchedResults, onSmartSelectedIdsChange])
const handleSelectAllMatched = useCallback(() => {
onSmartSelectedIdsChange(smartMatchedResults.map((r) => r.asset.id))
}, [smartMatchedResults, onSmartSelectedIdsChange])
const handleClearSmartSelect = useCallback(() => {
onSmartSelectedIdsChange([])
}, [onSmartSelectedIdsChange])
/* ── 计算已选智能匹配素材的总时长 ── */
const smartSelectedTotalDuration = useMemo(() => {
return smartMatchedResults
.filter((r) => smartSelectedIds.includes(r.asset.id))
.reduce((sum, r) => sum + (r.asset.duration || 0), 0)
}, [smartMatchedResults, smartSelectedIds])
return {
smartMatchInput,
setSmartMatchInput,
smartMatching,
smartMatchedResults,
hasMatched,
smartSelectedIds,
handleSmartMatch,
handleToggleSmartSelect,
handleRefreshMatch,
handleSelectAllMatched,
handleClearSmartSelect,
smartSelectedTotalDuration,
}
}
@@ -1,70 +0,0 @@
import { useCallback } from "react"
import type { VoiceClone } from "@/api/voice-clone"
interface UseVoiceModeSelectionOptions {
selectedVoice: string
onSelectedVoiceChange: (voiceId: string) => void
voiceMode: "preset" | "custom" | "clone"
onVoiceModeChange: (mode: "preset" | "custom" | "clone") => void
selectedClonedVoice: string
onSelectedClonedVoiceChange: (voiceId: string) => void
addClone: (voice: VoiceClone) => void
cloneModalOpen: boolean
onCloneModalOpenChange: (open: boolean) => void
}
export function useVoiceModeSelection({
onSelectedVoiceChange,
onVoiceModeChange,
onSelectedClonedVoiceChange,
addClone,
onCloneModalOpenChange,
}: UseVoiceModeSelectionOptions) {
const handleSelectRecommendedVoice = useCallback(
(voiceId: string) => {
onVoiceModeChange("preset")
onSelectedVoiceChange(voiceId)
},
[onVoiceModeChange, onSelectedVoiceChange],
)
const handleSelectPresetVoice = useCallback(
(voiceId: string) => {
onVoiceModeChange("preset")
onSelectedVoiceChange(voiceId)
},
[onVoiceModeChange, onSelectedVoiceChange],
)
const handleSelectCloneVoice = useCallback(() => {
onVoiceModeChange("clone")
}, [onVoiceModeChange])
const handleSelectClonedVoice = useCallback(
(voiceId: string) => {
onSelectedClonedVoiceChange(voiceId)
},
[onSelectedClonedVoiceChange],
)
const handleOpenCloneModal = useCallback(() => {
onCloneModalOpenChange(true)
}, [onCloneModalOpenChange])
const handleCloneSuccess = useCallback(
(voice: VoiceClone) => {
addClone(voice)
onCloneModalOpenChange(false)
},
[addClone, onCloneModalOpenChange],
)
return {
handleSelectRecommendedVoice,
handleSelectPresetVoice,
handleSelectCloneVoice,
handleSelectClonedVoice,
handleOpenCloneModal,
handleCloneSuccess,
}
}
+149 -23
View File
@@ -1,11 +1,20 @@
/** /**
* Step 2 素材选择 Hook * Step 2 素材选择 Hook
* 组合素材库加载 + 智能匹配两个子 Hook * 封装素材库加载、手动选择、智能匹配等逻辑
*/ */
import { useCallback } from "react" import { useState, useCallback, useEffect, useMemo } from "react"
import { message } from "antd"
import { useQuery } from "@tanstack/react-query"
import { getAssets, getAssetLibraries } from "@/api/assets"
import type { AssetItem } from "@/api/assets"
import { formatDuration } from "../utils/formatDuration" import { formatDuration } from "../utils/formatDuration"
import { useMaterialLibrary } from "./step2-materials/useMaterialLibrary" import { SMART_MATCH_REASONS } from "../constants"
import { useSmartMatch } from "./step2-materials/useSmartMatch"
interface SmartMatchedResult {
asset: AssetItem
matchScore: number
matchReason: string
}
interface UseStep2MaterialsProps { interface UseStep2MaterialsProps {
materialMode: "manual" | "auto" materialMode: "manual" | "auto"
@@ -24,14 +33,34 @@ export function useStep2Materials({
smartSelectedIds, smartSelectedIds,
onSmartSelectedIdsChange, onSmartSelectedIdsChange,
}: UseStep2MaterialsProps) { }: UseStep2MaterialsProps) {
const { libraries, selectedLibraryId, setSelectedLibraryId, materials, materialsLoading } = /* ── 素材库数据 API ── */
useMaterialLibrary() const { data: libraries = [] } = useQuery({
queryKey: ["asset-libraries"],
const smartMatch = useSmartMatch({ queryFn: getAssetLibraries,
materials,
smartSelectedIds,
onSmartSelectedIdsChange,
}) })
const [selectedLibraryId, setSelectedLibraryId] = useState<string>("")
// 自动选中第一个视频库
useEffect(() => {
if (libraries.length > 0 && !selectedLibraryId) {
setSelectedLibraryId(libraries[0].id)
}
}, [libraries, selectedLibraryId])
const { data: materials = { items: [], total: 0 }, isLoading: materialsLoading } = useQuery<{
items: AssetItem[]
total: number
}>({
queryKey: ["generate-assets", selectedLibraryId],
queryFn: () => getAssets(selectedLibraryId),
enabled: !!selectedLibraryId,
})
/* ── 智能素材匹配状态 ── */
const [smartMatchInput, setSmartMatchInput] = useState("")
const [smartMatching, setSmartMatching] = useState(false)
const [smartMatchedResults, setSmartMatchedResults] = useState<SmartMatchedResult[]>([])
const [hasMatched, setHasMatched] = useState(false)
/* ── 手动选择素材 ── */ /* ── 手动选择素材 ── */
const handleToggleMaterial = useCallback( const handleToggleMaterial = useCallback(
@@ -45,6 +74,103 @@ export function useStep2Materials({
[selectedMaterials, onSelectedMaterialsChange], [selectedMaterials, onSelectedMaterialsChange],
) )
/* ── 智能素材匹配 ── */
const handleSmartMatch = useCallback(async () => {
if (!smartMatchInput.trim()) {
message.warning("请先输入视频内容描述")
return
}
if (materials.items.length === 0) {
message.warning("当前视频库暂无素材")
return
}
setSmartMatching(true)
setHasMatched(true)
// 模拟 AI 匹配延迟
await new Promise((resolve) => setTimeout(resolve, 1500))
// 从素材库中随机选取 5-8 个作为推荐结果
const shuffled = [...materials.items].sort(() => Math.random() - 0.5)
const count = Math.min(shuffled.length, 5 + Math.floor(Math.random() * 4))
const picked = shuffled.slice(0, count)
const results = picked.map((asset, idx) => ({
asset,
matchScore: Math.round(85 + Math.random() * 14), // 85-99 分
matchReason:
SMART_MATCH_REASONS[idx % SMART_MATCH_REASONS.length] +
(Math.random() > 0.5 ? ",画面质感优秀" : ""),
}))
// 按匹配度从高到低排序
results.sort((a, b) => b.matchScore - a.matchScore)
setSmartMatchedResults(results)
// 默认选中匹配度 >= 90 的素材
const defaultSelected = results.filter((r) => r.matchScore >= 90).map((r) => r.asset.id)
onSmartSelectedIdsChange(
defaultSelected.length > 0 ? defaultSelected : results.slice(0, 3).map((r) => r.asset.id),
)
setSmartMatching(false)
}, [smartMatchInput, materials.items, onSmartSelectedIdsChange])
const handleToggleSmartSelect = useCallback(
(assetId: string) => {
onSmartSelectedIdsChange(
smartSelectedIds.includes(assetId)
? smartSelectedIds.filter((id) => id !== assetId)
: [...smartSelectedIds, assetId],
)
},
[smartSelectedIds, onSmartSelectedIdsChange],
)
const handleRefreshMatch = useCallback(async () => {
if (materials.items.length <= 5) {
message.info("视频库素材较少,无法换一批")
return
}
setSmartMatching(true)
await new Promise((resolve) => setTimeout(resolve, 800))
const remaining = materials.items.filter(
(m) => !smartMatchedResults.some((r) => r.asset.id === m.id),
)
const shuffled = [...remaining].sort(() => Math.random() - 0.5)
const count = Math.min(shuffled.length, 5 + Math.floor(Math.random() * 3))
const picked = shuffled.slice(0, count)
const results = picked.map((asset, idx) => ({
asset,
matchScore: Math.round(80 + Math.random() * 19),
matchReason:
SMART_MATCH_REASONS[(idx + 2) % SMART_MATCH_REASONS.length] +
(Math.random() > 0.5 ? ",节奏明快" : ""),
}))
results.sort((a, b) => b.matchScore - a.matchScore)
setSmartMatchedResults(results)
onSmartSelectedIdsChange([])
setSmartMatching(false)
}, [materials.items, smartMatchedResults, onSmartSelectedIdsChange])
const handleSelectAllMatched = useCallback(() => {
onSmartSelectedIdsChange(smartMatchedResults.map((r) => r.asset.id))
}, [smartMatchedResults, onSmartSelectedIdsChange])
const handleClearSmartSelect = useCallback(() => {
onSmartSelectedIdsChange([])
}, [onSmartSelectedIdsChange])
/* ── 计算已选智能匹配素材的总时长 ── */
const smartSelectedTotalDuration = useMemo(() => {
return smartMatchedResults
.filter((r) => smartSelectedIds.includes(r.asset.id))
.reduce((sum, r) => sum + (r.asset.duration || 0), 0)
}, [smartMatchedResults, smartSelectedIds])
return { return {
// 素材库 // 素材库
libraries, libraries,
@@ -59,18 +185,18 @@ export function useStep2Materials({
selectedMaterials, selectedMaterials,
handleToggleMaterial, handleToggleMaterial,
// 智能匹配 // 智能匹配
smartMatchInput: smartMatch.smartMatchInput, smartMatchInput,
setSmartMatchInput: smartMatch.setSmartMatchInput, setSmartMatchInput,
smartMatching: smartMatch.smartMatching, smartMatching,
smartMatchedResults: smartMatch.smartMatchedResults, smartMatchedResults,
hasMatched: smartMatch.hasMatched, hasMatched,
smartSelectedIds: smartMatch.smartSelectedIds, smartSelectedIds,
handleSmartMatch: smartMatch.handleSmartMatch, handleSmartMatch,
handleToggleSmartSelect: smartMatch.handleToggleSmartSelect, handleToggleSmartSelect,
handleRefreshMatch: smartMatch.handleRefreshMatch, handleRefreshMatch,
handleSelectAllMatched: smartMatch.handleSelectAllMatched, handleSelectAllMatched,
handleClearSmartSelect: smartMatch.handleClearSmartSelect, handleClearSmartSelect,
smartSelectedTotalDuration: smartMatch.smartSelectedTotalDuration, smartSelectedTotalDuration,
// utils // utils
formatDuration, formatDuration,
} }
+43 -24
View File
@@ -2,15 +2,15 @@
* Step 5 配音选择 Hook * Step 5 配音选择 Hook
* 封装 AI 推荐、预设音色试听、TTS 自定义合成、存为素材等逻辑 * 封装 AI 推荐、预设音色试听、TTS 自定义合成、存为素材等逻辑
*/ */
import { message } from "antd" import { useCallback } from "react"
import type { VoiceClone } from "@/api/voice-clone" import type { VoiceClone } from "@/api/voice-clone"
import { message } from "antd"
import { VOICE_GENDER_ICON, CLONE_STATUS_CONFIG } from "../constants" import { VOICE_GENDER_ICON, CLONE_STATUS_CONFIG } from "../constants"
import { formatDuration } from "../utils/formatDuration" import { formatDuration } from "../utils/formatDuration"
import { useVoiceAudio } from "./step5-voice/useVoiceAudio" import { useVoiceAudio } from "./step5-voice/useVoiceAudio"
import { useVoiceRecommend } from "./step5-voice/useVoiceRecommend" import { useVoiceRecommend } from "./step5-voice/useVoiceRecommend"
import { useTtsSynthesis } from "./step5-voice/useTtsSynthesis" import { useTtsSynthesis } from "./step5-voice/useTtsSynthesis"
import { useSaveToLibrary } from "./step5-voice/useSaveToLibrary" import { useSaveToLibrary } from "./step5-voice/useSaveToLibrary"
import { useVoiceModeSelection } from "./step5-voice/useVoiceModeSelection"
interface UseStep5VoiceProps { interface UseStep5VoiceProps {
selectedVoice: string selectedVoice: string
@@ -81,29 +81,48 @@ export function useStep5Voice({
handleAddTagInModal, handleAddTagInModal,
} = useSaveToLibrary(completedTtsJobId, resetTtsState) } = useSaveToLibrary(completedTtsJobId, resetTtsState)
const { /* ── 推荐音色选择 ── */
handleSelectRecommendedVoice, const handleSelectRecommendedVoice = useCallback(
handleSelectPresetVoice, (voiceId: string) => {
handleSelectCloneVoice, onVoiceModeChange("preset")
handleSelectClonedVoice, onSelectedVoiceChange(voiceId)
handleOpenCloneModal, },
handleCloneSuccess: rawCloneSuccess, [onVoiceModeChange, onSelectedVoiceChange],
} = useVoiceModeSelection({ )
selectedVoice,
onSelectedVoiceChange,
voiceMode,
onVoiceModeChange,
selectedClonedVoice,
onSelectedClonedVoiceChange,
addClone,
cloneModalOpen,
onCloneModalOpenChange,
})
const handleCloneSuccess = (voice: VoiceClone) => { /* ── 克隆成功回调 ── */
rawCloneSuccess(voice) const handleCloneSuccess = useCallback(
message.success("音色克隆成功!") (voice: VoiceClone) => {
} addClone(voice)
onCloneModalOpenChange(false)
message.success("音色克隆成功!")
},
[addClone, onCloneModalOpenChange],
)
/* ── 预设音色选择操作 ── */
const handleSelectPresetVoice = useCallback(
(voiceId: string) => {
onVoiceModeChange("preset")
onSelectedVoiceChange(voiceId)
},
[onVoiceModeChange, onSelectedVoiceChange],
)
const handleSelectCloneVoice = useCallback(() => {
onVoiceModeChange("clone")
}, [onVoiceModeChange])
const handleSelectClonedVoice = useCallback(
(voiceId: string) => {
onSelectedClonedVoiceChange(voiceId)
},
[onSelectedClonedVoiceChange],
)
const handleOpenCloneModal = useCallback(() => {
onCloneModalOpenChange(true)
}, [onCloneModalOpenChange])
return { return {
// 数据 // 数据
@@ -1,30 +0,0 @@
import { useQueryClient } from "@tanstack/react-query"
import { message } from "antd"
import { deleteProduct } from "@/api/products"
interface UseBatchDeleteOptions {
selectedIds: Set<string>
clearSelection: () => void
}
export function useBatchDelete({ selectedIds, clearSelection }: UseBatchDeleteOptions) {
const queryClient = useQueryClient()
const handleBatchDelete = async () => {
const ids = Array.from(selectedIds)
let successCount = 0
for (const id of ids) {
try {
await deleteProduct(id)
successCount++
} catch {
// 忽略单个失败
}
}
queryClient.invalidateQueries({ queryKey: ["products"] })
clearSelection()
message.success(`已批量删除 ${successCount}/${ids.length} 个视频`)
}
return { handleBatchDelete }
}
@@ -1,53 +0,0 @@
import { useState, useCallback } from "react"
import { message } from "antd"
import { batchDownload, getBatchDownloadStatus } from "@/api/products"
interface UseBatchDownloadOptions {
selectedIds: Set<string>
clearSelection: () => void
}
export function useBatchDownload({ selectedIds, clearSelection }: UseBatchDownloadOptions) {
const [batchDownloading, setBatchDownloading] = useState(false)
const handleBatchDownload = useCallback(async () => {
const ids = Array.from(selectedIds)
if (ids.length === 0) return
setBatchDownloading(true)
try {
const { job_id } = await batchDownload(ids)
message.info(`批量下载任务已创建,正在打包 ${ids.length} 个视频...`)
let attempts = 0
const maxAttempts = 60
const poll = async (): Promise<void> => {
if (attempts >= maxAttempts) {
message.warning("打包超时,请稍后在消息中心查看")
return
}
attempts++
await new Promise((r) => setTimeout(r, 2000))
const status = await getBatchDownloadStatus(job_id)
if (status.status === "completed" && status.download_url) {
const a = document.createElement("a")
a.href = status.download_url
a.download = ""
a.click()
message.success(`已打包下载 ${ids.length} 个视频`)
clearSelection()
} else if (status.status === "failed") {
message.error("批量下载失败,请重试")
} else {
await poll()
}
}
await poll()
} catch {
message.error("发起批量下载失败")
} finally {
setBatchDownloading(false)
}
}, [selectedIds, clearSelection])
return { batchDownloading, handleBatchDownload }
}
@@ -1,37 +0,0 @@
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { message } from "antd"
import { deleteProduct, updateReviewStatus, type ReviewStatus } from "@/api/products"
export function useProductMutations() {
const queryClient = useQueryClient()
const deleteMutation = useMutation({
mutationFn: deleteProduct,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["products"] })
message.success("已删除")
},
onError: () => {
message.error("删除失败")
},
})
const reviewMutation = useMutation({
mutationFn: ({ id, status }: { id: string; status: ReviewStatus }) =>
updateReviewStatus(id, status),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["products"] })
message.success("复核状态已更新")
},
onError: () => {
message.error("更新复核状态失败")
},
})
return {
deleteMutation,
reviewMutation,
isDeleting: deleteMutation.isPending,
isUpdatingReview: reviewMutation.isPending,
}
}
+104 -12
View File
@@ -1,11 +1,17 @@
import { useState } from "react"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { useNavigate } from "react-router-dom" import { useNavigate } from "react-router-dom"
import { message } from "antd" import { message } from "antd"
import { getProductDownloadUrl } from "@/api/products" import {
deleteProduct,
getProductDownloadUrl,
updateReviewStatus,
batchDownload,
getBatchDownloadStatus,
type ReviewStatus,
} from "@/api/products"
import type { ProductItem } from "../types" import type { ProductItem } from "../types"
import { getNextReviewStatus } from "../utils" import { getNextReviewStatus } from "../utils"
import { useBatchDownload } from "./product-actions/useBatchDownload"
import { useBatchDelete } from "./product-actions/useBatchDelete"
import { useProductMutations } from "./product-actions/useProductMutations"
interface UseProductActionsOptions { interface UseProductActionsOptions {
selectedIds: Set<string> selectedIds: Set<string>
@@ -20,17 +26,35 @@ export const useProductActions = ({
products, products,
setPlayingProduct, setPlayingProduct,
}: UseProductActionsOptions) => { }: UseProductActionsOptions) => {
const queryClient = useQueryClient()
const navigate = useNavigate() const navigate = useNavigate()
const { deleteMutation, reviewMutation, isDeleting, isUpdatingReview } = useProductMutations() /* ── 删除 mutation ── */
const deleteMutation = useMutation({
const { batchDownloading, handleBatchDownload } = useBatchDownload({ mutationFn: deleteProduct,
selectedIds, onSuccess: () => {
clearSelection, queryClient.invalidateQueries({ queryKey: ["products"] })
message.success("已删除")
},
onError: () => {
message.error("删除失败")
},
}) })
const { handleBatchDelete } = useBatchDelete({ selectedIds, clearSelection }) /* ── 复核状态 mutation ── */
const reviewMutation = useMutation({
mutationFn: ({ id, status }: { id: string; status: ReviewStatus }) =>
updateReviewStatus(id, status),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["products"] })
message.success("复核状态已更新")
},
onError: () => {
message.error("更新复核状态失败")
},
})
/* 下载 — 调用真实 API 获取下载链接 */
const handleDownload = async (product: ProductItem) => { const handleDownload = async (product: ProductItem) => {
if (product.status !== "completed") return if (product.status !== "completed") return
try { try {
@@ -45,6 +69,7 @@ export const useProductActions = ({
} }
} }
/* 分享 */
const handleShare = (product: ProductItem) => { const handleShare = (product: ProductItem) => {
if (product.status !== "completed") return if (product.status !== "completed") return
const link = `${window.location.origin}/share/${product.id}` const link = `${window.location.origin}/share/${product.id}`
@@ -54,42 +79,109 @@ export const useProductActions = ({
) )
} }
/* 查看详情 — 跳转到产品详情页 */
const handleViewDetail = (product: ProductItem) => { const handleViewDetail = (product: ProductItem) => {
setPlayingProduct(null) setPlayingProduct(null)
navigate(`/app/products/${product.id}`) navigate(`/app/products/${product.id}`)
} }
/* 删除 */
const handleDelete = (id: string) => { const handleDelete = (id: string) => {
deleteMutation.mutate(id) deleteMutation.mutate(id)
} }
/* 发布 — TODO: 后端发布 API 待实现 */
const handlePublish = (_product: ProductItem) => { const handlePublish = (_product: ProductItem) => {
message.info("发布功能待后端 API 补齐") message.info("发布功能待后端 API 补齐")
} }
/* 切换复核状态 */
const handleReviewStatusChange = (id: string) => { const handleReviewStatusChange = (id: string) => {
const current = products.find((p) => p.id === id)?.reviewStatus const current = products.find((p) => p.id === id)?.reviewStatus
const nextStatus = getNextReviewStatus(current) const nextStatus = getNextReviewStatus(current)
reviewMutation.mutate({ id, status: nextStatus }) reviewMutation.mutate({ id, status: nextStatus })
} }
/* 批量下载 */
const [batchDownloading, setBatchDownloading] = useState(false)
const handleBatchDownload = async () => {
const ids = Array.from(selectedIds)
if (ids.length === 0) return
setBatchDownloading(true)
try {
const { job_id } = await batchDownload(ids)
message.info(`批量下载任务已创建,正在打包 ${ids.length} 个视频...`)
let attempts = 0
const maxAttempts = 60
const poll = async (): Promise<void> => {
if (attempts >= maxAttempts) {
message.warning("打包超时,请稍后在消息中心查看")
return
}
attempts++
await new Promise((r) => setTimeout(r, 2000))
const status = await getBatchDownloadStatus(job_id)
if (status.status === "completed" && status.download_url) {
const a = document.createElement("a")
a.href = status.download_url
a.download = ""
a.click()
message.success(`已打包下载 ${ids.length} 个视频`)
clearSelection()
} else if (status.status === "failed") {
message.error("批量下载失败,请重试")
} else {
await poll()
}
}
await poll()
} catch {
message.error("发起批量下载失败")
} finally {
setBatchDownloading(false)
}
}
/* 批量删除 */
const handleBatchDelete = async () => {
const ids = Array.from(selectedIds)
let successCount = 0
for (const id of ids) {
try {
await deleteProduct(id)
successCount++
} catch {
// 忽略单个失败
}
}
queryClient.invalidateQueries({ queryKey: ["products"] })
clearSelection()
message.success(`已批量删除 ${successCount}/${ids.length} 个视频`)
}
/* 批量发布 */
const handleBatchPublish = () => { const handleBatchPublish = () => {
message.info("批量发布功能待后端 API 补齐") message.info("批量发布功能待后端 API 补齐")
clearSelection() clearSelection()
} }
return { return {
// 单个操作
handleDownload, handleDownload,
handleShare, handleShare,
handleViewDetail, handleViewDetail,
handleDelete, handleDelete,
handlePublish, handlePublish,
handleReviewStatusChange, handleReviewStatusChange,
// 批量操作
handleBatchDownload, handleBatchDownload,
handleBatchDelete, handleBatchDelete,
handleBatchPublish, handleBatchPublish,
batchDownloading, batchDownloading,
isDeleting, // mutation 状态
isUpdatingReview, isDeleting: deleteMutation.isPending,
isUpdatingReview: reviewMutation.isPending,
} }
} }
+123 -5
View File
@@ -1,8 +1,11 @@
import React from "react" import React from "react"
import { Table } from "antd" import { Table, Tag, Button, Popconfirm, Tooltip } from "antd"
import type { TaskItem } from "@/api/tasks" import { RedoOutlined, InfoCircleOutlined, ClockCircleOutlined } from "@ant-design/icons"
import type { ColumnsType } from "antd/es/table"
import type { TaskItem, TaskStatus } from "@/api/tasks"
import { STATUS_CONFIG, TYPE_LABELS } from "../constants"
import { formatDuration, formatTime } from "../utils"
import { TaskErrorDetail } from "./TaskErrorDetail" import { TaskErrorDetail } from "./TaskErrorDetail"
import { useTaskTableColumns, TaskEmptyState } from "./task-table"
interface TaskTableProps { interface TaskTableProps {
dataSource: TaskItem[] dataSource: TaskItem[]
@@ -21,6 +24,7 @@ interface TaskTableProps {
/** /**
* 任务列表表格 * 任务列表表格
* 含列定义、分页、展开行
*/ */
export const TaskTable: React.FC<TaskTableProps> = ({ export const TaskTable: React.FC<TaskTableProps> = ({
dataSource, dataSource,
@@ -36,7 +40,116 @@ export const TaskTable: React.FC<TaskTableProps> = ({
onRetry, onRetry,
onViewDetail, onViewDetail,
}) => { }) => {
const columns = useTaskTableColumns({ retryLoading, onRetry, onViewDetail }) // 表格列定义
const columns: ColumnsType<TaskItem> = [
{
title: "任务ID",
dataIndex: "id",
key: "id",
width: 120,
ellipsis: true,
render: (id: string) => (
<Tooltip title={id}>
<span className="task-id">{id.slice(0, 8)}...</span>
</Tooltip>
),
},
{
title: "类型",
dataIndex: "task_type",
key: "task_type",
width: 100,
render: (type: string) => {
const config = TYPE_LABELS[type] || { label: type, color: "default" }
return <Tag color={config.color}>{config.label}</Tag>
},
},
{
title: "状态",
dataIndex: "status",
key: "status",
width: 120,
render: (status: TaskStatus, record: TaskItem) => {
const config = STATUS_CONFIG[status] || {
label: status,
color: "default",
icon: null,
}
return (
<Tag color={config.color} icon={config.icon} className="task-status-tag">
{config.label}
{status === "running" && record.progress > 0 && (
<span className="task-progress"> {record.progress}%</span>
)}
</Tag>
)
},
},
{
title: "当前步骤",
dataIndex: "current_step",
key: "current_step",
width: 150,
ellipsis: true,
render: (step: string) => <span className="task-step">{step || "-"}</span>,
},
{
title: "耗时",
dataIndex: "duration_seconds",
key: "duration_seconds",
width: 100,
render: (seconds: number) => <span className="task-duration">{formatDuration(seconds)}</span>,
},
{
title: "创建时间",
dataIndex: "created_at",
key: "created_at",
width: 120,
render: (time: string) => <span className="task-time">{formatTime(time)}</span>,
},
{
title: "操作",
key: "action",
width: 100,
fixed: "right",
render: (_: unknown, record: TaskItem) => {
if (record.status === "failed" && record.retryable) {
return (
<Popconfirm
title="确认重试"
description="确定要重试这个失败的任务吗?"
onConfirm={() => onRetry(record.id)}
okText="确定"
cancelText="取消"
>
<Button
type="link"
size="small"
icon={<RedoOutlined />}
loading={retryLoading}
className="task-retry-btn"
>
</Button>
</Popconfirm>
)
}
if (record.status === "failed") {
return (
<Button
type="link"
size="small"
icon={<InfoCircleOutlined />}
onClick={() => onViewDetail(record)}
>
</Button>
)
}
return <span className="task-action-placeholder">-</span>
},
},
]
return ( return (
<Table <Table
@@ -65,7 +178,12 @@ export const TaskTable: React.FC<TaskTableProps> = ({
scroll={{ x: 800 }} scroll={{ x: 800 }}
className="task-table" className="task-table"
locale={{ locale={{
emptyText: <TaskEmptyState />, emptyText: (
<div className="task-empty">
<ClockCircleOutlined />
<p></p>
</div>
),
}} }}
/> />
) )
@@ -1,9 +0,0 @@
import React from "react"
import { ClockCircleOutlined } from "@ant-design/icons"
export const TaskEmptyState: React.FC = () => (
<div className="task-empty">
<ClockCircleOutlined />
<p></p>
</div>
)
@@ -1,2 +0,0 @@
export { useTaskTableColumns } from "./useTaskTableColumns"
export { TaskEmptyState } from "./TaskEmptyState"
@@ -1,128 +0,0 @@
import { Tag, Button, Popconfirm, Tooltip } from "antd"
import { RedoOutlined, InfoCircleOutlined } from "@ant-design/icons"
import type { ColumnsType } from "antd/es/table"
import type { TaskItem, TaskStatus } from "@/api/tasks"
import { STATUS_CONFIG, TYPE_LABELS } from "../../constants"
import { formatDuration, formatTime } from "../../utils"
interface UseTaskTableColumnsOptions {
retryLoading: boolean
onRetry: (id: string) => void
onViewDetail: (record: TaskItem) => void
}
export function useTaskTableColumns({
retryLoading,
onRetry,
onViewDetail,
}: UseTaskTableColumnsOptions): ColumnsType<TaskItem> {
return [
{
title: "任务ID",
dataIndex: "id",
key: "id",
width: 120,
ellipsis: true,
render: (id: string) => (
<Tooltip title={id}>
<span className="task-id">{id.slice(0, 8)}...</span>
</Tooltip>
),
},
{
title: "类型",
dataIndex: "task_type",
key: "task_type",
width: 100,
render: (type: string) => {
const config = TYPE_LABELS[type] || { label: type, color: "default" }
return <Tag color={config.color}>{config.label}</Tag>
},
},
{
title: "状态",
dataIndex: "status",
key: "status",
width: 120,
render: (status: TaskStatus, record: TaskItem) => {
const config = STATUS_CONFIG[status] || {
label: status,
color: "default",
icon: null,
}
return (
<Tag color={config.color} icon={config.icon} className="task-status-tag">
{config.label}
{status === "running" && record.progress > 0 && (
<span className="task-progress"> {record.progress}%</span>
)}
</Tag>
)
},
},
{
title: "当前步骤",
dataIndex: "current_step",
key: "current_step",
width: 150,
ellipsis: true,
render: (step: string) => <span className="task-step">{step || "-"}</span>,
},
{
title: "耗时",
dataIndex: "duration_seconds",
key: "duration_seconds",
width: 100,
render: (seconds: number) => <span className="task-duration">{formatDuration(seconds)}</span>,
},
{
title: "创建时间",
dataIndex: "created_at",
key: "created_at",
width: 120,
render: (time: string) => <span className="task-time">{formatTime(time)}</span>,
},
{
title: "操作",
key: "action",
width: 100,
fixed: "right",
render: (_: unknown, record: TaskItem) => {
if (record.status === "failed" && record.retryable) {
return (
<Popconfirm
title="确认重试"
description="确定要重试这个失败的任务吗?"
onConfirm={() => onRetry(record.id)}
okText="确定"
cancelText="取消"
>
<Button
type="link"
size="small"
icon={<RedoOutlined />}
loading={retryLoading}
className="task-retry-btn"
>
</Button>
</Popconfirm>
)
}
if (record.status === "failed") {
return (
<Button
type="link"
size="small"
icon={<InfoCircleOutlined />}
onClick={() => onViewDetail(record)}
>
</Button>
)
}
return <span className="task-action-placeholder">-</span>
},
},
]
}
+136 -20
View File
@@ -1,13 +1,25 @@
import React from "react" import React from "react"
import { Descriptions } from "antd" import { Button, Descriptions, Tooltip } from "antd"
import type { TemplateDetailModalProps } from "./template-detail-modal/types" import { CopyOutlined, ThunderboltOutlined } from "@ant-design/icons"
import PreviewArea from "./template-detail-modal/PreviewArea" import type { TemplateItem, TemplateSegment } from "@/api/templates"
import SegmentList from "./template-detail-modal/SegmentList" import {
import StyleConfig from "./template-detail-modal/StyleConfig" gradientForCategory,
import DetailFooter from "./template-detail-modal/DetailFooter" getTypeColor,
import { getTypeColor, formatDuration } from "../../utils/templateLibrary" formatDuration,
formatConfig,
getMaterialTypeLabel,
calcTotalSegmentDuration,
} from "../../utils/templateLibrary"
import { TEMPLATE_TYPES } from "../../constants/templateLibrary" import { TEMPLATE_TYPES } from "../../constants/templateLibrary"
import { calcTotalSegmentDuration } from "../../utils/templateLibrary"
interface TemplateDetailModalProps {
template: TemplateItem
isFavorite: boolean
onClose: () => void
onToggleFavorite: (id: string) => void
onUse: (template: TemplateItem) => void
onCopy: (template: TemplateItem) => void
}
export const TemplateDetailModal: React.FC<TemplateDetailModalProps> = ({ export const TemplateDetailModal: React.FC<TemplateDetailModalProps> = ({
template, template,
@@ -19,7 +31,6 @@ export const TemplateDetailModal: React.FC<TemplateDetailModalProps> = ({
}) => { }) => {
const segments = template.segments ?? [] const segments = template.segments ?? []
const totalSegmentDuration = calcTotalSegmentDuration(segments) const totalSegmentDuration = calcTotalSegmentDuration(segments)
const typeInfo = TEMPLATE_TYPES.find((t) => t.type === template.category)
return ( return (
<div className="xx-template-modal-overlay" onClick={onClose}> <div className="xx-template-modal-overlay" onClick={onClose}>
@@ -27,8 +38,33 @@ export const TemplateDetailModal: React.FC<TemplateDetailModalProps> = ({
className="xx-template-modal xx-template-modal-wide" className="xx-template-modal xx-template-modal-wide"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<PreviewArea template={template} onClose={onClose} /> {/* 关闭按钮 */}
<button className="xx-template-modal-close" onClick={onClose} title="关闭">
</button>
{/* 预览区域 */}
<div
className="xx-template-modal-preview"
style={{ background: gradientForCategory(template.category) }}
>
{template.thumbnail_url ? (
<img
src={template.thumbnail_url}
alt={template.name}
className="xx-template-modal-thumb-img"
/>
) : (
<div className="xx-template-modal-preview-content">
<span className="xx-template-preview-icon">
{TEMPLATE_TYPES.find((t) => t.type === template.category)?.icon ?? "📋"}
</span>
<span className="xx-template-preview-title">{template.name}</span>
</div>
)}
</div>
{/* 内容区域 */}
<div className="xx-template-modal-content"> <div className="xx-template-modal-content">
{/* 标题行 */} {/* 标题行 */}
<div className="xx-template-modal-title-row"> <div className="xx-template-modal-title-row">
@@ -40,7 +76,7 @@ export const TemplateDetailModal: React.FC<TemplateDetailModalProps> = ({
background: `${getTypeColor(template.category)}18`, background: `${getTypeColor(template.category)}18`,
}} }}
> >
{typeInfo?.icon} {template.category} {TEMPLATE_TYPES.find((t) => t.type === template.category)?.icon} {template.category}
</span> </span>
</div> </div>
@@ -87,15 +123,95 @@ export const TemplateDetailModal: React.FC<TemplateDetailModalProps> = ({
]} ]}
/> />
<SegmentList segments={segments} totalDuration={totalSegmentDuration} /> {/* 素材规则(片段配置) */}
<StyleConfig template={template} /> {segments.length > 0 && (
<DetailFooter <div className="xx-template-modal-section">
template={template} <h4>🎬 </h4>
isFavorite={isFavorite} <div className="xx-template-modal-clip-list">
onToggleFavorite={onToggleFavorite} {segments
onUse={onUse} .sort((a, b) => a.segment_order - b.segment_order)
onCopy={onCopy} .map((seg: TemplateSegment, idx: number) => (
/> <div key={seg.id ?? idx} className="xx-template-modal-clip-item">
<span className="xx-template-modal-clip-order">#{seg.segment_order}</span>
<span
className="xx-template-modal-clip-badge"
style={{
color: seg.material_type ? getTypeColor(seg.material_type) : "#64748b",
background: seg.material_type
? `${getTypeColor(seg.material_type)}18`
: "#f1f5f9",
}}
>
{getMaterialTypeLabel(seg.material_type)}
</span>
<span className="xx-template-modal-clip-desc">
{seg.description || `片段 ${seg.segment_order}`}
</span>
<Tooltip title={`时长范围: ${seg.duration_min}秒 - ${seg.duration_max}`}>
<span className="xx-template-modal-clip-duration">
{seg.duration_min}-{seg.duration_max}
</span>
</Tooltip>
</div>
))}
</div>
<div className="xx-template-modal-total-duration">
{formatDuration(Math.round(totalSegmentDuration))}
</div>
</div>
)}
{/* 样式配置 */}
<div className="xx-template-modal-section">
<h4>🎨 </h4>
<div className="xx-template-modal-style-grid">
<div className="xx-template-modal-style-item">
<span className="xx-template-modal-style-label"></span>
<span className="xx-template-modal-style-value">
{formatConfig(template.subtitle_config)}
</span>
</div>
<div className="xx-template-modal-style-item">
<span className="xx-template-modal-style-label"></span>
<span className="xx-template-modal-style-value">
{formatConfig(template.title_config)}
</span>
</div>
<div className="xx-template-modal-style-item">
<span className="xx-template-modal-style-label">BGM </span>
<span className="xx-template-modal-style-value">
{formatConfig(template.bgm_config)}
</span>
</div>
<div className="xx-template-modal-style-item">
<span className="xx-template-modal-style-label"></span>
<span className="xx-template-modal-style-value">
{template.aspect_ratio ?? "16:9"}
</span>
</div>
</div>
</div>
{/* 统计信息 */}
<div className="xx-template-modal-stats">
<span>使 {template.usage_count ?? 0} </span>
<button
className={`xx-template-modal-fav-btn${isFavorite ? " is-favorite" : ""}`}
onClick={() => onToggleFavorite(template.id)}
>
{isFavorite ? "★ 已收藏" : "☆ 收藏"}
</button>
</div>
{/* 操作按钮 */}
<div className="xx-template-modal-actions">
<Button icon={<CopyOutlined />} onClick={() => onCopy(template)}>
</Button>
<Button type="primary" icon={<ThunderboltOutlined />} onClick={() => onUse(template)}>
使
</Button>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -1,46 +0,0 @@
import React from "react"
import { Button } from "antd"
import { CopyOutlined, ThunderboltOutlined } from "@ant-design/icons"
import type { TemplateItem } from "@/api/templates"
interface DetailFooterProps {
template: TemplateItem
isFavorite: boolean
onToggleFavorite: (id: string) => void
onUse: (template: TemplateItem) => void
onCopy: (template: TemplateItem) => void
}
/** 底部操作区:统计 + 收藏 + 按钮 */
const DetailFooter: React.FC<DetailFooterProps> = ({
template,
isFavorite,
onToggleFavorite,
onUse,
onCopy,
}) => {
return (
<>
<div className="xx-template-modal-stats">
<span>使 {template.usage_count ?? 0} </span>
<button
className={`xx-template-modal-fav-btn${isFavorite ? " is-favorite" : ""}`}
onClick={() => onToggleFavorite(template.id)}
>
{isFavorite ? "★ 已收藏" : "☆ 收藏"}
</button>
</div>
<div className="xx-template-modal-actions">
<Button icon={<CopyOutlined />} onClick={() => onCopy(template)}>
</Button>
<Button type="primary" icon={<ThunderboltOutlined />} onClick={() => onUse(template)}>
使
</Button>
</div>
</>
)
}
export default DetailFooter
@@ -1,39 +0,0 @@
import React from "react"
import type { TemplateItem } from "@/api/templates"
import { gradientForCategory } from "../../../utils/templateLibrary"
import { TEMPLATE_TYPES } from "../../../constants/templateLibrary"
interface PreviewAreaProps {
template: TemplateItem
onClose: () => void
}
/** 预览区域 */
const PreviewArea: React.FC<PreviewAreaProps> = ({ template, onClose }) => {
const typeInfo = TEMPLATE_TYPES.find((t) => t.type === template.category)
return (
<div
className="xx-template-modal-preview"
style={{ background: gradientForCategory(template.category) }}
>
<button className="xx-template-modal-close" onClick={onClose} title="关闭">
</button>
{template.thumbnail_url ? (
<img
src={template.thumbnail_url}
alt={template.name}
className="xx-template-modal-thumb-img"
/>
) : (
<div className="xx-template-modal-preview-content">
<span className="xx-template-preview-icon">{typeInfo?.icon ?? "📋"}</span>
<span className="xx-template-preview-title">{template.name}</span>
</div>
)}
</div>
)
}
export default PreviewArea
@@ -1,53 +0,0 @@
import React from "react"
import { Tooltip } from "antd"
import type { TemplateSegment } from "@/api/templates"
import { getMaterialTypeLabel, getTypeColor, formatDuration } from "../../../utils/templateLibrary"
interface SegmentListProps {
segments: TemplateSegment[]
totalDuration: number
}
/** 素材规则 / 片段列表 */
const SegmentList: React.FC<SegmentListProps> = ({ segments, totalDuration }) => {
if (segments.length === 0) return null
return (
<div className="xx-template-modal-section">
<h4>🎬 </h4>
<div className="xx-template-modal-clip-list">
{segments
.sort((a, b) => a.segment_order - b.segment_order)
.map((seg, idx) => (
<div key={seg.id ?? idx} className="xx-template-modal-clip-item">
<span className="xx-template-modal-clip-order">#{seg.segment_order}</span>
<span
className="xx-template-modal-clip-badge"
style={{
color: seg.material_type ? getTypeColor(seg.material_type) : "#64748b",
background: seg.material_type
? `${getTypeColor(seg.material_type)}18`
: "#f1f5f9",
}}
>
{getMaterialTypeLabel(seg.material_type)}
</span>
<span className="xx-template-modal-clip-desc">
{seg.description || `片段 ${seg.segment_order}`}
</span>
<Tooltip title={`时长范围: ${seg.duration_min}秒 - ${seg.duration_max}`}>
<span className="xx-template-modal-clip-duration">
{seg.duration_min}-{seg.duration_max}
</span>
</Tooltip>
</div>
))}
</div>
<div className="xx-template-modal-total-duration">
{formatDuration(Math.round(totalDuration))}
</div>
</div>
)
}
export default SegmentList
@@ -1,33 +0,0 @@
import React from "react"
import type { TemplateItem } from "@/api/templates"
import { formatConfig } from "../../../utils/templateLibrary"
interface StyleConfigProps {
template: TemplateItem
}
/** 样式配置网格 */
const StyleConfig: React.FC<StyleConfigProps> = ({ template }) => {
const items = [
{ label: "字幕样式", value: formatConfig(template.subtitle_config) },
{ label: "标题样式", value: formatConfig(template.title_config) },
{ label: "BGM 配置", value: formatConfig(template.bgm_config) },
{ label: "视频比例", value: template.aspect_ratio ?? "16:9" },
]
return (
<div className="xx-template-modal-section">
<h4>🎨 </h4>
<div className="xx-template-modal-style-grid">
{items.map((item) => (
<div key={item.label} className="xx-template-modal-style-item">
<span className="xx-template-modal-style-label">{item.label}</span>
<span className="xx-template-modal-style-value">{item.value}</span>
</div>
))}
</div>
</div>
)
}
export default StyleConfig
@@ -1,6 +0,0 @@
export { TemplateDetailModal } from "../TemplateDetailModal"
export * from "./types"
export { default as PreviewArea } from "./PreviewArea"
export { default as SegmentList } from "./SegmentList"
export { default as StyleConfig } from "./StyleConfig"
export { default as DetailFooter } from "./DetailFooter"
@@ -1,10 +0,0 @@
import type { TemplateItem } from "@/api/templates"
export interface TemplateDetailModalProps {
template: TemplateItem
isFavorite: boolean
onClose: () => void
onToggleFavorite: (id: string) => void
onUse: (template: TemplateItem) => void
onCopy: (template: TemplateItem) => void
}
+81 -5
View File
@@ -1,10 +1,11 @@
import React, { useState } from "react" import React, { useState, useRef } from "react"
import { UploadOutlined, SoundOutlined, CloseOutlined } from "@ant-design/icons"
import { Button, Input } from "@/components/ui" import { Button, Input } from "@/components/ui"
import { type TagItem } from "@/api/tags" import { type TagItem } from "@/api/tags"
import { type VoiceGender, type VoiceMaterial } from "../types" import { type VoiceGender, type VoiceMaterial } from "../types"
import { GENDER_OPTIONS } from "../constants"
import { genderClass, formatFileSize } from "../utils/format"
import TagSelector from "./TagSelector" import TagSelector from "./TagSelector"
import FileUploadField from "./material-form/FileUploadField"
import GenderSelector from "./material-form/GenderSelector"
export interface MaterialFormProps { export interface MaterialFormProps {
initial?: VoiceMaterial initial?: VoiceMaterial
@@ -32,6 +33,7 @@ const MaterialForm: React.FC<MaterialFormProps> = ({
const [gender, setGender] = useState<VoiceGender>(initial?.gender ?? "female") const [gender, setGender] = useState<VoiceGender>(initial?.gender ?? "female")
const [selectedTagIds, setSelectedTagIds] = useState<string[]>(initial?.tagIds ?? []) const [selectedTagIds, setSelectedTagIds] = useState<string[]>(initial?.tagIds ?? [])
const [file, setFile] = useState<File | undefined>(undefined) const [file, setFile] = useState<File | undefined>(undefined)
const fileInputRef = useRef<HTMLInputElement>(null)
const handleSubmit = () => { const handleSubmit = () => {
if (!name.trim()) return if (!name.trim()) return
@@ -51,10 +53,65 @@ const MaterialForm: React.FC<MaterialFormProps> = ({
return ( return (
<div className="vmat-form"> <div className="vmat-form">
{/* 音频文件上传(编辑模式不显示) */}
{!initial && ( {!initial && (
<FileUploadField file={file} onChange={setFile} uploadProgress={uploadProgress} /> <div className="vmat-form-field">
<label className="vmat-form-label"> *</label>
<div
className="vmat-upload-zone"
onClick={() => fileInputRef.current?.click()}
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
e.preventDefault()
const f = e.dataTransfer.files[0]
if (f?.type.startsWith("audio/")) setFile(f)
}}
>
<input
ref={fileInputRef}
type="file"
accept="audio/*"
style={{ display: "none" }}
onChange={(e) => {
const f = e.target.files?.[0]
if (f) setFile(f)
}}
/>
{file ? (
<div className="vmat-upload-selected">
<SoundOutlined className="vmat-upload-icon" />
<span className="vmat-upload-filename">{file.name}</span>
<span className="vmat-upload-filesize">{formatFileSize(file.size)}</span>
<button
type="button"
className="vmat-upload-clear"
onClick={(e) => {
e.stopPropagation()
setFile(undefined)
}}
>
<CloseOutlined />
</button>
</div>
) : (
<div className="vmat-upload-placeholder">
<UploadOutlined className="vmat-upload-icon" />
<p></p>
<span> MP3WAVAACFLAC </span>
</div>
)}
</div>
{/* 上传进度条 */}
{uploadProgress !== null && uploadProgress !== undefined && (
<div className="vmat-upload-progress">
<div className="vmat-upload-progress-bar" style={{ width: `${uploadProgress}%` }} />
<span className="vmat-upload-progress-text">{uploadProgress}%</span>
</div>
)}
</div>
)} )}
{/* 名称 */}
<div className="vmat-form-field"> <div className="vmat-form-field">
<label className="vmat-form-label"> *</label> <label className="vmat-form-label"> *</label>
<Input <Input
@@ -65,6 +122,7 @@ const MaterialForm: React.FC<MaterialFormProps> = ({
/> />
</div> </div>
{/* 音色描述 */}
<div className="vmat-form-field"> <div className="vmat-form-field">
<label className="vmat-form-label"></label> <label className="vmat-form-label"></label>
<Input.TextArea <Input.TextArea
@@ -76,8 +134,25 @@ const MaterialForm: React.FC<MaterialFormProps> = ({
/> />
</div> </div>
<GenderSelector value={gender} onChange={setGender} /> {/* 性别 */}
<div className="vmat-form-field">
<label className="vmat-form-label"></label>
<div className="vmat-gender-group">
{GENDER_OPTIONS.map((opt) => (
<button
key={opt.value}
type="button"
className={`vmat-gender-btn${gender === opt.value ? " active" : ""} ${genderClass(opt.value)}`}
onClick={() => setGender(opt.value)}
>
{opt.icon}
{opt.label}
</button>
))}
</div>
</div>
{/* 风格标签 */}
<div className="vmat-form-field"> <div className="vmat-form-field">
<label className="vmat-form-label"></label> <label className="vmat-form-label"></label>
<TagSelector <TagSelector
@@ -89,6 +164,7 @@ const MaterialForm: React.FC<MaterialFormProps> = ({
/> />
</div> </div>
{/* 操作按钮 */}
<div className="vmat-form-actions"> <div className="vmat-form-actions">
<Button buttonType="ghost" buttonSize="md" onClick={onCancel}> <Button buttonType="ghost" buttonSize="md" onClick={onCancel}>
+72 -16
View File
@@ -1,8 +1,7 @@
import React from "react" import React, { useState, useRef, useCallback, useMemo } from "react"
import { CheckOutlined } from "@ant-design/icons" import { CheckOutlined } from "@ant-design/icons"
import { Tag } from "@/components/ui" import { Tag } from "@/components/ui"
import type { TagItem } from "@/api/tags" import { type TagItem } from "@/api/tags"
import { useTagInput } from "./tag-selector/useTagInput"
export interface TagSelectorProps { export interface TagSelectorProps {
/** 已选标签 ID 列表 */ /** 已选标签 ID 列表 */
@@ -25,22 +24,77 @@ const TagSelector: React.FC<TagSelectorProps> = ({
onCreateTag, onCreateTag,
placeholder = "输入标签后回车添加", placeholder = "输入标签后回车添加",
}) => { }) => {
const { const [inputVal, setInputVal] = useState("")
inputVal, const [showSuggestions, setShowSuggestions] = useState(false)
setInputVal, const inputRef = useRef<HTMLInputElement>(null)
showSuggestions,
setShowSuggestions, /** 按名称查找已有标签(大小写不敏感) */
inputRef, const findTagByName = useCallback(
suggestions, (name: string) => tags.find((t) => t.name.toLowerCase() === name.toLowerCase()),
addTagId, [tags],
removeTagId, )
handleKeyDown,
focus, /** 去重添加标签(按 ID */
} = useTagInput({ value, onChange, tags, onCreateTag }) const addTagId = useCallback(
(tagId: string) => {
if (value.includes(tagId)) return
onChange([...value, tagId])
setInputVal("")
setShowSuggestions(false)
},
[value, onChange],
)
/** 输入自定义标签名:若已存在则直接选,否则创建新标签 */
const addTagByName = useCallback(
async (name: string) => {
const trimmed = name.trim()
if (!trimmed) return
const existing = findTagByName(trimmed)
if (existing) {
addTagId(existing.id)
} else {
try {
const created = await onCreateTag(trimmed)
addTagId(created.id)
} catch {
/* 创建失败静默忽略 */
}
}
},
[findTagByName, addTagId, onCreateTag],
)
const removeTagId = useCallback(
(tagId: string) => {
onChange(value.filter((t) => t !== tagId))
},
[value, onChange],
)
/** 输入补全建议(排除已选) */
const suggestions = useMemo(() => {
if (!inputVal.trim()) return []
const lower = inputVal.toLowerCase()
return tags.filter((t) => t.name.toLowerCase().includes(lower) && !value.includes(t.id))
}, [inputVal, tags, value])
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault()
if (suggestions.length > 0) {
addTagId(suggestions[0].id)
} else {
addTagByName(inputVal)
}
} else if (e.key === "Backspace" && !inputVal && value.length > 0) {
removeTagId(value[value.length - 1])
}
}
return ( return (
<div className="vmat-tag-selector-wrapper"> <div className="vmat-tag-selector-wrapper">
<div className="vmat-tag-selector" onClick={focus}> <div className="vmat-tag-selector" onClick={() => inputRef.current?.focus()}>
{value.map((tagId) => ( {value.map((tagId) => (
<Tag key={tagId} variant="info" closable onClose={() => removeTagId(tagId)}> <Tag key={tagId} variant="info" closable onClose={() => removeTagId(tagId)}>
{tagMap.get(tagId)?.name ?? tagId} {tagMap.get(tagId)?.name ?? tagId}
@@ -61,6 +115,7 @@ const TagSelector: React.FC<TagSelectorProps> = ({
/> />
</div> </div>
{/* 自动补全下拉 */}
{showSuggestions && suggestions.length > 0 && ( {showSuggestions && suggestions.length > 0 && (
<div className="vmat-tag-suggestions"> <div className="vmat-tag-suggestions">
{suggestions.slice(0, 6).map((tag) => ( {suggestions.slice(0, 6).map((tag) => (
@@ -79,6 +134,7 @@ const TagSelector: React.FC<TagSelectorProps> = ({
</div> </div>
)} )}
{/* 已有标签快捷选择 */}
{tags.length > 0 && ( {tags.length > 0 && (
<div className="vmat-tag-selector-presets"> <div className="vmat-tag-selector-presets">
{tags.map((tag) => { {tags.map((tag) => {
+208 -35
View File
@@ -1,12 +1,45 @@
import React from "react" import React, { useRef } from "react"
import { type VoiceCardProps } from "./voice-material-card/types" import {
import BatchCheckbox from "./voice-material-card/BatchCheckbox" AudioOutlined,
import CardActions from "./voice-material-card/CardActions" PlayCircleOutlined,
import CardHeader from "./voice-material-card/CardHeader" PauseCircleOutlined,
import CardTags from "./voice-material-card/CardTags" EditOutlined,
import CardMeta from "./voice-material-card/CardMeta" DeleteOutlined,
import CardPlayer from "./voice-material-card/CardPlayer" CheckOutlined,
import { genderClass } from "../utils/format" SoundOutlined,
MutedOutlined,
} from "@ant-design/icons"
import { Tooltip } from "antd"
import { Tag } from "@/components/ui"
import { type TagItem } from "@/api/tags"
import { type VoiceMaterial } from "../types"
import { MAX_CARD_TAGS, TAG_VARIANTS } from "../constants"
import {
genderClass,
genderIcon,
genderLabel,
formatDuration,
formatFileSize,
formatDate,
} from "../utils/format"
export interface VoiceCardProps {
material: VoiceMaterial
isPlaying: boolean
currentTime: number
isSelected: boolean
batchMode: boolean
volume: number
tagMap: Map<string, TagItem>
onPlay: () => void
onPause: () => void
onSeek: (time: number) => void
onEdit: () => void
onDelete: () => void
onToggleSelect: (id: string) => void
onVolumeChange: (e: React.ChangeEvent<HTMLInputElement>) => void
onToggleMute: () => void
}
const VoiceMaterialCard: React.FC<VoiceCardProps> = ({ const VoiceMaterialCard: React.FC<VoiceCardProps> = ({
material, material,
@@ -25,6 +58,29 @@ const VoiceMaterialCard: React.FC<VoiceCardProps> = ({
onVolumeChange, onVolumeChange,
onToggleMute, onToggleMute,
}) => { }) => {
const progressRef = useRef<HTMLDivElement>(null)
const handleProgressMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
if (!progressRef.current) return
e.preventDefault()
const doSeek = (ev: MouseEvent) => {
if (!progressRef.current) return
const rect = progressRef.current.getBoundingClientRect()
const percent = Math.max(0, Math.min(1, (ev.clientX - rect.left) / rect.width))
onSeek(percent * material.duration)
}
doSeek(e.nativeEvent)
const handleMove = (ev: MouseEvent) => doSeek(ev)
const handleUp = () => {
document.removeEventListener("mousemove", handleMove)
document.removeEventListener("mouseup", handleUp)
}
document.addEventListener("mousemove", handleMove)
document.addEventListener("mouseup", handleUp)
}
const progress = material.duration > 0 ? (currentTime / material.duration) * 100 : 0
const handleCardClick = () => { const handleCardClick = () => {
if (batchMode) { if (batchMode) {
onToggleSelect(material.id) onToggleSelect(material.id)
@@ -36,37 +92,154 @@ const VoiceMaterialCard: React.FC<VoiceCardProps> = ({
className={`vmat-card ${genderClass(material.gender)}${isPlaying ? " playing" : ""}${isSelected ? " selected" : ""}${batchMode ? " batch-mode" : ""}`} className={`vmat-card ${genderClass(material.gender)}${isPlaying ? " playing" : ""}${isSelected ? " selected" : ""}${batchMode ? " batch-mode" : ""}`}
onClick={handleCardClick} onClick={handleCardClick}
> >
<BatchCheckbox {/* 批量选择 checkbox */}
isSelected={isSelected} {(batchMode || isSelected) && (
visible={batchMode || isSelected} <div
onToggle={() => onToggleSelect(material.id)} className={`vmat-card-checkbox vmat-checkbox${isSelected ? " checked" : ""}`}
/> onClick={(e) => {
<CardActions onEdit={onEdit} onDelete={onDelete} /> e.stopPropagation()
<CardHeader material={material} /> onToggleSelect(material.id)
}}
>
{isSelected && <CheckOutlined />}
</div>
)}
{/* 操作按钮 */}
<div className="vmat-card-actions">
<button
type="button"
className="vmat-card-action-btn"
onClick={(e) => {
e.stopPropagation()
onEdit()
}}
title="编辑"
>
<EditOutlined />
</button>
<button
type="button"
className="vmat-card-action-btn vmat-card-action-btn--danger"
onClick={(e) => {
e.stopPropagation()
onDelete()
}}
title="删除"
>
<DeleteOutlined />
</button>
</div>
{/* 头部:图标 + 名称 + 性别 */}
<div className="vmat-card-header">
<div className="vmat-card-avatar">
<AudioOutlined />
</div>
<div className="vmat-card-title-area">
<h4 className="vmat-card-name" title={material.name}>
{material.name}
</h4>
<span className={`vmat-card-gender ${genderClass(material.gender)}`}>
{genderIcon(material.gender)}
{genderLabel(material.gender)}
</span>
</div>
</div>
{/* 描述 */}
{material.description && <p className="vmat-card-desc">{material.description}</p>} {material.description && <p className="vmat-card-desc">{material.description}</p>}
<CardTags tagIds={material.tagIds} tagMap={tagMap} onEdit={onEdit} /> {/* 标签 */}
<CardMeta <div className="vmat-card-tags">
duration={material.duration} {material.tagIds.length === 0 ? (
fileSize={material.fileSize} <span
createdAt={material.createdAt} className="vmat-tag-empty"
/> onClick={(e) => {
<CardPlayer e.stopPropagation()
isPlaying={isPlaying} onEdit()
currentTime={currentTime} }}
duration={material.duration} >
volume={volume}
fileUrl={material.fileUrl} </span>
onPlay={onPlay} ) : (
onPause={onPause} <>
onSeek={onSeek} {material.tagIds.slice(0, MAX_CARD_TAGS).map((tagId, i) => (
onVolumeChange={onVolumeChange} <Tag key={tagId} variant={TAG_VARIANTS[i % TAG_VARIANTS.length]}>
onToggleMute={onToggleMute} {tagMap.get(tagId)?.name ?? tagId}
/> </Tag>
))}
{material.tagIds.length > MAX_CARD_TAGS && (
<Tooltip
title={material.tagIds
.slice(MAX_CARD_TAGS)
.map((id) => tagMap.get(id)?.name ?? id)
.join("、")}
>
<Tag className="vmat-tag-overflow">+{material.tagIds.length - MAX_CARD_TAGS}</Tag>
</Tooltip>
)}
</>
)}
</div>
{/* 元信息 */}
<div className="vmat-card-meta">
<span>{formatDuration(material.duration)}</span>
<span>{formatFileSize(material.fileSize)}</span>
<span>{formatDate(material.createdAt)}</span>
</div>
{/* 播放控制 */}
<div className="vmat-card-player">
<button
type="button"
className="vmat-play-btn"
onClick={(e) => {
e.stopPropagation()
isPlaying ? onPause() : onPlay()
}}
disabled={!material.fileUrl}
>
{isPlaying ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
</button>
<div ref={progressRef} className="vmat-progress" onMouseDown={handleProgressMouseDown}>
<div className="vmat-progress-bar" style={{ width: `${progress}%` }} />
{isPlaying && <div className="vmat-progress-thumb" style={{ left: `${progress}%` }} />}
</div>
<span className="vmat-time">
{isPlaying ? formatDuration(currentTime) : formatDuration(material.duration)}
</span>
{/* 音量控制 */}
<div className="vmat-volume">
<button
type="button"
className="vmat-volume-btn"
onClick={(e) => {
e.stopPropagation()
onToggleMute()
}}
title={volume === 0 ? "取消静音" : "静音"}
>
{volume === 0 ? <MutedOutlined /> : <SoundOutlined />}
</button>
<input
type="range"
className="vmat-volume-slider"
min={0}
max={1}
step={0.05}
value={volume}
onChange={(e) => {
e.stopPropagation()
onVolumeChange(e)
}}
onClick={(e) => e.stopPropagation()}
/>
</div>
</div>
</div> </div>
) )
} }
export default VoiceMaterialCard export default VoiceMaterialCard
export type { VoiceCardProps }
+57 -9
View File
@@ -1,4 +1,4 @@
import React from "react" import React, { useRef } from "react"
import { import {
PlayCircleOutlined, PlayCircleOutlined,
PauseCircleOutlined, PauseCircleOutlined,
@@ -6,8 +6,11 @@ import {
DeleteOutlined, DeleteOutlined,
CheckOutlined, CheckOutlined,
} from "@ant-design/icons" } from "@ant-design/icons"
import { Tooltip } from "antd"
import { Tag } from "@/components/ui"
import { type TagItem } from "@/api/tags" import { type TagItem } from "@/api/tags"
import { type VoiceMaterial } from "../types" import { type VoiceMaterial } from "../types"
import { MAX_ROW_TAGS, TAG_VARIANTS } from "../constants"
import { import {
genderClass, genderClass,
genderIcon, genderIcon,
@@ -15,8 +18,6 @@ import {
formatDuration, formatDuration,
formatFileSize, formatFileSize,
} from "../utils/format" } from "../utils/format"
import { useRowProgress } from "./voice-material-row/useRowProgress"
import TagDisplay from "./voice-material-row/TagDisplay"
export interface VoiceRowProps { export interface VoiceRowProps {
material: VoiceMaterial material: VoiceMaterial
@@ -47,10 +48,26 @@ const VoiceMaterialRow: React.FC<VoiceRowProps> = ({
onDelete, onDelete,
onToggleSelect, onToggleSelect,
}) => { }) => {
const { progressRef, handleMouseDown } = useRowProgress({ const progressRef = useRef<HTMLDivElement>(null)
duration: material.duration,
onSeek, const handleProgressMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
}) if (!progressRef.current) return
e.preventDefault()
const doSeek = (ev: MouseEvent) => {
if (!progressRef.current) return
const rect = progressRef.current.getBoundingClientRect()
const percent = Math.max(0, Math.min(1, (ev.clientX - rect.left) / rect.width))
onSeek(percent * material.duration)
}
doSeek(e.nativeEvent)
const handleMove = (ev: MouseEvent) => doSeek(ev)
const handleUp = () => {
document.removeEventListener("mousemove", handleMove)
document.removeEventListener("mouseup", handleUp)
}
document.addEventListener("mousemove", handleMove)
document.addEventListener("mouseup", handleUp)
}
const progress = material.duration > 0 ? (currentTime / material.duration) * 100 : 0 const progress = material.duration > 0 ? (currentTime / material.duration) * 100 : 0
@@ -58,6 +75,7 @@ const VoiceMaterialRow: React.FC<VoiceRowProps> = ({
<div <div
className={`vmat-row ${genderClass(material.gender)}${isPlaying ? " playing" : ""}${isSelected ? " selected" : ""}${batchMode ? " batch-mode" : ""}`} className={`vmat-row ${genderClass(material.gender)}${isPlaying ? " playing" : ""}${isSelected ? " selected" : ""}${batchMode ? " batch-mode" : ""}`}
> >
{/* 批量选择 checkbox */}
{(batchMode || isSelected) && ( {(batchMode || isSelected) && (
<div <div
className={`vmat-row-checkbox vmat-checkbox${isSelected ? " checked" : ""}`} className={`vmat-row-checkbox vmat-checkbox${isSelected ? " checked" : ""}`}
@@ -70,6 +88,7 @@ const VoiceMaterialRow: React.FC<VoiceRowProps> = ({
</div> </div>
)} )}
{/* 播放按钮 */}
<button <button
type="button" type="button"
className="vmat-row-play" className="vmat-row-play"
@@ -82,31 +101,60 @@ const VoiceMaterialRow: React.FC<VoiceRowProps> = ({
{isPlaying ? <PauseCircleOutlined /> : <PlayCircleOutlined />} {isPlaying ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
</button> </button>
{/* 名称 + 描述 */}
<div className="vmat-row-info"> <div className="vmat-row-info">
<h4 className="vmat-row-name">{material.name}</h4> <h4 className="vmat-row-name">{material.name}</h4>
{material.description && <p className="vmat-row-desc">{material.description}</p>} {material.description && <p className="vmat-row-desc">{material.description}</p>}
</div> </div>
{/* 性别 */}
<span className={`vmat-row-gender ${genderClass(material.gender)}`}> <span className={`vmat-row-gender ${genderClass(material.gender)}`}>
{genderIcon(material.gender)} {genderIcon(material.gender)}
{genderLabel(material.gender)} {genderLabel(material.gender)}
</span> </span>
{/* 标签 */}
<div className="vmat-row-tags"> <div className="vmat-row-tags">
<TagDisplay tagIds={material.tagIds} tagMap={tagMap} onAddTag={() => onEdit()} /> {material.tagIds.length === 0 ? (
<span className="vmat-tag-empty" onClick={() => onEdit()}>
</span>
) : (
<>
{material.tagIds.slice(0, MAX_ROW_TAGS).map((tagId, i) => (
<Tag key={tagId} variant={TAG_VARIANTS[i % TAG_VARIANTS.length]}>
{tagMap.get(tagId)?.name ?? tagId}
</Tag>
))}
{material.tagIds.length > MAX_ROW_TAGS && (
<Tooltip
title={material.tagIds
.slice(MAX_ROW_TAGS)
.map((id) => tagMap.get(id)?.name ?? id)
.join("、")}
>
<Tag className="vmat-tag-overflow">+{material.tagIds.length - MAX_ROW_TAGS}</Tag>
</Tooltip>
)}
</>
)}
</div> </div>
<div ref={progressRef} className="vmat-row-progress" onMouseDown={handleMouseDown}> {/* 进度条(可拖拽) */}
<div ref={progressRef} className="vmat-row-progress" onMouseDown={handleProgressMouseDown}>
<div className="vmat-row-progress-bar" style={{ width: `${progress}%` }} /> <div className="vmat-row-progress-bar" style={{ width: `${progress}%` }} />
{isPlaying && <div className="vmat-progress-thumb" style={{ left: `${progress}%` }} />} {isPlaying && <div className="vmat-progress-thumb" style={{ left: `${progress}%` }} />}
</div> </div>
{/* 时长 */}
<span className="vmat-row-time"> <span className="vmat-row-time">
{isPlaying ? formatDuration(currentTime) : formatDuration(material.duration)} {isPlaying ? formatDuration(currentTime) : formatDuration(material.duration)}
</span> </span>
{/* 文件大小 */}
<span className="vmat-row-size">{formatFileSize(material.fileSize)}</span> <span className="vmat-row-size">{formatFileSize(material.fileSize)}</span>
{/* 操作 */}
<div className="vmat-row-actions"> <div className="vmat-row-actions">
<button <button
type="button" type="button"
@@ -1,73 +0,0 @@
import React, { useRef } from "react"
import { UploadOutlined, SoundOutlined, CloseOutlined } from "@ant-design/icons"
import { formatFileSize } from "../../utils/format"
interface FileUploadFieldProps {
file: File | undefined
onChange: (file: File | undefined) => void
uploadProgress?: number | null
}
const FileUploadField: React.FC<FileUploadFieldProps> = ({ file, onChange, uploadProgress }) => {
const fileInputRef = useRef<HTMLInputElement>(null)
const handleDrop = (e: React.DragEvent) => {
e.preventDefault()
const f = e.dataTransfer.files[0]
if (f?.type.startsWith("audio/")) onChange(f)
}
return (
<div className="vmat-form-field">
<label className="vmat-form-label"> *</label>
<div
className="vmat-upload-zone"
onClick={() => fileInputRef.current?.click()}
onDragOver={(e) => e.preventDefault()}
onDrop={handleDrop}
>
<input
ref={fileInputRef}
type="file"
accept="audio/*"
style={{ display: "none" }}
onChange={(e) => {
const f = e.target.files?.[0]
if (f) onChange(f)
}}
/>
{file ? (
<div className="vmat-upload-selected">
<SoundOutlined className="vmat-upload-icon" />
<span className="vmat-upload-filename">{file.name}</span>
<span className="vmat-upload-filesize">{formatFileSize(file.size)}</span>
<button
type="button"
className="vmat-upload-clear"
onClick={(e) => {
e.stopPropagation()
onChange(undefined)
}}
>
<CloseOutlined />
</button>
</div>
) : (
<div className="vmat-upload-placeholder">
<UploadOutlined className="vmat-upload-icon" />
<p></p>
<span> MP3WAVAACFLAC </span>
</div>
)}
</div>
{uploadProgress !== null && uploadProgress !== undefined && (
<div className="vmat-upload-progress">
<div className="vmat-upload-progress-bar" style={{ width: `${uploadProgress}%` }} />
<span className="vmat-upload-progress-text">{uploadProgress}%</span>
</div>
)}
</div>
)
}
export default FileUploadField
@@ -1,32 +0,0 @@
import React from "react"
import type { VoiceGender } from "../../types"
import { GENDER_OPTIONS } from "../../constants"
import { genderClass } from "../../utils/format"
interface GenderSelectorProps {
value: VoiceGender
onChange: (value: VoiceGender) => void
}
const GenderSelector: React.FC<GenderSelectorProps> = ({ value, onChange }) => {
return (
<div className="vmat-form-field">
<label className="vmat-form-label"></label>
<div className="vmat-gender-group">
{GENDER_OPTIONS.map((opt) => (
<button
key={opt.value}
type="button"
className={`vmat-gender-btn${value === opt.value ? " active" : ""} ${genderClass(opt.value)}`}
onClick={() => onChange(opt.value)}
>
{opt.icon}
{opt.label}
</button>
))}
</div>
</div>
)
}
export default GenderSelector
@@ -1,90 +0,0 @@
import { useState, useRef, useCallback, useMemo } from "react"
import type { TagItem } from "@/api/tags"
interface UseTagInputOptions {
value: string[]
onChange: (tagIds: string[]) => void
tags: TagItem[]
onCreateTag: (name: string) => Promise<TagItem>
}
export function useTagInput({ value, onChange, tags, onCreateTag }: UseTagInputOptions) {
const [inputVal, setInputVal] = useState("")
const [showSuggestions, setShowSuggestions] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
const findTagByName = useCallback(
(name: string) => tags.find((t) => t.name.toLowerCase() === name.toLowerCase()),
[tags],
)
const addTagId = useCallback(
(tagId: string) => {
if (value.includes(tagId)) return
onChange([...value, tagId])
setInputVal("")
setShowSuggestions(false)
},
[value, onChange],
)
const addTagByName = useCallback(
async (name: string) => {
const trimmed = name.trim()
if (!trimmed) return
const existing = findTagByName(trimmed)
if (existing) {
addTagId(existing.id)
} else {
try {
const created = await onCreateTag(trimmed)
addTagId(created.id)
} catch {
/* 创建失败静默忽略 */
}
}
},
[findTagByName, addTagId, onCreateTag],
)
const removeTagId = useCallback(
(tagId: string) => {
onChange(value.filter((t) => t !== tagId))
},
[value, onChange],
)
const suggestions = useMemo(() => {
if (!inputVal.trim()) return []
const lower = inputVal.toLowerCase()
return tags.filter((t) => t.name.toLowerCase().includes(lower) && !value.includes(t.id))
}, [inputVal, tags, value])
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault()
if (suggestions.length > 0) {
addTagId(suggestions[0].id)
} else {
addTagByName(inputVal)
}
} else if (e.key === "Backspace" && !inputVal && value.length > 0) {
removeTagId(value[value.length - 1])
}
}
const focus = () => inputRef.current?.focus()
return {
inputVal,
setInputVal,
showSuggestions,
setShowSuggestions,
inputRef,
suggestions,
addTagId,
removeTagId,
handleKeyDown,
focus,
}
}
@@ -1,26 +0,0 @@
import React from "react"
import { CheckOutlined } from "@ant-design/icons"
interface BatchCheckboxProps {
isSelected: boolean
visible: boolean
onToggle: () => void
}
/** 批量选择 checkbox */
const BatchCheckbox: React.FC<BatchCheckboxProps> = ({ isSelected, visible, onToggle }) => {
if (!visible) return null
return (
<div
className={`vmat-card-checkbox vmat-checkbox${isSelected ? " checked" : ""}`}
onClick={(e) => {
e.stopPropagation()
onToggle()
}}
>
{isSelected && <CheckOutlined />}
</div>
)
}
export default BatchCheckbox
@@ -1,39 +0,0 @@
import React from "react"
import { EditOutlined, DeleteOutlined } from "@ant-design/icons"
interface CardActionsProps {
onEdit: () => void
onDelete: () => void
}
/** 卡片操作按钮:编辑 / 删除 */
const CardActions: React.FC<CardActionsProps> = ({ onEdit, onDelete }) => {
return (
<div className="vmat-card-actions">
<button
type="button"
className="vmat-card-action-btn"
onClick={(e) => {
e.stopPropagation()
onEdit()
}}
title="编辑"
>
<EditOutlined />
</button>
<button
type="button"
className="vmat-card-action-btn vmat-card-action-btn--danger"
onClick={(e) => {
e.stopPropagation()
onDelete()
}}
title="删除"
>
<DeleteOutlined />
</button>
</div>
)
}
export default CardActions
@@ -1,30 +0,0 @@
import React from "react"
import { AudioOutlined } from "@ant-design/icons"
import { type VoiceMaterial } from "../../types"
import { genderClass, genderIcon, genderLabel } from "../../utils/format"
interface CardHeaderProps {
material: VoiceMaterial
}
/** 卡片头部:头像 + 名称 + 性别标签 */
const CardHeader: React.FC<CardHeaderProps> = ({ material }) => {
return (
<div className="vmat-card-header">
<div className="vmat-card-avatar">
<AudioOutlined />
</div>
<div className="vmat-card-title-area">
<h4 className="vmat-card-name" title={material.name}>
{material.name}
</h4>
<span className={`vmat-card-gender ${genderClass(material.gender)}`}>
{genderIcon(material.gender)}
{genderLabel(material.gender)}
</span>
</div>
</div>
)
}
export default CardHeader
@@ -1,21 +0,0 @@
import React from "react"
import { formatDuration, formatFileSize, formatDate } from "../../utils/format"
interface CardMetaProps {
duration: number
fileSize: number
createdAt: string
}
/** 元信息:时长 / 文件大小 / 创建日期 */
const CardMeta: React.FC<CardMetaProps> = ({ duration, fileSize, createdAt }) => {
return (
<div className="vmat-card-meta">
<span>{formatDuration(duration)}</span>
<span>{formatFileSize(fileSize)}</span>
<span>{formatDate(createdAt)}</span>
</div>
)
}
export default CardMeta
@@ -1,110 +0,0 @@
import React, { useRef } from "react"
import {
PlayCircleOutlined,
PauseCircleOutlined,
SoundOutlined,
MutedOutlined,
} from "@ant-design/icons"
import { formatDuration } from "../../utils/format"
interface CardPlayerProps {
isPlaying: boolean
currentTime: number
duration: number
volume: number
fileUrl?: string
onPlay: () => void
onPause: () => void
onSeek: (time: number) => void
onVolumeChange: (e: React.ChangeEvent<HTMLInputElement>) => void
onToggleMute: () => void
}
/** 播放控制区:播放按钮 + 进度条 + 时间 + 音量 */
const CardPlayer: React.FC<CardPlayerProps> = ({
isPlaying,
currentTime,
duration,
volume,
fileUrl,
onPlay,
onPause,
onSeek,
onVolumeChange,
onToggleMute,
}) => {
const progressRef = useRef<HTMLDivElement>(null)
const handleProgressMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
if (!progressRef.current) return
e.preventDefault()
const doSeek = (ev: MouseEvent) => {
if (!progressRef.current) return
const rect = progressRef.current.getBoundingClientRect()
const percent = Math.max(0, Math.min(1, (ev.clientX - rect.left) / rect.width))
onSeek(percent * duration)
}
doSeek(e.nativeEvent)
const handleMove = (ev: MouseEvent) => doSeek(ev)
const handleUp = () => {
document.removeEventListener("mousemove", handleMove)
document.removeEventListener("mouseup", handleUp)
}
document.addEventListener("mousemove", handleMove)
document.addEventListener("mouseup", handleUp)
}
const progress = duration > 0 ? (currentTime / duration) * 100 : 0
return (
<div className="vmat-card-player">
<button
type="button"
className="vmat-play-btn"
onClick={(e) => {
e.stopPropagation()
isPlaying ? onPause() : onPlay()
}}
disabled={!fileUrl}
>
{isPlaying ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
</button>
<div ref={progressRef} className="vmat-progress" onMouseDown={handleProgressMouseDown}>
<div className="vmat-progress-bar" style={{ width: `${progress}%` }} />
{isPlaying && <div className="vmat-progress-thumb" style={{ left: `${progress}%` }} />}
</div>
<span className="vmat-time">
{isPlaying ? formatDuration(currentTime) : formatDuration(duration)}
</span>
{/* 音量控制 */}
<div className="vmat-volume">
<button
type="button"
className="vmat-volume-btn"
onClick={(e) => {
e.stopPropagation()
onToggleMute()
}}
title={volume === 0 ? "取消静音" : "静音"}
>
{volume === 0 ? <MutedOutlined /> : <SoundOutlined />}
</button>
<input
type="range"
className="vmat-volume-slider"
min={0}
max={1}
step={0.05}
value={volume}
onChange={(e) => {
e.stopPropagation()
onVolumeChange(e)
}}
onClick={(e) => e.stopPropagation()}
/>
</div>
</div>
)
}
export default CardPlayer
@@ -1,52 +0,0 @@
import React from "react"
import { Tooltip } from "antd"
import { Tag } from "@/components/ui"
import { type TagItem } from "@/api/tags"
import { MAX_CARD_TAGS, TAG_VARIANTS } from "../../constants"
interface CardTagsProps {
tagIds: string[]
tagMap: Map<string, TagItem>
onEdit: () => void
}
/** 标签展示区 */
const CardTags: React.FC<CardTagsProps> = ({ tagIds, tagMap, onEdit }) => {
if (tagIds.length === 0) {
return (
<div className="vmat-card-tags">
<span
className="vmat-tag-empty"
onClick={(e) => {
e.stopPropagation()
onEdit()
}}
>
</span>
</div>
)
}
return (
<div className="vmat-card-tags">
{tagIds.slice(0, MAX_CARD_TAGS).map((tagId, i) => (
<Tag key={tagId} variant={TAG_VARIANTS[i % TAG_VARIANTS.length]}>
{tagMap.get(tagId)?.name ?? tagId}
</Tag>
))}
{tagIds.length > MAX_CARD_TAGS && (
<Tooltip
title={tagIds
.slice(MAX_CARD_TAGS)
.map((id) => tagMap.get(id)?.name ?? id)
.join("、")}
>
<Tag className="vmat-tag-overflow">+{tagIds.length - MAX_CARD_TAGS}</Tag>
</Tooltip>
)}
</div>
)
}
export default CardTags
@@ -1,8 +0,0 @@
export { default } from "../VoiceMaterialCard"
export * from "./types"
export { default as CardHeader } from "./CardHeader"
export { default as CardTags } from "./CardTags"
export { default as CardMeta } from "./CardMeta"
export { default as CardPlayer } from "./CardPlayer"
export { default as CardActions } from "./CardActions"
export { default as BatchCheckbox } from "./BatchCheckbox"
@@ -1,21 +0,0 @@
import React from "react"
import { type VoiceMaterial } from "../../types"
import { type TagItem } from "@/api/tags"
export interface VoiceCardProps {
material: VoiceMaterial
isPlaying: boolean
currentTime: number
isSelected: boolean
batchMode: boolean
volume: number
tagMap: Map<string, TagItem>
onPlay: () => void
onPause: () => void
onSeek: (time: number) => void
onEdit: () => void
onDelete: () => void
onToggleSelect: (id: string) => void
onVolumeChange: (e: React.ChangeEvent<HTMLInputElement>) => void
onToggleMute: () => void
}
@@ -1,41 +0,0 @@
import React from "react"
import { Tooltip } from "antd"
import { Tag } from "@/components/ui"
import { type TagItem } from "@/api/tags"
import { MAX_ROW_TAGS, TAG_VARIANTS } from "../../constants"
interface TagDisplayProps {
tagIds: string[]
tagMap: Map<string, TagItem>
onAddTag?: () => void
}
const TagDisplay: React.FC<TagDisplayProps> = ({ tagIds, tagMap, onAddTag }) => {
if (tagIds.length === 0) {
return (
<span className="vmat-tag-empty" onClick={onAddTag}>
</span>
)
}
const visible = tagIds.slice(0, MAX_ROW_TAGS)
const overflow = tagIds.slice(MAX_ROW_TAGS)
return (
<>
{visible.map((tagId, i) => (
<Tag key={tagId} variant={TAG_VARIANTS[i % TAG_VARIANTS.length]}>
{tagMap.get(tagId)?.name ?? tagId}
</Tag>
))}
{overflow.length > 0 && (
<Tooltip title={overflow.map((id) => tagMap.get(id)?.name ?? id).join("、")}>
<Tag className="vmat-tag-overflow">+{overflow.length}</Tag>
</Tooltip>
)}
</>
)
}
export default TagDisplay
@@ -1,38 +0,0 @@
import { useRef, useCallback } from "react"
interface UseRowProgressOptions {
duration: number
onSeek: (time: number) => void
}
export function useRowProgress({ duration, onSeek }: UseRowProgressOptions) {
const progressRef = useRef<HTMLDivElement>(null)
const handleMouseDown = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if (!progressRef.current) return
e.preventDefault()
const doSeek = (ev: MouseEvent) => {
if (!progressRef.current) return
const rect = progressRef.current.getBoundingClientRect()
const percent = Math.max(0, Math.min(1, (ev.clientX - rect.left) / rect.width))
onSeek(percent * duration)
}
doSeek(e.nativeEvent)
const handleMove = (ev: MouseEvent) => doSeek(ev)
const handleUp = () => {
document.removeEventListener("mousemove", handleMove)
document.removeEventListener("mouseup", handleUp)
}
document.addEventListener("mousemove", handleMove)
document.addEventListener("mouseup", handleUp)
},
[duration, onSeek],
)
return { progressRef, handleMouseDown }
}
@@ -1,38 +0,0 @@
import { useCallback } from "react"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { deleteAsset } from "@/api/assets"
import { type VoiceMaterial } from "../../../types"
interface UseVoiceDeleteOptions {
materials: VoiceMaterial[]
}
/**
* 配音素材删除 Hook
*/
export function useVoiceDelete({ materials }: UseVoiceDeleteOptions) {
const queryClient = useQueryClient()
const deleteMutation = useMutation({
mutationFn: (assetId: string) => deleteAsset(assetId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["assets", "voice"] })
},
})
const handleDelete = useCallback(
(id: string, onBeforeDelete?: () => void) => {
const material = materials.find((m) => m.id === id)
if (!material) return
if (onBeforeDelete) onBeforeDelete()
deleteMutation.mutate(id)
},
[materials, deleteMutation],
)
return {
isDeleting: deleteMutation.isPending,
deleteMutation,
handleDelete,
}
}
@@ -1,78 +0,0 @@
import { useCallback } from "react"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { updateAsset } from "@/api/assets"
import { tagAsset, untagAsset } from "@/api/tags"
import { type VoiceGender, type VoiceMaterial, buildMetadata } from "../../../types"
interface UseVoiceEditOptions {
materials: VoiceMaterial[]
}
/**
* 配音素材编辑 Hook
* 封装编辑流程:更新基础信息 + 同步标签差异
*/
export function useVoiceEdit({ materials }: UseVoiceEditOptions) {
const queryClient = useQueryClient()
const editMutation = useMutation({
mutationFn: async (data: {
id: string
name: string
gender: VoiceGender
description: string
tagIds: string[]
}) => {
// 1. 更新基础信息
await updateAsset(data.id, {
name: data.name,
metadata: buildMetadata({
gender: data.gender,
description: data.description,
}),
})
// 2. 对比标签差异,调用 tag/untag API
const currentAsset = materials.find((m) => m.id === data.id)
const oldTagIds = currentAsset?.tagIds ?? []
const newTagIds = data.tagIds
const toAdd = newTagIds.filter((id) => !oldTagIds.includes(id))
const toRemove = oldTagIds.filter((id) => !newTagIds.includes(id))
if (toAdd.length > 0) {
await tagAsset(data.id, toAdd)
}
for (const tagId of toRemove) {
await untagAsset(data.id, tagId)
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["assets", "voice"] })
queryClient.invalidateQueries({ queryKey: ["tags"] })
},
})
const handleEdit = useCallback(
(
editingMaterial: VoiceMaterial | null,
data: Omit<VoiceMaterial, "id" | "createdAt"> & { file?: File },
) => {
if (!editingMaterial) return
editMutation.mutate({
id: editingMaterial.id,
name: data.name,
gender: data.gender,
description: data.description,
tagIds: data.tagIds,
})
},
[editMutation],
)
return {
isEditing: editMutation.isPending,
editMutation,
handleEdit,
}
}
@@ -1,111 +0,0 @@
import { useState, useCallback } from "react"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { message } from "antd"
import {
createAsset,
uploadAssetDirect,
getAssetLibraries,
type AssetLibraryItem,
} from "@/api/assets"
import { tagAsset } from "@/api/tags"
import { type VoiceGender, type VoiceMaterial, buildMetadata } from "../../../types"
import { getAudioDuration } from "../../../utils/audio"
interface UseVoiceUploadOptions {
voiceLibrary?: { id: string; kind: string }
createLibMutation: { mutateAsync: () => Promise<AssetLibraryItem>; isPending: boolean }
}
/**
* 配音素材上传 Hook
* 封装上传流程:获取库 → 上传文件 → 获取时长 → 创建记录 → 打标签
*/
export function useVoiceUpload({ voiceLibrary, createLibMutation }: UseVoiceUploadOptions) {
const queryClient = useQueryClient()
const [uploadProgress, setUploadProgress] = useState<number | null>(null)
const uploadMutation = useMutation({
mutationFn: async (data: {
file: File
name: string
gender: VoiceGender
description: string
tagIds: string[]
}) => {
setUploadProgress(0)
try {
// 1. 获取或等待 voice library
let lib = voiceLibrary
if (!lib) {
if (createLibMutation.isPending) {
await createLibMutation.mutateAsync()
}
const libs = await queryClient.fetchQuery({
queryKey: ["asset-libraries"],
queryFn: getAssetLibraries,
})
lib = libs.find((l: AssetLibraryItem) => l.kind === "voice")
if (!lib) throw new Error("无法创建配音库")
}
// 2. 上传文件(带进度)
const { storage_key } = await uploadAssetDirect({
file: data.file,
library_id: lib.id,
onProgress: (p) => setUploadProgress(p),
})
// 3. 获取音频时长
const duration = await getAudioDuration(data.file)
// 4. 创建素材记录
const asset = await createAsset({
library_id: lib.id,
name: data.name,
storage_key,
mime_type: data.file.type || "audio/mpeg",
metadata: buildMetadata({
gender: data.gender,
description: data.description,
duration,
}),
})
// 5. 打标签(标签走独立 API)
if (data.tagIds.length > 0) {
await tagAsset(asset.id, data.tagIds)
}
} finally {
setUploadProgress(null)
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["assets", "voice"] })
queryClient.invalidateQueries({ queryKey: ["tags"] })
},
onError: (err: Error) => {
message.error(err.message || "上传失败,请重试")
},
})
const handleUpload = useCallback(
(data: Omit<VoiceMaterial, "id" | "createdAt"> & { file?: File }) => {
if (!data.file) return
uploadMutation.mutate({
file: data.file,
name: data.name,
gender: data.gender,
description: data.description,
tagIds: data.tagIds,
})
},
[uploadMutation],
)
return {
uploadProgress,
isUploading: uploadMutation.isPending,
uploadMutation,
handleUpload,
}
}
@@ -1,9 +1,17 @@
import { useState } from "react" import { useState, useCallback } from "react"
import { type VoiceMaterial } from "../../types" import { useMutation, useQueryClient } from "@tanstack/react-query"
import { type AssetLibraryItem } from "@/api/assets" import { message } from "antd"
import { useVoiceUpload } from "./actions/useVoiceUpload" import {
import { useVoiceEdit } from "./actions/useVoiceEdit" createAsset,
import { useVoiceDelete } from "./actions/useVoiceDelete" updateAsset,
deleteAsset,
uploadAssetDirect,
getAssetLibraries,
type AssetLibraryItem,
} from "@/api/assets"
import { tagAsset, untagAsset } from "@/api/tags"
import { type VoiceGender, type VoiceMaterial, buildMetadata } from "../../types"
import { getAudioDuration } from "../../utils/audio"
interface UseVoiceMaterialActionsOptions { interface UseVoiceMaterialActionsOptions {
voiceLibrary?: { id: string; kind: string } voiceLibrary?: { id: string; kind: string }
@@ -13,56 +21,184 @@ interface UseVoiceMaterialActionsOptions {
/** /**
* 配音素材操作 Hook * 配音素材操作 Hook
* 组合上传、编辑、删除三个子 Hook,统一管理弹窗状态 * 封装上传、编辑、删除等变更操作及相关 UI 状态
*/ */
export function useVoiceMaterialActions({ export function useVoiceMaterialActions({
voiceLibrary, voiceLibrary,
materials, materials,
createLibMutation, createLibMutation,
}: UseVoiceMaterialActionsOptions) { }: UseVoiceMaterialActionsOptions) {
// ── 弹窗状态 ── const queryClient = useQueryClient()
// ── 弹窗状态 ──────────────────────────────────────────────
const [uploadOpen, setUploadOpen] = useState(false) const [uploadOpen, setUploadOpen] = useState(false)
const [editingMaterial, setEditingMaterial] = useState<VoiceMaterial | null>(null) const [editingMaterial, setEditingMaterial] = useState<VoiceMaterial | null>(null)
// ── 子领域 Hooks ── // ── 上传进度 ──────────────────────────────────────────────
const { uploadProgress, uploadMutation } = useVoiceUpload({ voiceLibrary, createLibMutation }) const [uploadProgress, setUploadProgress] = useState<number | null>(null)
const { editMutation } = useVoiceEdit({ materials })
const { handleDelete } = useVoiceDelete({ materials })
/* ── 操作 handlers(关联弹窗状态) ── */ // ── 上传 mutation ─────────────────────────────────────────
const uploadMutation = useMutation({
mutationFn: async (data: {
file: File
name: string
gender: VoiceGender
description: string
tagIds: string[]
}) => {
setUploadProgress(0)
try {
// 1. 获取或等待 voice library
let lib = voiceLibrary
if (!lib) {
if (createLibMutation.isPending) {
await createLibMutation.mutateAsync()
}
const libs = await queryClient.fetchQuery({
queryKey: ["asset-libraries"],
queryFn: getAssetLibraries,
})
lib = libs.find((l: AssetLibraryItem) => l.kind === "voice")
if (!lib) throw new Error("无法创建配音库")
}
const handleUpload = (data: Omit<VoiceMaterial, "id" | "createdAt"> & { file?: File }) => { // 2. 上传文件(带进度)
if (!data.file) return const { storage_key } = await uploadAssetDirect({
uploadMutation.mutate( file: data.file,
{ library_id: lib.id,
file: data.file, onProgress: (p) => setUploadProgress(p),
})
// 3. 获取音频时长
const duration = await getAudioDuration(data.file)
// 4. 创建素材记录
const asset = await createAsset({
library_id: lib.id,
name: data.name,
storage_key,
mime_type: data.file.type || "audio/mpeg",
metadata: buildMetadata({
gender: data.gender,
description: data.description,
duration,
}),
})
// 5. 打标签(标签走独立 API)
if (data.tagIds.length > 0) {
await tagAsset(asset.id, data.tagIds)
}
} finally {
setUploadProgress(null)
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["assets", "voice"] })
queryClient.invalidateQueries({ queryKey: ["tags"] })
},
onError: (err: Error) => {
message.error(err.message || "上传失败,请重试")
},
})
// ── 编辑 mutation ─────────────────────────────────────────
const editMutation = useMutation({
mutationFn: async (data: {
id: string
name: string
gender: VoiceGender
description: string
tagIds: string[]
}) => {
// 1. 更新基础信息
await updateAsset(data.id, {
name: data.name,
metadata: buildMetadata({
gender: data.gender,
description: data.description,
}),
})
// 2. 对比标签差异,调用 tag/untag API
const currentAsset = materials.find((m) => m.id === data.id)
const oldTagIds = currentAsset?.tagIds ?? []
const newTagIds = data.tagIds
const toAdd = newTagIds.filter((id) => !oldTagIds.includes(id))
const toRemove = oldTagIds.filter((id) => !newTagIds.includes(id))
if (toAdd.length > 0) {
await tagAsset(data.id, toAdd)
}
for (const tagId of toRemove) {
await untagAsset(data.id, tagId)
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["assets", "voice"] })
queryClient.invalidateQueries({ queryKey: ["tags"] })
},
})
// ── 删除 mutation ─────────────────────────────────────────
const deleteMutation = useMutation({
mutationFn: (assetId: string) => deleteAsset(assetId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["assets", "voice"] })
},
})
/* ── 数据操作 handlers ──────────────────────────────────── */
const handleUpload = useCallback(
(data: Omit<VoiceMaterial, "id" | "createdAt"> & { file?: File }) => {
if (!data.file) return
uploadMutation.mutate(
{
file: data.file,
name: data.name,
gender: data.gender,
description: data.description,
tagIds: data.tagIds,
},
{
onSuccess: () => {
setUploadOpen(false)
},
},
)
},
[uploadMutation],
)
const handleEdit = useCallback(
(data: Omit<VoiceMaterial, "id" | "createdAt"> & { file?: File }) => {
if (!editingMaterial) return
editMutation.mutate({
id: editingMaterial.id,
name: data.name, name: data.name,
gender: data.gender, gender: data.gender,
description: data.description, description: data.description,
tagIds: data.tagIds, tagIds: data.tagIds,
}, })
{ setEditingMaterial(null)
onSuccess: () => { },
setUploadOpen(false) [editingMaterial, editMutation],
}, )
},
)
}
const handleEdit = (data: Omit<VoiceMaterial, "id" | "createdAt"> & { file?: File }) => { const handleDelete = useCallback(
if (!editingMaterial) return (id: string, onBeforeDelete?: () => void) => {
editMutation.mutate({ const material = materials.find((m) => m.id === id)
id: editingMaterial.id, if (!material) return
name: data.name, if (onBeforeDelete) onBeforeDelete()
gender: data.gender, deleteMutation.mutate(id)
description: data.description, },
tagIds: data.tagIds, [materials, deleteMutation],
}) )
setEditingMaterial(null)
}
return { return {
// 上传 & 编辑 loading 状态 // 上传 & 编辑状态
uploadProgress, uploadProgress,
isUploading: uploadMutation.isPending, isUploading: uploadMutation.isPending,
isEditing: editMutation.isPending, isEditing: editMutation.isPending,
@@ -1,9 +1,16 @@
import React from "react" import React from "react"
import { DeleteOutlined, ReloadOutlined, CloseCircleOutlined } from "@ant-design/icons" import {
SoundOutlined,
PlayCircleOutlined,
PauseCircleOutlined,
DeleteOutlined,
ReloadOutlined,
CloseCircleOutlined,
UserOutlined,
} from "@ant-design/icons"
import { Tooltip } from "antd" import { Tooltip } from "antd"
import { type ClonedVoiceDisplay } from "@/pages/voices/types" import { type ClonedVoiceDisplay } from "@/pages/voices/types"
import CardHeader from "./clone-voice-card/CardHeader" import { CLONE_STATUS_CONFIG } from "@/pages/voices/constants"
import CardFooter from "./clone-voice-card/CardFooter"
export interface CloneVoiceCardProps { export interface CloneVoiceCardProps {
voice: ClonedVoiceDisplay voice: ClonedVoiceDisplay
@@ -21,18 +28,26 @@ export interface CloneVoiceCardProps {
const CloneVoiceCard: React.FC<CloneVoiceCardProps> = ({ const CloneVoiceCard: React.FC<CloneVoiceCardProps> = ({
voice, voice,
isPlaying, isPlaying,
currentTime,
onPlay,
onPause,
onUse,
onDelete, onDelete,
onRetry, onRetry,
onShowDetail, onShowDetail,
...footerProps
}) => { }) => {
const statusCfg = CLONE_STATUS_CONFIG[voice.status]
const isFailed = voice.status === "failed" const isFailed = voice.status === "failed"
const isProcessing = voice.status === "processing"
const genderText =
voice.gender === "male" ? "男声" : voice.gender === "female" ? "女声" : voice.gender
return ( return (
<div <div
className={`xx-clone-card${isPlaying ? " playing" : ""}${isFailed ? " failed" : ""}`} className={`xx-clone-card${isPlaying ? " playing" : ""}${isFailed ? " failed" : ""}`}
onClick={isFailed ? undefined : onShowDetail} onClick={isFailed ? undefined : onShowDetail}
> >
{/* 右上角操作按钮 */}
<div className="xx-clone-card-actions"> <div className="xx-clone-card-actions">
<Tooltip title="删除"> <Tooltip title="删除">
<button <button
@@ -62,8 +77,38 @@ const CloneVoiceCard: React.FC<CloneVoiceCardProps> = ({
)} )}
</div> </div>
<CardHeader voice={voice} /> {/* 头部:头像 + 名称 + 状态 */}
<div className="xx-clone-card-header">
<div className={`xx-clone-avatar${isProcessing ? " xx-clone-avatar--processing" : ""}`}>
<SoundOutlined />
</div>
<div className="xx-clone-header-info">
<h4 className="xx-clone-name" title={voice.name}>
{voice.name}
</h4>
<span className={`xx-clone-status ${statusCfg.className}`}>
<span className="xx-clone-status-dot" />
{statusCfg.label}
</span>
</div>
</div>
{/* 描述 */}
{voice.description && <p className="xx-clone-desc">{voice.description}</p>}
{/* 元信息 */}
<div className="xx-clone-meta">
{(voice.gender || voice.language) && (
<span className="xx-clone-meta-item">
<UserOutlined />
{genderText}
{voice.language ? ` · ${voice.language}` : ""}
</span>
)}
<span className="xx-clone-meta-item">{voice.createdAt}</span>
</div>
{/* 错误信息 */}
{isFailed && voice.errorMessage && ( {isFailed && voice.errorMessage && (
<div className="xx-clone-error"> <div className="xx-clone-error">
<CloseCircleOutlined /> <CloseCircleOutlined />
@@ -71,7 +116,63 @@ const CloneVoiceCard: React.FC<CloneVoiceCardProps> = ({
</div> </div>
)} )}
<CardFooter voice={voice} isPlaying={isPlaying} onRetry={onRetry} {...footerProps} /> {/* 底部操作区 */}
<div className="xx-clone-footer">
{voice.status === "ready" && (
<>
<button
type="button"
className="xx-clone-play-btn"
onClick={(e) => {
e.stopPropagation()
isPlaying ? onPause() : onPlay()
}}
title={isPlaying ? "暂停" : "试听"}
>
{isPlaying ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
</button>
<div className="xx-clone-progress">
<div
className="xx-clone-progress-bar"
style={{
width: isPlaying
? `${Math.min((currentTime / Math.max(voice.duration, 1)) * 100, 100)}%`
: "0%",
}}
/>
</div>
<button
type="button"
className="xx-clone-use-btn"
onClick={(e) => {
e.stopPropagation()
onUse()
}}
>
使
</button>
</>
)}
{isProcessing && (
<div className="xx-clone-processing-hint">
<ReloadOutlined spin />
...
</div>
)}
{isFailed && (
<button
type="button"
className="xx-clone-retry-btn"
onClick={(e) => {
e.stopPropagation()
onRetry()
}}
>
<ReloadOutlined />
</button>
)}
</div>
</div> </div>
) )
} }
@@ -1,87 +0,0 @@
import React from "react"
import { PlayCircleOutlined, PauseCircleOutlined, ReloadOutlined } from "@ant-design/icons"
import { type ClonedVoiceDisplay } from "@/pages/voices/types"
interface CardFooterProps {
voice: ClonedVoiceDisplay
isPlaying: boolean
currentTime: number
onPlay: () => void
onPause: () => void
onUse: () => void
onRetry: () => void
}
const CardFooter: React.FC<CardFooterProps> = ({
voice,
isPlaying,
currentTime,
onPlay,
onPause,
onUse,
onRetry,
}) => {
const isFailed = voice.status === "failed"
const isProcessing = voice.status === "processing"
return (
<div className="xx-clone-footer">
{voice.status === "ready" && (
<>
<button
type="button"
className="xx-clone-play-btn"
onClick={(e) => {
e.stopPropagation()
isPlaying ? onPause() : onPlay()
}}
title={isPlaying ? "暂停" : "试听"}
>
{isPlaying ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
</button>
<div className="xx-clone-progress">
<div
className="xx-clone-progress-bar"
style={{
width: isPlaying
? `${Math.min((currentTime / Math.max(voice.duration, 1)) * 100, 100)}%`
: "0%",
}}
/>
</div>
<button
type="button"
className="xx-clone-use-btn"
onClick={(e) => {
e.stopPropagation()
onUse()
}}
>
使
</button>
</>
)}
{isProcessing && (
<div className="xx-clone-processing-hint">
<ReloadOutlined spin />
...
</div>
)}
{isFailed && (
<button
type="button"
className="xx-clone-retry-btn"
onClick={(e) => {
e.stopPropagation()
onRetry()
}}
>
<ReloadOutlined />
</button>
)}
</div>
)
}
export default CardFooter
@@ -1,51 +0,0 @@
import React from "react"
import { SoundOutlined, UserOutlined } from "@ant-design/icons"
import { type ClonedVoiceDisplay } from "@/pages/voices/types"
import { CLONE_STATUS_CONFIG } from "@/pages/voices/constants"
interface CardHeaderProps {
voice: ClonedVoiceDisplay
}
const genderTextOf = (gender: string) =>
gender === "male" ? "男声" : gender === "female" ? "女声" : gender
const CardHeader: React.FC<CardHeaderProps> = ({ voice }) => {
const statusCfg = CLONE_STATUS_CONFIG[voice.status]
const isProcessing = voice.status === "processing"
const genderText = genderTextOf(voice.gender ?? "")
return (
<>
<div className="xx-clone-card-header">
<div className={`xx-clone-avatar${isProcessing ? " xx-clone-avatar--processing" : ""}`}>
<SoundOutlined />
</div>
<div className="xx-clone-header-info">
<h4 className="xx-clone-name" title={voice.name}>
{voice.name}
</h4>
<span className={`xx-clone-status ${statusCfg.className}`}>
<span className="xx-clone-status-dot" />
{statusCfg.label}
</span>
</div>
</div>
{voice.description && <p className="xx-clone-desc">{voice.description}</p>}
<div className="xx-clone-meta">
{(voice.gender || voice.language) && (
<span className="xx-clone-meta-item">
<UserOutlined />
{genderText}
{voice.language ? ` · ${voice.language}` : ""}
</span>
)}
<span className="xx-clone-meta-item">{voice.createdAt}</span>
</div>
</>
)
}
export default CardHeader
-1
View File
@@ -1,4 +1,3 @@
// 重构:DuplicationUpload 页面已拆分为子组件(UploadZone/InfoSidebar/UploadProgress等)
import { describe, expect, it, vi, beforeEach } from "vitest" import { describe, expect, it, vi, beforeEach } from "vitest"
import { import {
uploadForDuplication, uploadForDuplication,
-1
View File
@@ -1,5 +1,4 @@
import React from "react" import React from "react"
// 重构:useCloneModal Hook 已拆分为 useCloneFormState + useCloneSubmit 子 Hook
import { describe, expect, it, vi } from "vitest" import { describe, expect, it, vi } from "vitest"
import { render } from "@testing-library/react" import { render } from "@testing-library/react"
import CloneModal from "@/components/voice/CloneModal" import CloneModal from "@/components/voice/CloneModal"
-5
View File
@@ -122,11 +122,6 @@ import "@/pages/templates/hooks/useTemplateLibrary"
import "@/pages/templates/hooks/useTemplateDetail" import "@/pages/templates/hooks/useTemplateDetail"
import "@/pages/templates/components/template-library/TemplateCard" import "@/pages/templates/components/template-library/TemplateCard"
import "@/pages/templates/components/template-library/TemplateDetailModal" import "@/pages/templates/components/template-library/TemplateDetailModal"
import "@/pages/templates/components/template-library/template-detail-modal/PreviewArea"
import "@/pages/templates/components/template-library/template-detail-modal/SegmentList"
import "@/pages/templates/components/template-library/template-detail-modal/StyleConfig"
import "@/pages/templates/components/template-library/template-detail-modal/DetailFooter"
import "@/pages/templates/components/template-library/template-detail-modal/types"
import "@/pages/templates/components/template-library/TemplateHeader" import "@/pages/templates/components/template-library/TemplateHeader"
import "@/pages/templates/components/template-library/TemplateToolbar" import "@/pages/templates/components/template-library/TemplateToolbar"
import "@/pages/templates/components/template-library/TemplateGrid" import "@/pages/templates/components/template-library/TemplateGrid"
@@ -2,9 +2,6 @@
* GeneratePage 模块 smoke test * GeneratePage 模块 smoke test
* 建立完整依赖链,确保 vitest related 模式能匹配到 * 建立完整依赖链,确保 vitest related 模式能匹配到
* generate 目录下所有文件的改动(包括 Phase 3 子组件) * generate 目录下所有文件的改动(包括 Phase 3 子组件)
*
* 重构记录:
* - useStep2Materials 拆分为 useMaterialLibrary + useSmartMatch 子 Hook
*/ */
import { describe, it, expect } from "vitest" import { describe, it, expect } from "vitest"
@@ -8,14 +8,14 @@ import type {
UseGenerateVideoProps, UseGenerateVideoProps,
GenerationPhase, GenerationPhase,
} from "@/pages/generate/hooks/generate-video/types" } from "@/pages/generate/hooks/generate-video/types"
import { getGenerationPhase } from "@/pages/generate/hooks/generate-video/phase" import { getNextPhase, PHASE_ORDER } from "@/pages/generate/hooks/generate-video/phase"
import { extractBackendError } from "@/pages/generate/hooks/generate-video/errorUtils" import { extractErrorMessage } from "@/pages/generate/hooks/generate-video/errorUtils"
import { buildVoiceConfig } from "@/pages/generate/hooks/generate-video/voiceConfig" import { getDefaultVoiceConfig } from "@/pages/generate/hooks/generate-video/voiceConfig"
describe("generate-video module smoke test", () => { describe("generate-video module smoke test", () => {
it("should load all generate-video modules", () => { it("should load all generate-video modules", () => {
expect(typeof getGenerationPhase).toBe("function") expect(PHASE_ORDER.length).toBeGreaterThan(0)
expect(typeof extractBackendError).toBe("function") expect(typeof extractErrorMessage).toBe("function")
expect(typeof buildVoiceConfig).toBe("function") expect(typeof getDefaultVoiceConfig).toBe("function")
}) })
}) })
@@ -1,17 +0,0 @@
/**
* Tasks 模块 smoke test
* 建立依赖链,确保 vitest related 能匹配到 tasks 目录下的改动
*/
import { describe, it, expect } from "vitest"
import "@/pages/tasks/components/TaskTable"
import "@/pages/tasks/components/task-table/useTaskTableColumns"
import "@/pages/tasks/components/task-table/TaskEmptyState"
import "@/pages/tasks/components/TaskFilterBar"
import "@/pages/tasks/components/TaskErrorDetail"
describe("Tasks module smoke test", () => {
it("should load all task modules", () => {
expect(true).toBe(true)
})
})
-10
View File
@@ -12,13 +12,6 @@ import "@/pages/voice-materials/VoiceMaterialLibrary"
import "@/pages/voice-materials/components/TagSelector" import "@/pages/voice-materials/components/TagSelector"
import "@/pages/voice-materials/components/MaterialForm" import "@/pages/voice-materials/components/MaterialForm"
import "@/pages/voice-materials/components/VoiceMaterialCard" import "@/pages/voice-materials/components/VoiceMaterialCard"
import "@/pages/voice-materials/components/voice-material-card/CardHeader"
import "@/pages/voice-materials/components/voice-material-card/CardTags"
import "@/pages/voice-materials/components/voice-material-card/CardMeta"
import "@/pages/voice-materials/components/voice-material-card/CardPlayer"
import "@/pages/voice-materials/components/voice-material-card/CardActions"
import "@/pages/voice-materials/components/voice-material-card/BatchCheckbox"
import "@/pages/voice-materials/components/voice-material-card/types"
import "@/pages/voice-materials/components/VoiceMaterialRow" import "@/pages/voice-materials/components/VoiceMaterialRow"
import "@/pages/voice-materials/components/Toolbar" import "@/pages/voice-materials/components/Toolbar"
import "@/pages/voice-materials/components/TagFilterBar" import "@/pages/voice-materials/components/TagFilterBar"
@@ -40,9 +33,6 @@ describe("VoiceMaterialLibrary module smoke test", () => {
// Hooks // Hooks
import "@/pages/voice-materials/hooks/useVoiceMaterials" import "@/pages/voice-materials/hooks/useVoiceMaterials"
import "@/pages/voice-materials/hooks/useVoiceMaterials/useVoiceMaterialActions" import "@/pages/voice-materials/hooks/useVoiceMaterials/useVoiceMaterialActions"
import "@/pages/voice-materials/hooks/useVoiceMaterials/actions/useVoiceUpload"
import "@/pages/voice-materials/hooks/useVoiceMaterials/actions/useVoiceEdit"
import "@/pages/voice-materials/hooks/useVoiceMaterials/actions/useVoiceDelete"
import "@/pages/voice-materials/hooks/useTtsSynthesize" import "@/pages/voice-materials/hooks/useTtsSynthesize"
import "@/pages/voice-materials/hooks/useAudioPlayer" import "@/pages/voice-materials/hooks/useAudioPlayer"
import "@/pages/voice-materials/hooks/useBatchOperations" import "@/pages/voice-materials/hooks/useBatchOperations"
+1 -2
View File
@@ -8,11 +8,10 @@ packages / DB 等重依赖。
""" """
# 共享工具模块(零外部依赖,供 editing_modes / generation / edit_plan_generation 等复用) # 共享工具模块(零外部依赖,供 editing_modes / generation / edit_plan_generation 等复用)
from . import dedup_helpers, ffmpeg_utils, oss_helpers, url_security from . import dedup_helpers, ffmpeg_utils, oss_helpers
__all__ = [ __all__ = [
"ffmpeg_utils", "ffmpeg_utils",
"oss_helpers", "oss_helpers",
"dedup_helpers", "dedup_helpers",
"url_security",
] ]
@@ -6,6 +6,8 @@
from __future__ import annotations from __future__ import annotations
from typing import Any, Optional
# ── 单轨时间计算 ────────────────────────────────────────────────────────────── # ── 单轨时间计算 ──────────────────────────────────────────────────────────────
@@ -259,6 +261,7 @@ def db_to_linear(db: float) -> float:
Returns: Returns:
线性音量值 线性音量值
""" """
import math
return 10 ** (db / 20.0) return 10 ** (db / 20.0)
@@ -1,485 +0,0 @@
"""画中画(PiP)引擎纯逻辑模块.
从 pip_engine.py 抽离的纯函数,0 FFmpeg 依赖,可完全单测。
原模块 pip_engine.py 保持不变,向后兼容。
抽离范围:
- 滤镜链构建(scale / 圆角 / 边框 / 透明度 / 动画 / overlay
- 位置与尺寸计算辅助(封装 domain 层调用)
- 完整 PiP 滤镜链编排
- 配置验证与降级策略判断
"""
from __future__ import annotations
import logging
from pathlib import Path
from packages.domain.pip_config import (
ANIMATION_FADE,
ANIMATION_SLIDE_BOTTOM,
ANIMATION_SLIDE_LEFT,
ANIMATION_SLIDE_RIGHT,
ANIMATION_SLIDE_TOP,
PiPLayerConfig,
calculate_pip_position,
parse_size_value,
)
logger = logging.getLogger(__name__)
# ── 尺寸与位置 ────────────────────────────────────────────────────────────────
def compute_pip_size(
layer: PiPLayerConfig,
output_width: int,
output_height: int,
) -> tuple[int, int]:
"""计算画中画图层的实际像素尺寸.
Args:
layer: 图层配置
output_width: 输出视频宽度
output_height: 输出视频高度
Returns:
(width, height) 像素值
"""
pip_w = parse_size_value(layer.width, output_width)
if layer.height:
pip_h = parse_size_value(layer.height, output_height)
else:
# 按宽度等比例(默认 16:9
pip_h = int(pip_w * 9 / 16)
# 钳制到输出尺寸内
pip_w = max(1, min(pip_w, output_width))
pip_h = max(1, min(pip_h, output_height))
return pip_w, pip_h
def compute_pip_position(
layer: PiPLayerConfig,
pip_width: int,
pip_height: int,
output_width: int,
output_height: int,
) -> tuple[int, int]:
"""计算画中画的实际位置 (x, y).
封装 domain 层的 calculate_pip_position
提供默认值并做边界钳制。
"""
x, y = calculate_pip_position(
position=layer.position,
output_width=output_width,
output_height=output_height,
pip_width=pip_width,
pip_height=pip_height,
margin=layer.margin,
custom_x=layer.x,
custom_y=layer.y,
)
# 边界钳制:确保不超出画面
x = max(0, min(x, output_width - pip_width))
y = max(0, min(y, output_height - pip_height))
return x, y
# ── 预处理滤镜 ────────────────────────────────────────────────────────────────
def build_pip_pre_filter(
input_label: str,
layer: PiPLayerConfig,
pip_width: int,
pip_height: int,
output_label: str,
) -> str:
"""构建单个 PiP 图层的预处理滤镜链.
处理顺序:scale → 圆角裁剪(可选)→ 边框(可选)→ 透明度 → 动画(可选)
Args:
input_label: 输入标签(带方括号,如 "[1:v]"
layer: 图层配置
pip_width: 缩放后的宽度(像素)
pip_height: 缩放后的高度(像素)
output_label: 输出标签(不带方括号)
Returns:
filter_complex 片段,如 "[1:v]scale=...,setsar=1[pip_pre_0]"
"""
filters: list[str] = []
# Step 1: scale + SAR
filters.append(f"scale={pip_width}:{pip_height}")
filters.append("setsar=1")
# Step 2: 圆角裁剪
if layer.corner_radius > 0:
r = min(layer.corner_radius, pip_width // 2, pip_height // 2)
# 用 geq + 圆形遮罩实现四角圆角
filters.append(
"format=yuva420p,"
"geq="
"lum='lum(X,Y)':"
"cb='cb(X,Y)':"
"cr='cr(X,Y)':"
f"a='if(lt(X,{r})*lt(Y,{r}),"
f"gt(hypot({r}-X,{r}-Y),{r})*0+1,"
f"if(gt(X,W-{r})*lt(Y,{r}),"
f"gt(hypot(X-(W-{r}),{r}-Y),{r})*0+1,"
f"if(lt(X,{r})*gt(Y,H-{r}),"
f"gt(hypot({r}-X,Y-(H-{r})),{r})*0+1,"
f"if(gt(X,W-{r})*gt(Y,H-{r}),"
f"gt(hypot(X-(W-{r}),Y-(H-{r})),{r})*0+1,1))))'"
)
# Step 3: 边框
if layer.border_width > 0:
bw = layer.border_width
color = layer.border_color
filters.append(f"pad={pip_width + 2 * bw}:{pip_height + 2 * bw}:{bw}:{bw}:{color}")
# Step 4: 透明度
if layer.opacity < 1.0:
alpha = max(0.0, min(1.0, layer.opacity))
filters.append(f"format=yuva420p,colorchannelmixer=aa={alpha}")
# Step 5: 入场出场动画(fade 类直接在预处理中加)
anim_filters = build_animation_filters(layer, pip_width, pip_height)
if anim_filters:
filters.extend(anim_filters)
return f"{input_label}{','.join(filters)}[{output_label}]"
def build_animation_filters(
layer: PiPLayerConfig,
pip_width: int,
pip_height: int,
) -> list[str]:
"""构建 fade 类入场出场动画滤镜.
注意:slide 类动画由 overlay 表达式处理,不在此函数内。
Returns:
滤镜字符串列表(每项是一个完整 filter,可直接用逗号连接)
"""
filters: list[str] = []
anim_dur = max(0.0, layer.animation_duration)
# 入场动画
if layer.animation_in == ANIMATION_FADE and anim_dur > 0:
filters.append(f"fade=t=in:st=0:d={anim_dur}:alpha=1")
# 出场动画(需要总时长)
if layer.animation_out == ANIMATION_FADE and anim_dur > 0 and layer.duration is not None and layer.duration > 0:
start_fade = max(0.0, layer.duration - anim_dur)
filters.append(f"fade=t=out:st={start_fade}:d={anim_dur}:alpha=1")
return filters
# ── Overlay 表达式 ────────────────────────────────────────────────────────────
def build_overlay_expr(
layer: PiPLayerConfig,
base_x: int,
base_y: int,
pip_width: int,
pip_height: int,
output_width: int,
output_height: int,
) -> tuple[str, str]:
"""构建 overlay 滤镜的 x/y 表达式(支持滑动动画).
Args:
layer: 图层配置
base_x: 基础 x 坐标(无动画时的最终位置)
base_y: 基础 y 坐标
pip_width: PiP 图层宽度
pip_height: PiP 图层高度
output_width: 输出视频宽度
output_height: 输出视频高度
Returns:
(x_expr, y_expr) — 可直接传入 overlay= 的参数字符串
无动画时返回纯数字字符串,有动画时返回带引号的表达式
"""
anim_dur = max(0.0, layer.animation_duration)
x_expr = str(base_x)
y_expr = str(base_y)
# ── 入场滑入动画 ──
if anim_dur > 0:
if layer.animation_in == ANIMATION_SLIDE_LEFT:
# 从左侧滑入:x 从 -pip_width 变化到 base_x
x_expr = (
f"'{base_x}+if(lt(t,{anim_dur})," f"{-pip_width}+t/{anim_dur}*({base_x + pip_width})," f"{base_x})'"
)
elif layer.animation_in == ANIMATION_SLIDE_RIGHT:
# 从右侧滑入:x 从 output_width 变化到 base_x
x_expr = (
f"'{base_x}+if(lt(t,{anim_dur}),"
f"{output_width}-t/{anim_dur}*({output_width - base_x}),"
f"{base_x})'"
)
elif layer.animation_in == ANIMATION_SLIDE_TOP:
# 从顶部滑入
y_expr = (
f"'{base_y}+if(lt(t,{anim_dur})," f"{-pip_height}+t/{anim_dur}*({base_y + pip_height})," f"{base_y})'"
)
elif layer.animation_in == ANIMATION_SLIDE_BOTTOM:
# 从底部滑入
y_expr = (
f"'{base_y}+if(lt(t,{anim_dur}),"
f"{output_height}-t/{anim_dur}*({output_height - base_y}),"
f"{base_y})'"
)
# ── 出场滑出动画(需要总时长) ──
if layer.duration is not None and layer.duration > 0 and anim_dur > 0:
out_start = layer.duration - anim_dur
if out_start < 0:
out_start = 0
if layer.animation_out == ANIMATION_SLIDE_LEFT:
# 向左滑出
x_expr = (
f"'{base_x}+if(gt(t,{out_start}),"
f"{base_x}-(t-{out_start})/{anim_dur}*({base_x + pip_width}),"
f"{base_x})'"
)
elif layer.animation_out == ANIMATION_SLIDE_RIGHT:
# 向右滑出
x_expr = (
f"'{base_x}+if(gt(t,{out_start}),"
f"{base_x}+(t-{out_start})/{anim_dur}*({output_width - base_x + pip_width}),"
f"{base_x})'"
)
elif layer.animation_out == ANIMATION_SLIDE_TOP:
# 向上滑出
y_expr = (
f"'{base_y}+if(gt(t,{out_start}),"
f"{base_y}-(t-{out_start})/{anim_dur}*({base_y + pip_height}),"
f"{base_y})'"
)
elif layer.animation_out == ANIMATION_SLIDE_BOTTOM:
# 向下滑出
y_expr = (
f"'{base_y}+if(gt(t,{out_start}),"
f"{base_y}+(t-{out_start})/{anim_dur}*({output_height - base_y + pip_height}),"
f"{base_y})'"
)
return x_expr, y_expr
def build_enable_expr(
layer: PiPLayerConfig,
) -> str:
"""构建 overlay 的 enable 时间控制表达式.
Returns:
enable 表达式片段,如 ":enable='between(t,1,5)'"
无时间限制时返回空字符串
"""
start = max(0.0, layer.start_time)
duration = layer.duration
if start <= 0 and (duration is None or duration <= 0):
return ""
if duration and duration > 0:
end = start + duration
return f":enable='between(t,{start},{end})'"
else:
return f":enable='gte(t,{start})'"
# ── 完整滤镜链 ────────────────────────────────────────────────────────────────
def build_pip_filters(
base_label: str,
layers: list[PiPLayerConfig],
source_paths: list[Path | str],
*,
output_width: int,
output_height: int,
base_input_idx: int = 0,
) -> tuple[list[str], list[str], str]:
"""构建完整的画中画滤镜链和输入参数(纯函数版).
与 PiPEngine.build_pip_filters 对应,但不依赖类实例,
所有参数显式传入,方便测试。
Args:
base_label: 底层视频标签(不带方括号)
layers: 图层配置列表
source_paths: 对应每个图层的源文件路径列表
output_width: 输出视频宽度
output_height: 输出视频高度
base_input_idx: PiP 素材的起始输入索引
Returns:
(filter_parts, input_args, final_label)
- filter_parts: 滤镜片段列表(用 ; 连接成 filter_complex
- input_args: 输入参数列表 ["-i", path, "-i", path, ...]
- final_label: 最终输出标签(不带方括号)
Raises:
ValueError: layers 和 source_paths 长度不一致
"""
if len(layers) != len(source_paths):
raise ValueError(f"layers ({len(layers)}) 和 source_paths ({len(source_paths)}) 长度不一致")
if not layers:
return [], [], base_label
filter_parts: list[str] = []
input_args: list[str] = []
current_label = base_label
for i, (layer, path) in enumerate(zip(layers, source_paths, strict=False)):
# 计算实际大小
pip_w, pip_h = compute_pip_size(layer, output_width, output_height)
# 添加输入
input_args.extend(["-i", str(path)])
# 实际输入索引
actual_input_idx = base_input_idx + i
# 预处理标签
pre_label = f"pip_pre_{i}"
# 构建预处理滤镜
pre_filter = build_pip_pre_filter(
input_label=f"[{actual_input_idx}:v]",
layer=layer,
pip_width=pip_w,
pip_height=pip_h,
output_label=pre_label,
)
filter_parts.append(pre_filter)
# 计算位置
base_x, base_y = compute_pip_position(layer, pip_w, pip_h, output_width, output_height)
# 构建 overlay 表达式
x_expr, y_expr = build_overlay_expr(layer, base_x, base_y, pip_w, pip_h, output_width, output_height)
# 时间控制
enable_expr = build_enable_expr(layer)
# 合成标签
combined_label = f"pip_combined_{i}"
# overlay 滤镜
overlay_filter = (
f"[{current_label}][{pre_label}]" f"overlay={x_expr}:{y_expr}{enable_expr}" f"[{combined_label}]"
)
filter_parts.append(overlay_filter)
current_label = combined_label
return filter_parts, input_args, current_label
# ── 配置验证 ──────────────────────────────────────────────────────────────────
def validate_pip_layer(layer: PiPLayerConfig) -> tuple[bool, str]:
"""验证单个 PiP 图层配置是否合法.
Returns:
(is_valid, error_message) — 合法时 error_message 为空
"""
errors: list[str] = []
# 源类型检查
if not layer.source_type:
errors.append("source_type 不能为空")
elif layer.source_type not in ("local_path", "asset_id", "url"):
errors.append(f"不支持的 source_type: {layer.source_type}")
if not layer.source:
errors.append("source 不能为空")
# 尺寸检查
if layer.width is None or layer.width == "":
errors.append("width 不能为空")
# 位置检查
valid_positions = {
"top_left",
"top_center",
"top_right",
"center_left",
"center",
"center_right",
"bottom_left",
"bottom_center",
"bottom_right",
"custom",
}
if layer.position not in valid_positions:
errors.append(f"不支持的 position: {layer.position}")
# 数值范围检查
if layer.opacity < 0.0 or layer.opacity > 1.0:
errors.append(f"opacity 必须在 0-1 之间: {layer.opacity}")
if layer.corner_radius < 0:
errors.append(f"corner_radius 不能为负: {layer.corner_radius}")
if layer.border_width < 0:
errors.append(f"border_width 不能为负: {layer.border_width}")
if layer.animation_duration < 0:
errors.append(f"animation_duration 不能为负: {layer.animation_duration}")
if layer.start_time < 0:
errors.append(f"start_time 不能为负: {layer.start_time}")
if layer.duration is not None and layer.duration < 0:
errors.append(f"duration 不能为负: {layer.duration}")
# 动画类型检查
valid_anims = {
"",
None,
ANIMATION_FADE,
ANIMATION_SLIDE_LEFT,
ANIMATION_SLIDE_RIGHT,
ANIMATION_SLIDE_TOP,
ANIMATION_SLIDE_BOTTOM,
}
if layer.animation_in and layer.animation_in not in valid_anims:
errors.append(f"不支持的 animation_in: {layer.animation_in}")
if layer.animation_out and layer.animation_out not in valid_anims:
errors.append(f"不支持的 animation_out: {layer.animation_out}")
return (len(errors) == 0, "; ".join(errors))
def count_visible_layers(layers: list[PiPLayerConfig]) -> int:
"""统计可见图层数量(排除完全透明的)."""
count = 0
for layer in layers:
if layer.opacity > 0:
count += 1
return count
def sort_layers_by_z_index(layers: list[PiPLayerConfig]) -> list[PiPLayerConfig]:
"""按 z_index 从小到大排序图层(z_index 小的先画,在底层)."""
return sorted(layers, key=lambda layer: layer.z_index)
+4 -34
View File
@@ -13,10 +13,12 @@
from __future__ import annotations from __future__ import annotations
from packages.domain.speed_config import MAX_SPEED # noqa: F401 — 向后兼容 from typing import Optional
from packages.domain.speed_config import MIN_SPEED # noqa: F401 — 向后兼容
from packages.domain.speed_config import ( from packages.domain.speed_config import (
DEFAULT_SPEED, DEFAULT_SPEED,
MAX_SPEED,
MIN_SPEED,
SpeedConfig, SpeedConfig,
_split_atempo_stages, _split_atempo_stages,
) )
@@ -70,35 +72,3 @@ class SpeedEngine:
) -> float: ) -> float:
"""从 clip config 中解析 playback_speed0 或缺失则使用全局速度.""" """从 clip config 中解析 playback_speed0 或缺失则使用全局速度."""
return _resolve_clip_speed_base(clip_config, global_speed) return _resolve_clip_speed_base(clip_config, global_speed)
# ── 向后兼容:模块级函数(重构前的 API) ─────────────────────────
def build_video_filter(config):
"""向后兼容:模块级 build_video_filter."""
return _build_video_filter_base(config)
def build_audio_filter(config):
"""向后兼容:模块级 build_audio_filter."""
return _build_audio_filter_base(config)
def adjust_duration(original_duration, config):
"""向后兼容:模块级 adjust_duration."""
return _adjust_duration_base(original_duration, config)
def resolve_clip_speed(clip_config, global_speed=DEFAULT_SPEED):
"""向后兼容:模块级 resolve_clip_speed."""
return _resolve_clip_speed_base(clip_config, global_speed)
def build_clip_speed_filter(speed, pitch_correct=True):
"""向后兼容:模块级 build_clip_speed_filter."""
config = SpeedConfig(speed=speed, pitch_correct=pitch_correct)
config.clamp()
return (
build_video_filter(config),
build_audio_filter(config),
config,
)
+2 -3
View File
@@ -8,10 +8,9 @@ from packages.adapters.sqlalchemy_impl import (
from packages.adapters.sqlalchemy_impl.schema_guard import assert_auto_create_schema_allowed from packages.adapters.sqlalchemy_impl.schema_guard import assert_auto_create_schema_allowed
settings = get_settings() settings = get_settings()
_db_url = settings.effective_database_url ensure_database_exists(settings.database_url)
ensure_database_exists(_db_url)
engine, SessionLocal = build_session_factory( engine, SessionLocal = build_session_factory(
_db_url, settings.database_url,
pool_size=settings.database_pool_size, pool_size=settings.database_pool_size,
max_overflow=settings.database_max_overflow, max_overflow=settings.database_max_overflow,
pool_timeout=settings.database_pool_timeout, pool_timeout=settings.database_pool_timeout,
@@ -48,20 +48,12 @@ def build_session_factory(
return engine, session_factory return engine, session_factory
def _is_sqlite(database_url: str) -> bool:
"""检测是否为 SQLite 数据库 URL."""
return database_url.startswith("sqlite")
def _build_admin_url(database_url: str) -> URL: def _build_admin_url(database_url: str) -> URL:
url = make_url(database_url) url = make_url(database_url)
return url.set(database="postgres") return url.set(database="postgres")
def ensure_database_exists(database_url: str) -> None: def ensure_database_exists(database_url: str) -> None:
"""确保数据库存在(仅 PostgreSQL 需要,SQLite 自动创建)."""
if _is_sqlite(database_url):
return
target_url = make_url(database_url) target_url = make_url(database_url)
admin_engine = create_engine(_build_admin_url(database_url), isolation_level="AUTOCOMMIT") admin_engine = create_engine(_build_admin_url(database_url), isolation_level="AUTOCOMMIT")
try: try:
@@ -78,14 +70,6 @@ def ensure_database_exists(database_url: str) -> None:
def initialize_database(engine) -> None: def initialize_database(engine) -> None:
"""初始化数据库 schema。
PostgreSQL 使用 advisory lock 防止并发初始化冲突;
SQLite 直接 create_all(单文件,无并发风险)。
"""
if _is_sqlite(str(engine.url)):
Base.metadata.create_all(bind=engine)
return
with engine.connect() as connection: with engine.connect() as connection:
connection.execute(text("SELECT pg_advisory_lock(:lock_id)"), {"lock_id": SCHEMA_INIT_LOCK_ID}) connection.execute(text("SELECT pg_advisory_lock(:lock_id)"), {"lock_id": SCHEMA_INIT_LOCK_ID})
try: try:
-13
View File
@@ -34,9 +34,6 @@ class SharedSettings(BaseSettings):
database_pool_timeout: int = 30 database_pool_timeout: int = 30
database_pool_recycle: int = 3600 database_pool_recycle: int = 3600
# 测试用:使用 SQLite 内存数据库(CI 环境无需 PostgreSQL
use_in_memory_db: bool = False
# ── Redis ──────────────────────────────────────────────────────────── # ── Redis ────────────────────────────────────────────────────────────
redis_url: str = "redis://localhost:6379/0" redis_url: str = "redis://localhost:6379/0"
@@ -69,16 +66,6 @@ class SharedSettings(BaseSettings):
doubao_timeout: int = 30 doubao_timeout: int = 30
doubao_max_retries: int = 2 doubao_max_retries: int = 2
@property
def effective_database_url(self) -> str:
"""返回实际使用的数据库 URL。
当 USE_IN_MEMORY_DB=True 时返回 SQLite 内存 URL,否则返回 database_url。
"""
if self.use_in_memory_db:
return "sqlite:///./test.db"
return self.database_url
model_config = SettingsConfigDict( model_config = SettingsConfigDict(
env_file=".env", env_file=".env",
env_file_encoding="utf-8", env_file_encoding="utf-8",
+10 -23
View File
@@ -22,12 +22,17 @@ import urllib.error
import urllib.request import urllib.request
from urllib.parse import urljoin from urllib.parse import urljoin
from packages.domain.url_security import ALLOWED_AUDIO_MIME_TYPES as _allowed_audio_base from packages.domain.url_security import (
from packages.domain.url_security import ALLOWED_IMAGE_MIME_TYPES as _allowed_image_base ALLOWED_AUDIO_MIME_TYPES,
ALLOWED_IMAGE_MIME_TYPES,
)
from packages.domain.url_security import ALLOWED_PORTS as _allowed_ports_base from packages.domain.url_security import ALLOWED_PORTS as _allowed_ports_base
from packages.domain.url_security import ALLOWED_SCHEMES as _allowed_schemes_base from packages.domain.url_security import ALLOWED_SCHEMES as _allowed_schemes_base
from packages.domain.url_security import ALLOWED_VIDEO_MIME_TYPES as _allowed_video_base from packages.domain.url_security import (
from packages.domain.url_security import MAX_URL_LENGTH as _max_url_length_base ALLOWED_VIDEO_MIME_TYPES,
MAGIC_NUMBERS,
MAX_URL_LENGTH,
)
from packages.domain.url_security import UrlSecurityError as _UrlSecurityError_base from packages.domain.url_security import UrlSecurityError as _UrlSecurityError_base
from packages.domain.url_security import check_internal_hostname as _check_internal_hostname_base from packages.domain.url_security import check_internal_hostname as _check_internal_hostname_base
from packages.domain.url_security import check_ssrf_ip as _check_ssrf_ip_base from packages.domain.url_security import check_ssrf_ip as _check_ssrf_ip_base
@@ -41,22 +46,8 @@ logger = logging.getLogger(__name__)
# ── 兼容导出(保持原有变量名供外部引用) ────────────────────────────────── # ── 兼容导出(保持原有变量名供外部引用) ──────────────────────────────────
ALLOWED_SCHEMES = set(_allowed_schemes_base) ALLOWED_SCHEMES = set(_allowed_schemes_base)
ALLOWED_PORTS = set(_allowed_ports_base) ALLOWED_PORTS = set(_allowed_ports_base)
ALLOWED_AUDIO_MIME_TYPES = set(_allowed_audio_base)
ALLOWED_IMAGE_MIME_TYPES = set(_allowed_image_base)
ALLOWED_VIDEO_MIME_TYPES = set(_allowed_video_base)
MAX_URL_LENGTH = _max_url_length_base
UrlSecurityError = _UrlSecurityError_base UrlSecurityError = _UrlSecurityError_base
# 私有别名(供测试和内部引用)
_check_internal_hostnames = _check_internal_hostname_base
_check_ssrf_ip = _check_ssrf_ip_base
def _is_trusted_domain(hostname: str) -> bool:
"""便捷包装:使用模块级 TRUSTED_DOMAINS 做可信域名检查."""
return _is_trusted_domain_base(hostname, TRUSTED_DOMAINS)
# 可信域名白名单(从环境变量读取) # 可信域名白名单(从环境变量读取)
TRUSTED_DOMAINS: set[str] = set() TRUSTED_DOMAINS: set[str] = set()
_env_trusted = os.environ.get("URL_SECURITY_TRUSTED_DOMAINS", "") _env_trusted = os.environ.get("URL_SECURITY_TRUSTED_DOMAINS", "")
@@ -148,11 +139,7 @@ def validate_url_safety(url: str, *, purpose: str = "download") -> str:
parsed = urlparse(url) parsed = urlparse(url)
hostname = parsed.hostname hostname = parsed.hostname
if hostname and _is_ip_address_base(hostname): if hostname and not _is_ip_address_base(hostname):
# IP 直接访问:通过本地别名调用以便 mock
if ALLOW_DIRECT_IP:
_check_ssrf_ip(hostname)
elif hostname:
try: try:
_check_ssrf_domain(hostname) _check_ssrf_domain(hostname)
except UrlSecurityError: except UrlSecurityError:
-2
View File
@@ -102,5 +102,3 @@ ignore = [
"apps/api/app/middleware/auth.py" = ["ALL"] "apps/api/app/middleware/auth.py" = ["ALL"]
"apps/*/migrations/*" = ["ALL"] "apps/*/migrations/*" = ["ALL"]
"alembic/*" = ["ALL"] "alembic/*" = ["ALL"]
"tests/**" = ["B011"]
-2
View File
@@ -1,8 +1,6 @@
[pytest] [pytest]
pythonpath = . apps/api apps/worker packages pythonpath = . apps/api apps/worker packages
testpaths = tests testpaths = tests
# importlib 模式避免同名测试文件的模块名冲突
addopts = --import-mode=importlib
# ===== 覆盖率配置 ===== # ===== 覆盖率配置 =====
# 覆盖率统计范围(供 --cov 使用时的默认源) # 覆盖率统计范围(供 --cov 使用时的默认源)
+3 -26
View File
@@ -303,7 +303,7 @@ def is_in_protected_list(tag, protected_set):
# ========== 核心清理逻辑 ========== # ========== 核心清理逻辑 ==========
def cleanup_repo(repo, keep_count, dry_run, protected_tags, pr_sha=None, pr_open_set=None, pr_days=0): def cleanup_repo(repo, keep_count, dry_run, protected_tags, pr_sha=None, pr_open_set=None):
""" """
清理单个仓库 清理单个仓库
@@ -441,28 +441,8 @@ def cleanup_repo(repo, keep_count, dry_run, protected_tags, pr_sha=None, pr_open
deleted_count += 1 deleted_count += 1
print(f" 打开PR数: {len(open_head_shas)}个head sha") print(f" 打开PR数: {len(open_head_shas)}个head sha")
print(f" 将删除PR镜像: {deleted_count}") print(f" 将删除PR镜像: {deleted_count}")
# pr-days兜底:超过指定天数的打开PR镜像也清理
if pr_days > 0:
cutoff = datetime.now(timezone.utc) - timedelta(days=pr_days)
extra_old = []
for tag in pr_tags_list:
sha = extract_sha_from_pr_tag(tag)
is_open_pr = False
for ohs in open_head_shas:
if sha.startswith(ohs) or ohs.startswith(sha):
is_open_pr = True
break
if is_open_pr:
info = get_manifest_info(repo, tag, token_pull)
created = parse_time(info["created"])
if created < cutoff and info["digest"]:
extra_old.append({"tag": tag, "digest": info["digest"], "created": info["created"]})
if extra_old:
pr_to_delete.extend(extra_old)
print(f" pr-days兜底: 额外清理{len(extra_old)}个超期打开PR镜像(>{pr_days}天)")
else: else:
# 无Gitea token,降级为按pr_days天保留(默认7天) # 无Gitea token,降级为按7天保留
print(" 模式: 按时间保留7天(无Gitea token降级)") print(" 模式: 按时间保留7天(无Gitea token降级)")
cutoff = datetime.now(timezone.utc) - timedelta(days=7) cutoff = datetime.now(timezone.utc) - timedelta(days=7)
for tag in pr_tags_list: for tag in pr_tags_list:
@@ -581,9 +561,6 @@ def main():
parser.add_argument("--pr-sha", type=str, default="", help="PR关闭模式:删除指定commit sha的PR镜像") parser.add_argument("--pr-sha", type=str, default="", help="PR关闭模式:删除指定commit sha的PR镜像")
parser.add_argument("--protected-tags", type=str, default="", help="受保护tag列表,逗号分隔(运行中镜像白名单)") parser.add_argument("--protected-tags", type=str, default="", help="受保护tag列表,逗号分隔(运行中镜像白名单)")
parser.add_argument("--skip-pr-check", action="store_true", help="跳过Gitea PR状态检查(纯按时间清理PR镜像)") parser.add_argument("--skip-pr-check", action="store_true", help="跳过Gitea PR状态检查(纯按时间清理PR镜像)")
parser.add_argument(
"--pr-days", type=int, default=0, help="PR镜像保留天数(超过天数的PR镜像会被清理,0表示不按天数清理)"
)
args = parser.parse_args() args = parser.parse_args()
# 必须指定 --dry-run 或 --execute # 必须指定 --dry-run 或 --execute
@@ -656,7 +633,7 @@ def main():
total_tags = 0 total_tags = 0
for repo in repos_to_clean: for repo in repos_to_clean:
count, deleted = cleanup_repo( count, deleted = cleanup_repo(
repo, args.keep, dry_run, protected_tags, pr_sha=args.pr_sha, pr_open_set=pr_open_set, pr_days=args.pr_days repo, args.keep, dry_run, protected_tags, pr_sha=args.pr_sha, pr_open_set=pr_open_set
) )
total_tags += count total_tags += count
total_deleted += deleted total_deleted += deleted

Some files were not shown because too many files have changed in this diff Show More