Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2023ddc8cf | |||
| 7ea0a1397e | |||
| 9f61f76019 |
Executable → Regular
+3
-284
@@ -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"
|
||||
|
||||
+172
@@ -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>
|
||||
</>
|
||||
)
|
||||
+87
@@ -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
|
||||
Regular → Executable
+266
-49
@@ -1,21 +1,8 @@
|
||||
/**
|
||||
* 上传音频弹窗
|
||||
*
|
||||
* 子组件目录: upload-voice/
|
||||
* - UploadDragger: 拖拽上传区
|
||||
* - SelectedFileInfo: 已选文件信息
|
||||
* - UploadProgress: 上传进度条
|
||||
* - UploadFormFields: 表单区域(名称/性别/描述)
|
||||
* - UploadActions: 底部操作按钮
|
||||
*/
|
||||
import React from "react"
|
||||
import { Modal } from "antd"
|
||||
import { UploadOutlined, SoundOutlined } from "@ant-design/icons"
|
||||
import { Modal, Upload, message } from "antd"
|
||||
import { type VoiceGender } from "@/pages/voices/types"
|
||||
import UploadDragger from "./upload-voice/UploadDragger"
|
||||
import SelectedFileInfo from "./upload-voice/SelectedFileInfo"
|
||||
import UploadProgress from "./upload-voice/UploadProgress"
|
||||
import UploadFormFields from "./upload-voice/UploadFormFields"
|
||||
import UploadActions from "./upload-voice/UploadActions"
|
||||
import { formatFileSize } from "@/pages/voices/utils/format"
|
||||
|
||||
export interface UploadVoiceModalProps {
|
||||
open: boolean
|
||||
@@ -33,6 +20,7 @@ export interface UploadVoiceModalProps {
|
||||
onUpload: () => void
|
||||
}
|
||||
|
||||
/** 上传音频弹窗 */
|
||||
const UploadVoiceModal: React.FC<UploadVoiceModalProps> = ({
|
||||
open,
|
||||
uploadFile,
|
||||
@@ -48,21 +36,17 @@ const UploadVoiceModal: React.FC<UploadVoiceModalProps> = ({
|
||||
onDescChange,
|
||||
onUpload,
|
||||
}) => {
|
||||
const isUploading = uploadProgress !== null
|
||||
|
||||
const handleClose = () => {
|
||||
if (isUploading) return // 上传中不可关闭
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="上传音频"
|
||||
open={open}
|
||||
onCancel={handleClose}
|
||||
onCancel={() => {
|
||||
if (uploadProgress !== null) return // 上传中不可关闭
|
||||
onClose()
|
||||
}}
|
||||
footer={null}
|
||||
width={520}
|
||||
maskClosable={!isUploading}
|
||||
maskClosable={uploadProgress === null}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
@@ -73,37 +57,270 @@ const UploadVoiceModal: React.FC<UploadVoiceModalProps> = ({
|
||||
}}
|
||||
>
|
||||
{/* 拖拽上传区 */}
|
||||
<UploadDragger
|
||||
disabled={isUploading}
|
||||
onFileSelect={onFileSelect}
|
||||
onFileRemove={onFileRemove}
|
||||
/>
|
||||
<Upload.Dragger
|
||||
accept="audio/*"
|
||||
maxCount={1}
|
||||
beforeUpload={(file) => {
|
||||
onFileSelect(file)
|
||||
return false
|
||||
}}
|
||||
onRemove={() => {
|
||||
onFileRemove()
|
||||
}}
|
||||
showUploadList={false}
|
||||
disabled={uploadProgress !== null}
|
||||
>
|
||||
<p
|
||||
style={{
|
||||
fontSize: 32,
|
||||
color: "var(--primary-color)",
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
<UploadOutlined />
|
||||
</p>
|
||||
<p style={{ fontSize: 14, fontWeight: 500, margin: "0 0 4px" }}>
|
||||
点击或拖拽音频文件到此处
|
||||
</p>
|
||||
<p
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "var(--text-secondary)",
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
支持 MP3、WAV、AAC、FLAC 等格式,最大 200MB
|
||||
</p>
|
||||
</Upload.Dragger>
|
||||
|
||||
{/* 已选文件信息 */}
|
||||
{uploadFile && <SelectedFileInfo file={uploadFile} />}
|
||||
{uploadFile && (
|
||||
<div
|
||||
style={{
|
||||
padding: "10px 12px",
|
||||
background: "var(--bg-secondary)",
|
||||
borderRadius: 8,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
}}
|
||||
>
|
||||
<SoundOutlined style={{ fontSize: 18, color: "var(--primary-color)" }} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{uploadFile.name}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: "var(--text-secondary)" }}>
|
||||
{formatFileSize(uploadFile.size)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 上传进度 */}
|
||||
{isUploading && <UploadProgress progress={uploadProgress!} />}
|
||||
{uploadProgress !== null && (
|
||||
<div style={{ textAlign: "center", padding: "8px 0" }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 22,
|
||||
fontWeight: 700,
|
||||
color: "var(--primary-color)",
|
||||
}}
|
||||
>
|
||||
{uploadProgress}%
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: "var(--text-secondary)" }}>
|
||||
{uploadProgress < 100 ? "上传中..." : "处理中..."}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
height: 4,
|
||||
background: "var(--bg-tertiary)",
|
||||
borderRadius: 2,
|
||||
marginTop: 8,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: "100%",
|
||||
width: `${uploadProgress}%`,
|
||||
background: "var(--primary-color)",
|
||||
borderRadius: 2,
|
||||
transition: "width 0.3s ease",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 表单字段 */}
|
||||
<UploadFormFields
|
||||
name={uploadName}
|
||||
gender={uploadGender}
|
||||
description={uploadDesc}
|
||||
disabled={isUploading}
|
||||
onNameChange={onNameChange}
|
||||
onGenderChange={onGenderChange}
|
||||
onDescriptionChange={onDescChange}
|
||||
/>
|
||||
{/* 名称 */}
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: "var(--text-secondary)",
|
||||
marginBottom: 6,
|
||||
}}
|
||||
>
|
||||
素材名称
|
||||
</div>
|
||||
<input
|
||||
value={uploadName}
|
||||
onChange={(e) => onNameChange(e.target.value)}
|
||||
placeholder="输入素材名称"
|
||||
maxLength={100}
|
||||
disabled={uploadProgress !== null}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "8px 12px",
|
||||
border: "1px solid var(--border-color)",
|
||||
borderRadius: 8,
|
||||
fontSize: 13,
|
||||
background: "var(--bg-primary)",
|
||||
color: "var(--text-primary)",
|
||||
outline: "none",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 性别选择 */}
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: "var(--text-secondary)",
|
||||
marginBottom: 6,
|
||||
}}
|
||||
>
|
||||
音色性别
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
{(["female", "male", "child"] as VoiceGender[]).map((g) => (
|
||||
<button
|
||||
key={g}
|
||||
type="button"
|
||||
onClick={() => onGenderChange(g)}
|
||||
disabled={uploadProgress !== null}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: "6px 0",
|
||||
borderRadius: 8,
|
||||
border: `1px solid ${uploadGender === g ? "var(--primary-color)" : "var(--border-color)"}`,
|
||||
background: uploadGender === g ? "var(--primary-soft)" : "transparent",
|
||||
color: uploadGender === g ? "var(--primary-color)" : "var(--text-secondary)",
|
||||
fontSize: 13,
|
||||
fontWeight: uploadGender === g ? 600 : 400,
|
||||
cursor: uploadProgress !== null ? "not-allowed" : "pointer",
|
||||
transition: "all 0.2s",
|
||||
}}
|
||||
>
|
||||
{g === "female" ? "女声" : g === "male" ? "男声" : "童声"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 描述 */}
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: "var(--text-secondary)",
|
||||
marginBottom: 6,
|
||||
}}
|
||||
>
|
||||
音色描述(可选)
|
||||
</div>
|
||||
<textarea
|
||||
value={uploadDesc}
|
||||
onChange={(e) => onDescChange(e.target.value)}
|
||||
placeholder="描述这个音色的特点..."
|
||||
maxLength={500}
|
||||
rows={2}
|
||||
disabled={uploadProgress !== null}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "8px 12px",
|
||||
border: "1px solid var(--border-color)",
|
||||
borderRadius: 8,
|
||||
fontSize: 13,
|
||||
background: "var(--bg-primary)",
|
||||
color: "var(--text-primary)",
|
||||
outline: "none",
|
||||
resize: "vertical",
|
||||
fontFamily: "inherit",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<UploadActions
|
||||
hasFile={!!uploadFile}
|
||||
hasName={!!uploadName.trim()}
|
||||
uploading={isUploading}
|
||||
onCancel={onClose}
|
||||
onUpload={onUpload}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "flex-end",
|
||||
gap: 10,
|
||||
paddingTop: 4,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={uploadProgress !== null}
|
||||
style={{
|
||||
padding: "8px 20px",
|
||||
borderRadius: 8,
|
||||
border: "1px solid var(--border-color)",
|
||||
background: "transparent",
|
||||
fontSize: 13,
|
||||
cursor: uploadProgress !== null ? "not-allowed" : "pointer",
|
||||
color: "var(--text-secondary)",
|
||||
}}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!uploadFile) {
|
||||
message.warning("请先选择音频文件")
|
||||
return
|
||||
}
|
||||
if (!uploadName.trim()) {
|
||||
message.warning("请输入素材名称")
|
||||
return
|
||||
}
|
||||
onUpload()
|
||||
}}
|
||||
disabled={!uploadFile || !uploadName.trim() || uploadProgress !== null}
|
||||
style={{
|
||||
padding: "8px 20px",
|
||||
borderRadius: 8,
|
||||
border: "none",
|
||||
background:
|
||||
!uploadFile || !uploadName.trim() || uploadProgress !== null
|
||||
? "var(--text-tertiary)"
|
||||
: "var(--primary-color)",
|
||||
color: "#fff",
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
cursor:
|
||||
!uploadFile || !uploadName.trim() || uploadProgress !== null
|
||||
? "not-allowed"
|
||||
: "pointer",
|
||||
}}
|
||||
>
|
||||
{uploadProgress !== null ? "上传中..." : "开始上传"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
/**
|
||||
* 上传音频 - 已选文件信息
|
||||
*/
|
||||
import React from "react"
|
||||
import { SoundOutlined } from "@ant-design/icons"
|
||||
import { formatFileSize } from "@/pages/voices/utils/format"
|
||||
|
||||
export interface SelectedFileInfoProps {
|
||||
file: File
|
||||
}
|
||||
|
||||
export const SelectedFileInfo: React.FC<SelectedFileInfoProps> = ({ file }) => {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: "10px 12px",
|
||||
background: "var(--bg-secondary)",
|
||||
borderRadius: 8,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
}}
|
||||
>
|
||||
<SoundOutlined style={{ fontSize: 18, color: "var(--primary-color)" }} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{file.name}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: "var(--text-secondary)" }}>
|
||||
{formatFileSize(file.size)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SelectedFileInfo
|
||||
@@ -1,82 +0,0 @@
|
||||
/**
|
||||
* 上传音频 - 底部操作按钮
|
||||
*/
|
||||
import React from "react"
|
||||
import { message } from "antd"
|
||||
|
||||
export interface UploadActionsProps {
|
||||
hasFile: boolean
|
||||
hasName: boolean
|
||||
uploading: boolean
|
||||
onCancel: () => void
|
||||
onUpload: () => void
|
||||
}
|
||||
|
||||
export const UploadActions: React.FC<UploadActionsProps> = ({
|
||||
hasFile,
|
||||
hasName,
|
||||
uploading,
|
||||
onCancel,
|
||||
onUpload,
|
||||
}) => {
|
||||
const canUpload = hasFile && hasName && !uploading
|
||||
|
||||
const handleUploadClick = () => {
|
||||
if (!hasFile) {
|
||||
message.warning("请先选择音频文件")
|
||||
return
|
||||
}
|
||||
if (!hasName) {
|
||||
message.warning("请输入素材名称")
|
||||
return
|
||||
}
|
||||
onUpload()
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "flex-end",
|
||||
gap: 10,
|
||||
paddingTop: 4,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
disabled={uploading}
|
||||
style={{
|
||||
padding: "8px 20px",
|
||||
borderRadius: 8,
|
||||
border: "1px solid var(--border-color)",
|
||||
background: "transparent",
|
||||
fontSize: 13,
|
||||
cursor: uploading ? "not-allowed" : "pointer",
|
||||
color: "var(--text-secondary)",
|
||||
}}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleUploadClick}
|
||||
disabled={!canUpload}
|
||||
style={{
|
||||
padding: "8px 20px",
|
||||
borderRadius: 8,
|
||||
border: "none",
|
||||
background: canUpload ? "var(--primary-color)" : "var(--text-tertiary)",
|
||||
color: "#fff",
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
cursor: canUpload ? "pointer" : "not-allowed",
|
||||
}}
|
||||
>
|
||||
{uploading ? "上传中..." : "开始上传"}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default UploadActions
|
||||
@@ -1,56 +0,0 @@
|
||||
/**
|
||||
* 上传音频 - 拖拽上传区
|
||||
*/
|
||||
import React from "react"
|
||||
import { UploadOutlined } from "@ant-design/icons"
|
||||
import { Upload } from "antd"
|
||||
|
||||
export interface UploadDraggerProps {
|
||||
disabled: boolean
|
||||
onFileSelect: (file: File) => void
|
||||
onFileRemove: () => void
|
||||
}
|
||||
|
||||
export const UploadDragger: React.FC<UploadDraggerProps> = ({
|
||||
disabled,
|
||||
onFileSelect,
|
||||
onFileRemove,
|
||||
}) => {
|
||||
return (
|
||||
<Upload.Dragger
|
||||
accept="audio/*"
|
||||
maxCount={1}
|
||||
beforeUpload={(file) => {
|
||||
onFileSelect(file)
|
||||
return false
|
||||
}}
|
||||
onRemove={() => {
|
||||
onFileRemove()
|
||||
}}
|
||||
showUploadList={false}
|
||||
disabled={disabled}
|
||||
>
|
||||
<p
|
||||
style={{
|
||||
fontSize: 32,
|
||||
color: "var(--primary-color)",
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
<UploadOutlined />
|
||||
</p>
|
||||
<p style={{ fontSize: 14, fontWeight: 500, margin: "0 0 4px" }}>点击或拖拽音频文件到此处</p>
|
||||
<p
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "var(--text-secondary)",
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
支持 MP3、WAV、AAC、FLAC 等格式,最大 200MB
|
||||
</p>
|
||||
</Upload.Dragger>
|
||||
)
|
||||
}
|
||||
|
||||
export default UploadDragger
|
||||
@@ -1,115 +0,0 @@
|
||||
/**
|
||||
* 上传音频 - 表单区域(名称/性别/描述)
|
||||
*/
|
||||
import React from "react"
|
||||
import { type VoiceGender } from "@/pages/voices/types"
|
||||
|
||||
export interface UploadFormFieldsProps {
|
||||
name: string
|
||||
gender: VoiceGender
|
||||
description: string
|
||||
disabled: boolean
|
||||
onNameChange: (name: string) => void
|
||||
onGenderChange: (gender: VoiceGender) => void
|
||||
onDescriptionChange: (desc: string) => void
|
||||
}
|
||||
|
||||
const GENDER_LABELS: Record<VoiceGender, string> = {
|
||||
female: "女声",
|
||||
male: "男声",
|
||||
child: "童声",
|
||||
elderly: "老年音",
|
||||
}
|
||||
|
||||
export const UploadFormFields: React.FC<UploadFormFieldsProps> = ({
|
||||
name,
|
||||
gender,
|
||||
description,
|
||||
disabled,
|
||||
onNameChange,
|
||||
onGenderChange,
|
||||
onDescriptionChange,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
{/* 名称 */}
|
||||
<div>
|
||||
<div style={labelStyle}>素材名称</div>
|
||||
<input
|
||||
value={name}
|
||||
onChange={(e) => onNameChange(e.target.value)}
|
||||
placeholder="输入素材名称"
|
||||
maxLength={100}
|
||||
disabled={disabled}
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 性别选择 */}
|
||||
<div>
|
||||
<div style={labelStyle}>音色性别</div>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
{(["female", "male", "child"] as VoiceGender[]).map((g) => (
|
||||
<button
|
||||
key={g}
|
||||
type="button"
|
||||
onClick={() => onGenderChange(g)}
|
||||
disabled={disabled}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: "6px 0",
|
||||
borderRadius: 8,
|
||||
border: `1px solid ${gender === g ? "var(--primary-color)" : "var(--border-color)"}`,
|
||||
background: gender === g ? "var(--primary-soft)" : "transparent",
|
||||
color: gender === g ? "var(--primary-color)" : "var(--text-secondary)",
|
||||
fontSize: 13,
|
||||
fontWeight: gender === g ? 600 : 400,
|
||||
cursor: disabled ? "not-allowed" : "pointer",
|
||||
transition: "all 0.2s",
|
||||
}}
|
||||
>
|
||||
{GENDER_LABELS[g]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 描述 */}
|
||||
<div>
|
||||
<div style={labelStyle}>音色描述(可选)</div>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => onDescriptionChange(e.target.value)}
|
||||
placeholder="描述这个音色的特点..."
|
||||
maxLength={500}
|
||||
rows={2}
|
||||
disabled={disabled}
|
||||
style={{
|
||||
...inputStyle,
|
||||
resize: "vertical",
|
||||
fontFamily: "inherit",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const labelStyle: React.CSSProperties = {
|
||||
fontSize: 13,
|
||||
color: "var(--text-secondary)",
|
||||
marginBottom: 6,
|
||||
}
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
width: "100%",
|
||||
padding: "8px 12px",
|
||||
border: "1px solid var(--border-color)",
|
||||
borderRadius: 8,
|
||||
fontSize: 13,
|
||||
background: "var(--bg-primary)",
|
||||
color: "var(--text-primary)",
|
||||
outline: "none",
|
||||
}
|
||||
|
||||
export default UploadFormFields
|
||||
@@ -1,48 +0,0 @@
|
||||
/**
|
||||
* 上传音频 - 上传进度条
|
||||
*/
|
||||
import React from "react"
|
||||
|
||||
export interface UploadProgressProps {
|
||||
progress: number
|
||||
}
|
||||
|
||||
export const UploadProgress: React.FC<UploadProgressProps> = ({ progress }) => {
|
||||
return (
|
||||
<div style={{ textAlign: "center", padding: "8px 0" }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 22,
|
||||
fontWeight: 700,
|
||||
color: "var(--primary-color)",
|
||||
}}
|
||||
>
|
||||
{progress}%
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: "var(--text-secondary)" }}>
|
||||
{progress < 100 ? "上传中..." : "处理中..."}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
height: 4,
|
||||
background: "var(--bg-tertiary)",
|
||||
borderRadius: 2,
|
||||
marginTop: 8,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: "100%",
|
||||
width: `${progress}%`,
|
||||
background: "var(--primary-color)",
|
||||
borderRadius: 2,
|
||||
transition: "width 0.3s ease",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default UploadProgress
|
||||
Reference in New Issue
Block a user