Compare commits

..

3 Commits

Author SHA1 Message Date
xiaoxia 2023ddc8cf fix: 恢复 LayerConfig.tsx 主入口,re-export 子目录保持路径兼容
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 6s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m8s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m2s
CI/CD Pipeline / Validate - Code Quality (pull_request) Failing after 1m45s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 44s
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 / PR Build Worker Image (pull_request) Successful in 35s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m49s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 51s
AI Code Review / AI Code Review (pull_request) Successful in 1m28s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 48s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 3m46s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 15m15s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Has been cancelled
CI/CD Pipeline / 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 / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 25s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 20s
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 / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
2026-07-27 12:41:35 +08:00
xiaoxia 7ea0a1397e Merge branch 'develop' into refactor/layer-config-split
CI/CD Pipeline / Check if frontend-only change (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Lint (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / PR Build API Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Web Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
AI Code Review / AI Code Review (pull_request) Has been cancelled
Preview Deploy / Deploy Preview Environment (pull_request) Has been cancelled
PR Automation / Auto Approve on CI Green (pull_request) Successful in 1m3s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 58s
2026-07-27 12:34:17 +08:00
xiaoxia 9f61f76019 refactor(editing-planner): 拆分 LayerConfig 组件,按职责拆为 3 个子面板
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 28s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m2s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m0s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 40s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Code Quality (pull_request) Failing after 1m47s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 48s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 1m12s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m19s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 53s
AI Code Review / AI Code Review (pull_request) Successful in 1m42s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 2m41s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 8m5s
CI/CD Pipeline / 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 / Frontend Unit Tests (pull_request) Failing after 42s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
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 / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
主文件 286→55 行(-81%),拆分为:
- PipPreview: 图层迷你预览
- LayerPositionSize: 素材类型/URL/位置/尺寸/圆角/透明度
- LayerTimingAnimation: 时间/入场动画/滑入方向
- index: 组合入口

导入路径保持向后兼容
2026-07-27 09:17:16 +08:00
11 changed files with 646 additions and 713 deletions
+3 -284
View File
@@ -1,286 +1,5 @@
/**
* 混剪单图层配置区
* LayerConfig 入口(向后兼容)
* 实际实现位于 ./layer-config/ 目录
*/
import React from "react"
import type {
PipLayer,
PipAnimType,
PipSlideDirection,
PipGridPosition,
} from "@/pages/editing-planner/types"
import {
GRID_POSITIONS,
ANIM_OPTIONS,
SLIDE_DIR_OPTIONS,
LAYER_COLORS,
} from "@/pages/editing-planner/constants/pipConfig"
interface LayerConfigProps {
layer: PipLayer | null
layers: PipLayer[]
totalDuration: number
onUpdate: (id: string, partial: Partial<PipLayer>) => void
onGridClick: (pos: PipGridPosition) => void
onWidthChange: (val: number) => void
onHeightChange: (val: number) => void
}
const LayerConfig: React.FC<LayerConfigProps> = ({
layer,
layers,
totalDuration,
onUpdate,
onGridClick,
onWidthChange,
onHeightChange,
}) => {
if (!layer) {
return (
<div className="pip-config-area">
<div className="pip-config-empty"></div>
</div>
)
}
return (
<div className="pip-config-area">
{/* ── 迷你预览 ── */}
<div className="pip-preview-box">
{layers.map((l, idx) => (
<div
key={l.id}
className={`pip-preview-layer${layer.id === l.id ? " selected" : ""}`}
style={{
left: `${l.x}%`,
top: `${l.y}%`,
width: `${l.width}%`,
height: `${l.height}%`,
background: LAYER_COLORS[idx % LAYER_COLORS.length],
opacity: l.opacity / 100,
borderRadius: `${l.border_radius}%`,
}}
>
<span className="pip-preview-label">{l.name}</span>
</div>
))}
</div>
{/* ── 素材类型 ── */}
<div className="pip-field">
<label className="pip-field-label"></label>
<div className="pip-type-btns">
<button
className={`pip-type-btn${layer.material_type === "image" ? " active" : ""}`}
onClick={() => onUpdate(layer.id, { material_type: "image" })}
>
🖼
</button>
<button
className={`pip-type-btn${layer.material_type === "video" ? " active" : ""}`}
onClick={() => onUpdate(layer.id, { material_type: "video" })}
>
🎬
</button>
</div>
</div>
{/* ── 素材 URL ── */}
<div className="pip-field">
<label className="pip-field-label">
{layer.material_type === "image" ? "图片" : "视频"} URL
</label>
<input
className="pip-input"
type="text"
placeholder={
layer.material_type === "image"
? "https://example.com/image.png"
: "https://example.com/video.mp4"
}
value={layer.material_url}
onChange={(e) => onUpdate(layer.id, { material_url: e.target.value })}
/>
</div>
{/* ── 位置:九宫格 + 坐标 ── */}
<div className="pip-field">
<label className="pip-field-label"></label>
<div style={{ display: "flex", gap: 16, alignItems: "flex-start" }}>
<div className="pip-grid">
{GRID_POSITIONS.map((pos) => (
<button
key={pos}
className={`pip-grid-btn${layer.grid_position === pos ? " active" : ""}`}
onClick={() => onGridClick(pos)}
>
<span className="pip-grid-dot" />
</button>
))}
</div>
<div className="pip-field-row" style={{ flex: 1 }}>
<div>
<label className="pip-field-label">X (%)</label>
<input
className="pip-number"
type="number"
min={0}
max={100}
value={layer.x}
onChange={(e) => onUpdate(layer.id, { x: Number(e.target.value) })}
/>
</div>
<div>
<label className="pip-field-label">Y (%)</label>
<input
className="pip-number"
type="number"
min={0}
max={100}
value={layer.y}
onChange={(e) => onUpdate(layer.id, { y: Number(e.target.value) })}
/>
</div>
</div>
</div>
</div>
{/* ── 尺寸 ── */}
<div className="pip-field">
<label className="pip-field-label"></label>
<div className="pip-slider-row">
<span style={{ fontSize: 12, color: "#999", width: 20 }}></span>
<input
className="pip-slider"
type="range"
min={10}
max={80}
value={layer.width}
onChange={(e) => onWidthChange(Number(e.target.value))}
/>
<span className="pip-slider-value">{layer.width}%</span>
</div>
<div className="pip-slider-row" style={{ marginTop: 6 }}>
<span style={{ fontSize: 12, color: "#999", width: 20 }}></span>
<input
className="pip-slider"
type="range"
min={10}
max={80}
value={layer.height}
onChange={(e) => onHeightChange(Number(e.target.value))}
/>
<span className="pip-slider-value">{layer.height}%</span>
</div>
<div
className="pip-lock-row"
style={{ marginTop: 6 }}
onClick={() => onUpdate(layer.id, { aspect_lock: !layer.aspect_lock })}
>
<span className="pip-lock-icon">{layer.aspect_lock ? "🔒" : "🔓"}</span>
<span>{layer.aspect_lock ? "已锁定比例" : "锁定宽高比"}</span>
</div>
</div>
{/* ── 圆角 ── */}
<div className="pip-field">
<label className="pip-field-label"></label>
<div className="pip-slider-row">
<input
className="pip-slider"
type="range"
min={0}
max={50}
value={layer.border_radius}
onChange={(e) => onUpdate(layer.id, { border_radius: Number(e.target.value) })}
/>
<span className="pip-slider-value">{layer.border_radius}%</span>
</div>
</div>
{/* ── 透明度 ── */}
<div className="pip-field">
<label className="pip-field-label"></label>
<div className="pip-slider-row">
<input
className="pip-slider"
type="range"
min={0}
max={100}
value={layer.opacity}
onChange={(e) => onUpdate(layer.id, { opacity: Number(e.target.value) })}
/>
<span className="pip-slider-value">{layer.opacity}%</span>
</div>
</div>
{/* ── 时间 ── */}
<div className="pip-field">
<label className="pip-field-label"></label>
<div className="pip-field-row">
<div>
<label className="pip-field-label"> (s)</label>
<input
className="pip-number"
type="number"
min={0}
max={totalDuration || 999}
step={0.1}
value={layer.start_time}
onChange={(e) => onUpdate(layer.id, { start_time: Number(e.target.value) })}
/>
</div>
<div>
<label className="pip-field-label"> (s)</label>
<input
className="pip-number"
type="number"
min={0.1}
max={totalDuration || 999}
step={0.1}
value={layer.duration}
onChange={(e) => onUpdate(layer.id, { duration: Number(e.target.value) })}
/>
</div>
</div>
</div>
{/* ── 入场动画 ── */}
<div className="pip-field">
<label className="pip-field-label"></label>
<select
className="pip-select"
value={layer.animation}
onChange={(e) => onUpdate(layer.id, { animation: e.target.value as PipAnimType })}
>
{ANIM_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
{/* 滑入方向(仅 slide_in 时显示) */}
{layer.animation === "slide_in" && (
<div className="pip-field">
<label className="pip-field-label"></label>
<select
className="pip-select"
value={layer.slide_direction}
onChange={(e) =>
onUpdate(layer.id, { slide_direction: e.target.value as PipSlideDirection })
}
>
{SLIDE_DIR_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
)}
</div>
)
}
export default LayerConfig
export { default } from "./layer-config"
@@ -0,0 +1,172 @@
import React from "react"
import type { PipLayer, PipGridPosition } from "@/pages/editing-planner/types"
import { GRID_POSITIONS } from "@/pages/editing-planner/constants/pipConfig"
interface LayerPositionSizeProps {
layer: PipLayer
onUpdate: (id: string, partial: Partial<PipLayer>) => void
onGridClick: (pos: PipGridPosition) => void
onWidthChange: (val: number) => void
onHeightChange: (val: number) => void
}
/**
* 图层位置与尺寸配置面板
*/
export const LayerPositionSize: React.FC<LayerPositionSizeProps> = ({
layer,
onUpdate,
onGridClick,
onWidthChange,
onHeightChange,
}) => (
<>
{/* 素材类型 */}
<div className="pip-field">
<label className="pip-field-label"></label>
<div className="pip-type-btns">
<button
className={`pip-type-btn${layer.material_type === "image" ? " active" : ""}`}
onClick={() => onUpdate(layer.id, { material_type: "image" })}
>
🖼
</button>
<button
className={`pip-type-btn${layer.material_type === "video" ? " active" : ""}`}
onClick={() => onUpdate(layer.id, { material_type: "video" })}
>
🎬
</button>
</div>
</div>
{/* 素材 URL */}
<div className="pip-field">
<label className="pip-field-label">
{layer.material_type === "image" ? "图片" : "视频"} URL
</label>
<input
className="pip-input"
type="text"
placeholder={
layer.material_type === "image"
? "https://example.com/image.png"
: "https://example.com/video.mp4"
}
value={layer.material_url}
onChange={(e) => onUpdate(layer.id, { material_url: e.target.value })}
/>
</div>
{/* 位置:九宫格 + 坐标 */}
<div className="pip-field">
<label className="pip-field-label"></label>
<div style={{ display: "flex", gap: 16, alignItems: "flex-start" }}>
<div className="pip-grid">
{GRID_POSITIONS.map((pos) => (
<button
key={pos}
className={`pip-grid-btn${layer.grid_position === pos ? " active" : ""}`}
onClick={() => onGridClick(pos)}
>
<span className="pip-grid-dot" />
</button>
))}
</div>
<div className="pip-field-row" style={{ flex: 1 }}>
<div>
<label className="pip-field-label">X (%)</label>
<input
className="pip-number"
type="number"
min={0}
max={100}
value={layer.x}
onChange={(e) => onUpdate(layer.id, { x: Number(e.target.value) })}
/>
</div>
<div>
<label className="pip-field-label">Y (%)</label>
<input
className="pip-number"
type="number"
min={0}
max={100}
value={layer.y}
onChange={(e) => onUpdate(layer.id, { y: Number(e.target.value) })}
/>
</div>
</div>
</div>
</div>
{/* 尺寸 */}
<div className="pip-field">
<label className="pip-field-label"></label>
<div className="pip-slider-row">
<span style={{ fontSize: 12, color: "#999", width: 20 }}></span>
<input
className="pip-slider"
type="range"
min={10}
max={80}
value={layer.width}
onChange={(e) => onWidthChange(Number(e.target.value))}
/>
<span className="pip-slider-value">{layer.width}%</span>
</div>
<div className="pip-slider-row" style={{ marginTop: 6 }}>
<span style={{ fontSize: 12, color: "#999", width: 20 }}></span>
<input
className="pip-slider"
type="range"
min={10}
max={80}
value={layer.height}
onChange={(e) => onHeightChange(Number(e.target.value))}
/>
<span className="pip-slider-value">{layer.height}%</span>
</div>
<div
className="pip-lock-row"
style={{ marginTop: 6 }}
onClick={() => onUpdate(layer.id, { aspect_lock: !layer.aspect_lock })}
>
<span className="pip-lock-icon">{layer.aspect_lock ? "🔒" : "🔓"}</span>
<span>{layer.aspect_lock ? "已锁定比例" : "锁定宽高比"}</span>
</div>
</div>
{/* 圆角 */}
<div className="pip-field">
<label className="pip-field-label"></label>
<div className="pip-slider-row">
<input
className="pip-slider"
type="range"
min={0}
max={50}
value={layer.border_radius}
onChange={(e) => onUpdate(layer.id, { border_radius: Number(e.target.value) })}
/>
<span className="pip-slider-value">{layer.border_radius}%</span>
</div>
</div>
{/* 透明度 */}
<div className="pip-field">
<label className="pip-field-label"></label>
<div className="pip-slider-row">
<input
className="pip-slider"
type="range"
min={0}
max={100}
value={layer.opacity}
onChange={(e) => onUpdate(layer.id, { opacity: Number(e.target.value) })}
/>
<span className="pip-slider-value">{layer.opacity}%</span>
</div>
</div>
</>
)
@@ -0,0 +1,87 @@
import React from "react"
import type { PipLayer, PipAnimType, PipSlideDirection } from "@/pages/editing-planner/types"
import { ANIM_OPTIONS, SLIDE_DIR_OPTIONS } from "@/pages/editing-planner/constants/pipConfig"
interface LayerTimingAnimationProps {
layer: PipLayer
totalDuration: number
onUpdate: (id: string, partial: Partial<PipLayer>) => void
}
/**
* 图层时间与动画配置面板
*/
export const LayerTimingAnimation: React.FC<LayerTimingAnimationProps> = ({
layer,
totalDuration,
onUpdate,
}) => (
<>
{/* 时间 */}
<div className="pip-field">
<label className="pip-field-label"></label>
<div className="pip-field-row">
<div>
<label className="pip-field-label"> (s)</label>
<input
className="pip-number"
type="number"
min={0}
max={totalDuration || 999}
step={0.1}
value={layer.start_time}
onChange={(e) => onUpdate(layer.id, { start_time: Number(e.target.value) })}
/>
</div>
<div>
<label className="pip-field-label"> (s)</label>
<input
className="pip-number"
type="number"
min={0.1}
max={totalDuration || 999}
step={0.1}
value={layer.duration}
onChange={(e) => onUpdate(layer.id, { duration: Number(e.target.value) })}
/>
</div>
</div>
</div>
{/* 入场动画 */}
<div className="pip-field">
<label className="pip-field-label"></label>
<select
className="pip-select"
value={layer.animation}
onChange={(e) => onUpdate(layer.id, { animation: e.target.value as PipAnimType })}
>
{ANIM_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
{/* 滑入方向(仅 slide_in 时显示) */}
{layer.animation === "slide_in" && (
<div className="pip-field">
<label className="pip-field-label"></label>
<select
className="pip-select"
value={layer.slide_direction}
onChange={(e) =>
onUpdate(layer.id, { slide_direction: e.target.value as PipSlideDirection })
}
>
{SLIDE_DIR_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
)}
</>
)
@@ -0,0 +1,33 @@
import React from "react"
import type { PipLayer } from "@/pages/editing-planner/types"
import { LAYER_COLORS } from "@/pages/editing-planner/constants/pipConfig"
interface PipPreviewProps {
layers: PipLayer[]
selectedId: string
}
/**
* PIP 图层迷你预览组件
*/
export const PipPreview: React.FC<PipPreviewProps> = ({ layers, selectedId }) => (
<div className="pip-preview-box">
{layers.map((l, idx) => (
<div
key={l.id}
className={`pip-preview-layer${selectedId === l.id ? " selected" : ""}`}
style={{
left: `${l.x}%`,
top: `${l.y}%`,
width: `${l.width}%`,
height: `${l.height}%`,
background: LAYER_COLORS[idx % LAYER_COLORS.length],
opacity: l.opacity / 100,
borderRadius: `${l.border_radius}%`,
}}
>
<span className="pip-preview-label">{l.name}</span>
</div>
))}
</div>
)
@@ -0,0 +1,57 @@
/**
* 混剪单图层配置区
*/
import React from "react"
import type { PipLayer, PipGridPosition } from "@/pages/editing-planner/types"
import { PipPreview } from "./PipPreview"
import { LayerPositionSize } from "./LayerPositionSize"
import { LayerTimingAnimation } from "./LayerTimingAnimation"
interface LayerConfigProps {
layer: PipLayer | null
layers: PipLayer[]
totalDuration: number
onUpdate: (id: string, partial: Partial<PipLayer>) => void
onGridClick: (pos: PipGridPosition) => void
onWidthChange: (val: number) => void
onHeightChange: (val: number) => void
}
const LayerConfig: React.FC<LayerConfigProps> = ({
layer,
layers,
totalDuration,
onUpdate,
onGridClick,
onWidthChange,
onHeightChange,
}) => {
if (!layer) {
return (
<div className="pip-config-area">
<div className="pip-config-empty"></div>
</div>
)
}
return (
<div className="pip-config-area">
{/* 迷你预览 */}
<PipPreview layers={layers} selectedId={layer.id} />
{/* 位置与尺寸 */}
<LayerPositionSize
layer={layer}
onUpdate={onUpdate}
onGridClick={onGridClick}
onWidthChange={onWidthChange}
onHeightChange={onHeightChange}
/>
{/* 时间与动画 */}
<LayerTimingAnimation layer={layer} totalDuration={totalDuration} onUpdate={onUpdate} />
</div>
)
}
export default LayerConfig
+294 -37
View File
@@ -4,36 +4,232 @@
* 展示克隆音色列表,卡片网格布局
* 支持试听、使用、编辑名称、删除操作
*/
import React from "react"
import { Button } from "@/components/ui"
import React, { useState, useCallback } from "react"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import { Button, Tooltip } from "@/components/ui"
import { Popconfirm } from "antd"
import {
EditOutlined,
DeleteOutlined,
AudioOutlined,
SoundOutlined,
CalendarOutlined,
CheckCircleOutlined,
CloseCircleOutlined,
} from "@ant-design/icons"
import PageHead from "@/components/layout/PageHead"
import CloneModal from "@/components/voice/CloneModal"
import { VoiceCloneCard } from "./components/VoiceCloneCard"
import { VoiceCloneEmpty, VoiceCloneSkeleton, ToastContainer } from "./components/States"
import { EditNameDialog } from "./components/EditNameDialog"
import { useVoiceCloneList } from "./hooks/useVoiceCloneList"
import {
getVoiceClones,
deleteVoiceClone,
updateVoiceClone,
formatDuration,
type VoiceClone as VoiceCloneType,
} from "@/api/voice-clone"
import "./voice-clone.css"
/* ── 状态配置 ─────────────────────────────────────────── */
const STATUS_CONFIG: Record<VoiceCloneType["status"], { label: string; className: string }> = {
ready: { label: "就绪", className: "vc-status-pill--ready" },
processing: { label: "克隆中", className: "vc-status-pill--processing" },
failed: { label: "失败", className: "vc-status-pill--failed" },
}
/* ── Toast 系统 ─────────────────────────────────────────── */
interface Toast {
id: number
message: string
type: "success" | "error"
}
let toastId = 0
/* ── 音色卡片组件 ───────────────────────────────────────── */
interface VoiceCloneCardProps {
voice: VoiceCloneType
onPlay: (voice: VoiceCloneType) => void
onUse: (voice: VoiceCloneType) => void
onEdit: (voice: VoiceCloneType) => void
onDelete: (voice: VoiceCloneType) => void
}
const VoiceCloneCard: React.FC<VoiceCloneCardProps> = ({
voice,
onPlay,
onUse,
onEdit,
onDelete,
}) => {
const statusCfg = STATUS_CONFIG[voice.status]
const isProcessing = voice.status === "processing"
const createdDate = new Date(voice.created_at).toLocaleDateString("zh-CN")
return (
<div className="vc-card">
{/* 右上角操作 */}
<div className="vc-card-actions">
<Tooltip title="编辑名称">
<button type="button" className="vc-card-action-btn" onClick={() => onEdit(voice)}>
<EditOutlined />
</button>
</Tooltip>
<Tooltip title="删除">
<Popconfirm
title={`确定删除音色「${voice.name}」吗?`}
onConfirm={() => onDelete(voice)}
okText="删除"
cancelText="取消"
>
<button type="button" className="vc-card-action-btn vc-card-action-btn--danger">
<DeleteOutlined />
</button>
</Popconfirm>
</Tooltip>
</div>
{/* 头部:头像 + 名称 + 状态 */}
<div className="vc-card-header">
<div className={`vc-card-avatar${isProcessing ? " vc-card-avatar--processing" : ""}`}>
<AudioOutlined style={{ fontSize: 22 }} />
</div>
<div className="vc-card-info">
<h4 className="vc-card-name">{voice.name}</h4>
<span className={`vc-status-pill ${statusCfg.className}`}>
<span className="vc-status-dot" />
{statusCfg.label}
</span>
</div>
</div>
{/* 元信息 */}
<div className="vc-card-meta">
<div className="vc-card-meta-row">
<span className="vc-card-meta-icon">
<SoundOutlined />
</span>
<span>{formatDuration(voice.duration_seconds)}</span>
</div>
<div className="vc-card-meta-row">
<span className="vc-card-meta-icon">
<CalendarOutlined />
</span>
<span>{createdDate}</span>
</div>
</div>
{/* 操作区 */}
<div className="vc-card-footer">
<Button
buttonType="ghost"
buttonSize="sm"
disabled={isProcessing}
onClick={() => onPlay(voice)}
>
</Button>
<Button
buttonType="primary"
buttonSize="sm"
disabled={isProcessing}
onClick={() => onUse(voice)}
>
使
</Button>
</div>
</div>
)
}
/* ── 主页面 ─────────────────────────────────────────────── */
const VoiceClone: React.FC = () => {
const {
voices,
isLoading,
toasts,
editingVoice,
editName,
setEditName,
cloneModalOpen,
updateLoading,
handleCloneNew,
handleCloseCloneModal,
handleCloneSuccess,
handlePlay,
handleUse,
handleEdit,
handleCloseEdit,
handleEditConfirm,
handleDelete,
} = useVoiceCloneList()
const queryClient = useQueryClient()
const [toasts, setToasts] = useState<Toast[]>([])
const [editingVoice, setEditingVoice] = useState<VoiceCloneType | null>(null)
const [editName, setEditName] = useState("")
const [cloneModalOpen, setCloneModalOpen] = useState(false)
/** 显示 toast */
const showToast = useCallback((message: string, type: Toast["type"]) => {
const id = ++toastId
setToasts((prev) => [...prev, { id, message, type }])
setTimeout(() => {
setToasts((prev) => prev.filter((t) => t.id !== id))
}, 3000)
}, [])
/** 查询克隆音色列表 */
const { data: voices = [], isLoading } = useQuery({
queryKey: ["voiceClones"],
queryFn: () => getVoiceClones(),
})
/** 删除 mutation */
const deleteMutation = useMutation({
mutationFn: deleteVoiceClone,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["voiceClones"] })
showToast("音色已删除", "success")
},
onError: () => {
showToast("删除失败", "error")
},
})
/** 编辑 mutation */
const updateMutation = useMutation({
mutationFn: ({ id, name }: { id: string; name: string }) => updateVoiceClone(id, { name }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["voiceClones"] })
setEditingVoice(null)
showToast("名称已更新", "success")
},
onError: () => {
showToast("更新失败", "error")
},
})
/** 克隆新音色 — 打开弹窗 */
const handleCloneNew = () => {
setCloneModalOpen(true)
}
/** 试听 */
const handlePlay = (voice: VoiceCloneType) => {
if (voice.sample_url) {
const audio = new Audio(voice.sample_url)
audio.play().catch(() => {
showToast("播放失败", "error")
})
} else {
showToast("暂无试听音频", "error")
}
}
/** 使用音色 — 跳转到生成页面 */
const handleUse = (_voice: VoiceCloneType) => {
showToast("已选择音色,跳转到生成页面", "success")
}
/** 打开编辑弹窗 */
const handleEdit = (voice: VoiceCloneType) => {
setEditingVoice(voice)
setEditName(voice.name)
}
/** 确认编辑 */
const handleEditConfirm = () => {
if (!editingVoice || !editName.trim()) return
updateMutation.mutate({ id: editingVoice.id, name: editName.trim() })
}
/** 删除确认 — 使用 Popconfirm */
const handleDelete = (voice: VoiceCloneType) => {
deleteMutation.mutate(voice.id)
}
return (
<div className="vc-page">
@@ -48,7 +244,24 @@ const VoiceClone: React.FC = () => {
/>
{/* 加载状态 — 骨架屏 */}
{isLoading && <VoiceCloneSkeleton />}
{isLoading && (
<div className="vc-grid">
{[1, 2, 3].map((i) => (
<div key={i} className="vc-card">
<div className="vc-card-header">
<div className="vc-skeleton-avatar" />
<div style={{ flex: 1 }}>
<div className="vc-skeleton-line vc-skeleton-line--title" />
<div className="vc-skeleton-line vc-skeleton-line--short" />
</div>
</div>
<div className="vc-skeleton-line" />
<div className="vc-skeleton-line vc-skeleton-line--short" />
<div className="vc-skeleton-line vc-skeleton-line--footer" />
</div>
))}
</div>
)}
{/* 卡片网格 */}
{!isLoading && voices.length > 0 && (
@@ -67,27 +280,71 @@ const VoiceClone: React.FC = () => {
)}
{/* 空状态 */}
{!isLoading && voices.length === 0 && <VoiceCloneEmpty onClone={handleCloneNew} />}
{!isLoading && voices.length === 0 && (
<div className="vc-empty">
<div className="vc-empty-icon">
<AudioOutlined style={{ fontSize: 48 }} />
</div>
<h3 className="vc-empty-title"></h3>
<p className="vc-empty-desc">AI将克隆你的专属音色</p>
<Button buttonType="primary" onClick={handleCloneNew}>
</Button>
</div>
)}
{/* Toast 提示 */}
<ToastContainer toasts={toasts} />
{toasts.length > 0 && (
<div className="vc-toast-container">
{toasts.map((t) => (
<div key={t.id} className={`vc-toast vc-toast--${t.type}`}>
{t.type === "success" ? <CheckCircleOutlined /> : <CloseCircleOutlined />} {t.message}
</div>
))}
</div>
)}
{/* 编辑弹窗 */}
{editingVoice && (
<EditNameDialog
name={editName}
onNameChange={setEditName}
onConfirm={handleEditConfirm}
onCancel={handleCloseEdit}
loading={updateLoading}
/>
<div className="vc-edit-overlay" onClick={() => setEditingVoice(null)}>
<div className="vc-edit-dialog" onClick={(e) => e.stopPropagation()}>
<h3 className="vc-edit-title"></h3>
<input
type="text"
className="vc-edit-input"
value={editName}
onChange={(e) => setEditName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleEditConfirm()
if (e.key === "Escape") setEditingVoice(null)
}}
autoFocus
placeholder="输入音色名称"
/>
<div className="vc-edit-buttons">
<Button buttonType="ghost" onClick={() => setEditingVoice(null)}>
</Button>
<Button
buttonType="primary"
onClick={handleEditConfirm}
disabled={updateMutation.isPending}
>
{updateMutation.isPending ? "保存中..." : "保存"}
</Button>
</div>
</div>
</div>
)}
{/* 克隆音色弹窗 */}
<CloneModal
open={cloneModalOpen}
onClose={handleCloseCloneModal}
onSuccess={handleCloneSuccess}
onClose={() => setCloneModalOpen(false)}
onSuccess={() => {
queryClient.invalidateQueries({ queryKey: ["voiceClones"] })
showToast("音色克隆已提交", "success")
}}
/>
</div>
)
@@ -1,49 +0,0 @@
import React from "react"
import { Button } from "@/components/ui"
interface EditNameDialogProps {
name: string
onNameChange: (name: string) => void
onConfirm: () => void
onCancel: () => void
loading?: boolean
}
/**
* 编辑音色名称弹窗
*/
export const EditNameDialog: React.FC<EditNameDialogProps> = ({
name,
onNameChange,
onConfirm,
onCancel,
loading,
}) => {
return (
<div className="vc-edit-overlay" onClick={onCancel}>
<div className="vc-edit-dialog" onClick={(e) => e.stopPropagation()}>
<h3 className="vc-edit-title"></h3>
<input
type="text"
className="vc-edit-input"
value={name}
onChange={(e) => onNameChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") onConfirm()
if (e.key === "Escape") onCancel()
}}
autoFocus
placeholder="输入音色名称"
/>
<div className="vc-edit-buttons">
<Button buttonType="ghost" onClick={onCancel}>
</Button>
<Button buttonType="primary" onClick={onConfirm} disabled={loading}>
{loading ? "保存中..." : "保存"}
</Button>
</div>
</div>
</div>
)
}
@@ -1,60 +0,0 @@
import React from "react"
import { AudioOutlined, CheckCircleOutlined, CloseCircleOutlined } from "@ant-design/icons"
import { Button } from "@/components/ui"
import type { Toast } from "../types"
interface VoiceCloneEmptyProps {
onClone: () => void
}
/** 空状态 */
export const VoiceCloneEmpty: React.FC<VoiceCloneEmptyProps> = ({ onClone }) => (
<div className="vc-empty">
<div className="vc-empty-icon">
<AudioOutlined style={{ fontSize: 48 }} />
</div>
<h3 className="vc-empty-title"></h3>
<p className="vc-empty-desc">AI将克隆你的专属音色</p>
<Button buttonType="primary" onClick={onClone}>
</Button>
</div>
)
/** 骨架屏加载 */
export const VoiceCloneSkeleton: React.FC = () => (
<div className="vc-grid">
{[1, 2, 3].map((i) => (
<div key={i} className="vc-card">
<div className="vc-card-header">
<div className="vc-skeleton-avatar" />
<div style={{ flex: 1 }}>
<div className="vc-skeleton-line vc-skeleton-line--title" />
<div className="vc-skeleton-line vc-skeleton-line--short" />
</div>
</div>
<div className="vc-skeleton-line" />
<div className="vc-skeleton-line vc-skeleton-line--short" />
<div className="vc-skeleton-line vc-skeleton-line--footer" />
</div>
))}
</div>
)
interface ToastContainerProps {
toasts: Toast[]
}
/** Toast 提示容器 */
export const ToastContainer: React.FC<ToastContainerProps> = ({ toasts }) => {
if (toasts.length === 0) return null
return (
<div className="vc-toast-container">
{toasts.map((t) => (
<div key={t.id} className={`vc-toast vc-toast--${t.type}`}>
{t.type === "success" ? <CheckCircleOutlined /> : <CloseCircleOutlined />} {t.message}
</div>
))}
</div>
)
}
@@ -1,111 +0,0 @@
import React from "react"
import { Tooltip } from "@/components/ui"
import { Popconfirm } from "antd"
import {
EditOutlined,
DeleteOutlined,
AudioOutlined,
SoundOutlined,
CalendarOutlined,
} from "@ant-design/icons"
import { Button } from "@/components/ui"
import { formatDuration, type VoiceClone } from "@/api/voice-clone"
import { STATUS_CONFIG } from "../types"
interface VoiceCloneCardProps {
voice: VoiceClone
onPlay: (voice: VoiceClone) => void
onUse: (voice: VoiceClone) => void
onEdit: (voice: VoiceClone) => void
onDelete: (voice: VoiceClone) => void
}
/**
* 音色克隆卡片
*/
export const VoiceCloneCard: React.FC<VoiceCloneCardProps> = ({
voice,
onPlay,
onUse,
onEdit,
onDelete,
}) => {
const statusCfg = STATUS_CONFIG[voice.status]
const isProcessing = voice.status === "processing"
const createdDate = new Date(voice.created_at).toLocaleDateString("zh-CN")
return (
<div className="vc-card">
{/* 右上角操作 */}
<div className="vc-card-actions">
<Tooltip title="编辑名称">
<button type="button" className="vc-card-action-btn" onClick={() => onEdit(voice)}>
<EditOutlined />
</button>
</Tooltip>
<Tooltip title="删除">
<Popconfirm
title={`确定删除音色「${voice.name}」吗?`}
onConfirm={() => onDelete(voice)}
okText="删除"
cancelText="取消"
>
<button type="button" className="vc-card-action-btn vc-card-action-btn--danger">
<DeleteOutlined />
</button>
</Popconfirm>
</Tooltip>
</div>
{/* 头部:头像 + 名称 + 状态 */}
<div className="vc-card-header">
<div className={`vc-card-avatar${isProcessing ? " vc-card-avatar--processing" : ""}`}>
<AudioOutlined style={{ fontSize: 22 }} />
</div>
<div className="vc-card-info">
<h4 className="vc-card-name">{voice.name}</h4>
<span className={`vc-status-pill ${statusCfg.className}`}>
<span className="vc-status-dot" />
{statusCfg.label}
</span>
</div>
</div>
{/* 元信息 */}
<div className="vc-card-meta">
<div className="vc-card-meta-row">
<span className="vc-card-meta-icon">
<SoundOutlined />
</span>
<span>{formatDuration(voice.duration_seconds)}</span>
</div>
<div className="vc-card-meta-row">
<span className="vc-card-meta-icon">
<CalendarOutlined />
</span>
<span>{createdDate}</span>
</div>
</div>
{/* 操作区 */}
<div className="vc-card-footer">
<Button
buttonType="ghost"
buttonSize="sm"
disabled={isProcessing}
onClick={() => onPlay(voice)}
>
</Button>
<Button
buttonType="primary"
buttonSize="sm"
disabled={isProcessing}
onClick={() => onUse(voice)}
>
使
</Button>
</div>
</div>
)
}
@@ -1,157 +0,0 @@
import { useState, useCallback, useRef } from "react"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import {
getVoiceClones,
deleteVoiceClone,
updateVoiceClone,
type VoiceClone,
} from "@/api/voice-clone"
import type { Toast } from "../types"
let toastId = 0
/**
* 音色克隆列表业务 Hook
* 封装列表查询、删除、编辑、试听、Toast 等所有业务逻辑
*/
export const useVoiceCloneList = () => {
const queryClient = useQueryClient()
const [toasts, setToasts] = useState<Toast[]>([])
const [editingVoice, setEditingVoice] = useState<VoiceClone | null>(null)
const [editName, setEditName] = useState("")
const [cloneModalOpen, setCloneModalOpen] = useState(false)
const audioRef = useRef<HTMLAudioElement | null>(null)
/** 显示 toast */
const showToast = useCallback((message: string, type: Toast["type"]) => {
const id = ++toastId
setToasts((prev) => [...prev, { id, message, type }])
setTimeout(() => {
setToasts((prev) => prev.filter((t) => t.id !== id))
}, 3000)
}, [])
/** 查询克隆音色列表 */
const { data: voices = [], isLoading } = useQuery({
queryKey: ["voiceClones"],
queryFn: () => getVoiceClones(),
})
/** 删除 mutation */
const deleteMutation = useMutation({
mutationFn: deleteVoiceClone,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["voiceClones"] })
showToast("音色已删除", "success")
},
onError: () => {
showToast("删除失败", "error")
},
})
/** 编辑 mutation */
const updateMutation = useMutation({
mutationFn: ({ id, name }: { id: string; name: string }) => updateVoiceClone(id, { name }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["voiceClones"] })
setEditingVoice(null)
showToast("名称已更新", "success")
},
onError: () => {
showToast("更新失败", "error")
},
})
/** 克隆新音色 — 打开弹窗 */
const handleCloneNew = useCallback(() => {
setCloneModalOpen(true)
}, [])
/** 关闭克隆弹窗 */
const handleCloseCloneModal = useCallback(() => {
setCloneModalOpen(false)
}, [])
/** 克隆成功回调 */
const handleCloneSuccess = useCallback(() => {
queryClient.invalidateQueries({ queryKey: ["voiceClones"] })
showToast("音色克隆已提交", "success")
}, [queryClient, showToast])
/** 试听 */
const handlePlay = useCallback(
(voice: VoiceClone) => {
if (voice.sample_url) {
// 停止之前的播放
if (audioRef.current) {
audioRef.current.pause()
audioRef.current = null
}
const audio = new Audio(voice.sample_url)
audioRef.current = audio
audio.play().catch(() => {
showToast("播放失败", "error")
})
} else {
showToast("暂无试听音频", "error")
}
},
[showToast],
)
/** 使用音色 */
const handleUse = useCallback(() => {
showToast("已选择音色,跳转到生成页面", "success")
}, [showToast])
/** 打开编辑弹窗 */
const handleEdit = useCallback((voice: VoiceClone) => {
setEditingVoice(voice)
setEditName(voice.name)
}, [])
/** 关闭编辑弹窗 */
const handleCloseEdit = useCallback(() => {
setEditingVoice(null)
}, [])
/** 确认编辑 */
const handleEditConfirm = useCallback(() => {
if (!editingVoice || !editName.trim()) return
updateMutation.mutate({ id: editingVoice.id, name: editName.trim() })
}, [editingVoice, editName, updateMutation])
/** 删除确认 */
const handleDelete = useCallback(
(voice: VoiceClone) => {
deleteMutation.mutate(voice.id)
},
[deleteMutation],
)
return {
// 数据
voices,
isLoading,
// 状态
toasts,
editingVoice,
editName,
setEditName,
cloneModalOpen,
// 加载状态
deleteLoading: deleteMutation.isPending,
updateLoading: updateMutation.isPending,
// 操作
showToast,
handleCloneNew,
handleCloseCloneModal,
handleCloneSuccess,
handlePlay,
handleUse,
handleEdit,
handleCloseEdit,
handleEditConfirm,
handleDelete,
}
}
-15
View File
@@ -1,15 +0,0 @@
import type { VoiceClone } from "@/api/voice-clone"
/** 状态配置 */
export const STATUS_CONFIG: Record<VoiceClone["status"], { label: string; className: string }> = {
ready: { label: "就绪", className: "vc-status-pill--ready" },
processing: { label: "克隆中", className: "vc-status-pill--processing" },
failed: { label: "失败", className: "vc-status-pill--failed" },
}
/** Toast 类型 */
export interface Toast {
id: number
message: string
type: "success" | "error"
}