0fcb77b991
CI Build & Deploy Pipeline / Staging E2E Tests (push) Failing after 2m50s
CI Build & Deploy Pipeline / Production Browser E2E (push) Failing after 1437h19m24s
CI Build & Deploy Pipeline / Build Production Web Image (push) Failing after 1437h19m26s
CI Build & Deploy Pipeline / Deploy Production (push) Failing after 1437h19m25s
CI Build & Deploy Pipeline / Build Production API Image (push) Failing after 1437h19m26s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1437h21m16s
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Worker Image (push) Failing after 1437h51m29s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com> Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
548 lines
19 KiB
TypeScript
548 lines
19 KiB
TypeScript
/**
|
|
* 画中画配置面板 — Drawer 形式
|
|
* 左侧图层列表 + 右侧单图层配置 + 迷你预览区
|
|
*/
|
|
import React, { useCallback, useMemo } from "react"
|
|
import { Drawer, Switch } from "antd"
|
|
import type { PipConfig, PipLayer, PipGridPosition, PipAnimType, PipSlideDirection } from "../types"
|
|
import { DEFAULT_PIP_LAYER, DEFAULT_PIP_CONFIG } from "../types"
|
|
|
|
/* ──────────── 常量 ──────────── */
|
|
|
|
/** 九宫格位置 → 百分比坐标映射 */
|
|
const GRID_POSITION_MAP: Record<PipGridPosition, { x: number; y: number }> = {
|
|
top_left: { x: 5, y: 5 },
|
|
top_center: { x: 37.5, y: 5 },
|
|
top_right: { x: 70, y: 5 },
|
|
center_left: { x: 5, y: 37.5 },
|
|
center: { x: 37.5, y: 37.5 },
|
|
center_right: { x: 70, y: 37.5 },
|
|
bottom_left: { x: 5, y: 70 },
|
|
bottom_center: { x: 37.5, y: 70 },
|
|
bottom_right: { x: 70, y: 70 },
|
|
}
|
|
|
|
/** 九宫格位置选项 */
|
|
const GRID_POSITIONS: PipGridPosition[] = [
|
|
"top_left",
|
|
"top_center",
|
|
"top_right",
|
|
"center_left",
|
|
"center",
|
|
"center_right",
|
|
"bottom_left",
|
|
"bottom_center",
|
|
"bottom_right",
|
|
]
|
|
|
|
/** 入场动画选项 */
|
|
const ANIM_OPTIONS: { value: PipAnimType; label: string }[] = [
|
|
{ value: "none", label: "无" },
|
|
{ value: "fade_in", label: "淡入" },
|
|
{ value: "slide_in", label: "滑入" },
|
|
]
|
|
|
|
/** 滑入方向选项 */
|
|
const SLIDE_DIR_OPTIONS: { value: PipSlideDirection; label: string }[] = [
|
|
{ value: "left", label: "← 左" },
|
|
{ value: "right", label: "→ 右" },
|
|
{ value: "up", label: "↑ 上" },
|
|
{ value: "down", label: "↓ 下" },
|
|
]
|
|
|
|
/** 预览图层颜色池 */
|
|
const LAYER_COLORS = [
|
|
"rgba(22,119,255,0.5)",
|
|
"rgba(82,196,26,0.5)",
|
|
"rgba(250,173,20,0.5)",
|
|
"rgba(255,77,79,0.5)",
|
|
"rgba(114,46,209,0.5)",
|
|
"rgba(19,194,194,0.5)",
|
|
]
|
|
|
|
/* ──────────── Props ──────────── */
|
|
|
|
interface PipConfigPanelProps {
|
|
open: boolean
|
|
onClose: () => void
|
|
config: PipConfig
|
|
onChange: (config: PipConfig) => void
|
|
totalDuration: number
|
|
}
|
|
|
|
/* ──────────── 辅助函数 ──────────── */
|
|
|
|
let layerIdCounter = 0
|
|
const genLayerId = () => `pip_layer_${Date.now()}_${++layerIdCounter}`
|
|
|
|
/* ──────────── 组件 ──────────── */
|
|
|
|
const PipConfigPanel: React.FC<PipConfigPanelProps> = ({
|
|
open,
|
|
onClose,
|
|
config,
|
|
onChange,
|
|
totalDuration,
|
|
}) => {
|
|
/** 当前选中图层 ID */
|
|
const [selectedId, setSelectedId] = React.useState<string>("")
|
|
|
|
/** 当前选中图层 */
|
|
const selectedLayer = useMemo(
|
|
() => config.layers.find((l) => l.id === selectedId) ?? null,
|
|
[config.layers, selectedId],
|
|
)
|
|
|
|
/* ── 添加图层 ── */
|
|
const handleAddLayer = useCallback(() => {
|
|
const newLayer: PipLayer = {
|
|
...DEFAULT_PIP_LAYER,
|
|
id: genLayerId(),
|
|
name: `图层 ${config.layers.length + 1}`,
|
|
z_index: config.layers.length + 1,
|
|
}
|
|
onChange({
|
|
...config,
|
|
layers: [...config.layers, newLayer],
|
|
})
|
|
setSelectedId(newLayer.id)
|
|
}, [config, onChange])
|
|
|
|
/* ── 删除图层 ── */
|
|
const handleDeleteLayer = useCallback(
|
|
(id: string) => {
|
|
const newLayers = config.layers.filter((l) => l.id !== id)
|
|
onChange({ ...config, layers: newLayers })
|
|
if (selectedId === id) {
|
|
setSelectedId(newLayers.length > 0 ? newLayers[0].id : "")
|
|
}
|
|
},
|
|
[config, onChange, selectedId],
|
|
)
|
|
|
|
/* ── 更新图层 ── */
|
|
const updateLayer = useCallback(
|
|
(id: string, partial: Partial<PipLayer>) => {
|
|
onChange({
|
|
...config,
|
|
layers: config.layers.map((l) => (l.id === id ? { ...l, ...partial } : l)),
|
|
})
|
|
},
|
|
[config, onChange],
|
|
)
|
|
|
|
/* ── 切换启用 ── */
|
|
const handleEnableToggle = useCallback(
|
|
(checked: boolean) => {
|
|
onChange({ ...config, enabled: checked })
|
|
},
|
|
[config, onChange],
|
|
)
|
|
|
|
/* ── 重置 ── */
|
|
const handleReset = useCallback(() => {
|
|
onChange({ ...DEFAULT_PIP_CONFIG })
|
|
setSelectedId("")
|
|
}, [onChange])
|
|
|
|
/* ── 九宫格点击 ── */
|
|
const handleGridClick = useCallback(
|
|
(pos: PipGridPosition) => {
|
|
if (!selectedLayer) return
|
|
const coords = GRID_POSITION_MAP[pos]
|
|
updateLayer(selectedLayer.id, {
|
|
grid_position: pos,
|
|
x: coords.x,
|
|
y: coords.y,
|
|
})
|
|
},
|
|
[selectedLayer, updateLayer],
|
|
)
|
|
|
|
/* ── 宽高比锁定 ── */
|
|
const handleWidthChange = useCallback(
|
|
(val: number) => {
|
|
if (!selectedLayer) return
|
|
const partial: Partial<PipLayer> = { width: val }
|
|
if (selectedLayer.aspect_lock) {
|
|
// 保持宽高比 1:1(百分比相同)
|
|
partial.height = val
|
|
}
|
|
updateLayer(selectedLayer.id, partial)
|
|
},
|
|
[selectedLayer, updateLayer],
|
|
)
|
|
|
|
const handleHeightChange = useCallback(
|
|
(val: number) => {
|
|
if (!selectedLayer) return
|
|
const partial: Partial<PipLayer> = { height: val }
|
|
if (selectedLayer.aspect_lock) {
|
|
partial.width = val
|
|
}
|
|
updateLayer(selectedLayer.id, partial)
|
|
},
|
|
[selectedLayer, updateLayer],
|
|
)
|
|
|
|
return (
|
|
<Drawer
|
|
title="🖼️ 画中画设置"
|
|
placement="right"
|
|
width={520}
|
|
open={open}
|
|
onClose={onClose}
|
|
className="pip-config-panel-drawer"
|
|
>
|
|
{/* ═══ 顶部工具栏 ═══ */}
|
|
<div className="pip-toolbar">
|
|
<div className="pip-toolbar-left">
|
|
<button className="pip-add-btn" onClick={handleAddLayer}>
|
|
+ 添加图层
|
|
</button>
|
|
</div>
|
|
<div className="pip-enable-switch">
|
|
<span>启用</span>
|
|
<Switch size="small" checked={config.enabled} onChange={handleEnableToggle} />
|
|
</div>
|
|
</div>
|
|
|
|
{/* ═══ 主体:图层列表 + 配置区 ═══ */}
|
|
<div className="pip-body">
|
|
{/* 左侧图层列表 */}
|
|
<div className="pip-layer-list">
|
|
{config.layers.length === 0 ? (
|
|
<div className="pip-layer-empty">暂无图层,点击上方添加</div>
|
|
) : (
|
|
config.layers.map((layer, idx) => (
|
|
<div
|
|
key={layer.id}
|
|
className={`pip-layer-item${selectedId === layer.id ? " active" : ""}`}
|
|
onClick={() => setSelectedId(layer.id)}
|
|
>
|
|
{layer.thumbnail_url || layer.material_url ? (
|
|
<img
|
|
className="pip-layer-thumb"
|
|
src={layer.thumbnail_url || layer.material_url}
|
|
alt={layer.name}
|
|
/>
|
|
) : (
|
|
<div
|
|
className="pip-layer-thumb"
|
|
style={{
|
|
background: LAYER_COLORS[idx % LAYER_COLORS.length],
|
|
}}
|
|
/>
|
|
)}
|
|
<span className="pip-layer-name">{layer.name}</span>
|
|
<button
|
|
className="pip-layer-delete"
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
handleDeleteLayer(layer.id)
|
|
}}
|
|
title="删除图层"
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
|
|
{/* 右侧配置区 */}
|
|
<div className="pip-config-area">
|
|
{!selectedLayer ? (
|
|
<div className="pip-config-empty">选择或添加图层以配置</div>
|
|
) : (
|
|
<>
|
|
{/* ── 迷你预览 ── */}
|
|
<div className="pip-preview-box">
|
|
{config.layers.map((layer, idx) => (
|
|
<div
|
|
key={layer.id}
|
|
className={`pip-preview-layer${selectedId === layer.id ? " selected" : ""}`}
|
|
style={{
|
|
left: `${layer.x}%`,
|
|
top: `${layer.y}%`,
|
|
width: `${layer.width}%`,
|
|
height: `${layer.height}%`,
|
|
background: LAYER_COLORS[idx % LAYER_COLORS.length],
|
|
opacity: layer.opacity / 100,
|
|
borderRadius: `${layer.border_radius}%`,
|
|
}}
|
|
>
|
|
<span className="pip-preview-label">{layer.name}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* ── 素材类型 ── */}
|
|
<div className="pip-field">
|
|
<label className="pip-field-label">素材类型</label>
|
|
<div className="pip-type-btns">
|
|
<button
|
|
className={`pip-type-btn${selectedLayer.material_type === "image" ? " active" : ""}`}
|
|
onClick={() => updateLayer(selectedLayer.id, { material_type: "image" })}
|
|
>
|
|
🖼️ 图片
|
|
</button>
|
|
<button
|
|
className={`pip-type-btn${selectedLayer.material_type === "video" ? " active" : ""}`}
|
|
onClick={() => updateLayer(selectedLayer.id, { material_type: "video" })}
|
|
>
|
|
🎬 视频
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* ── 素材 URL ── */}
|
|
<div className="pip-field">
|
|
<label className="pip-field-label">
|
|
{selectedLayer.material_type === "image" ? "图片" : "视频"} URL
|
|
</label>
|
|
<input
|
|
className="pip-input"
|
|
type="text"
|
|
placeholder={
|
|
selectedLayer.material_type === "image"
|
|
? "https://example.com/image.png"
|
|
: "https://example.com/video.mp4"
|
|
}
|
|
value={selectedLayer.material_url}
|
|
onChange={(e) =>
|
|
updateLayer(selectedLayer.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${selectedLayer.grid_position === pos ? " active" : ""}`}
|
|
onClick={() => handleGridClick(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={selectedLayer.x}
|
|
onChange={(e) =>
|
|
updateLayer(selectedLayer.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={selectedLayer.y}
|
|
onChange={(e) =>
|
|
updateLayer(selectedLayer.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={selectedLayer.width}
|
|
onChange={(e) => handleWidthChange(Number(e.target.value))}
|
|
/>
|
|
<span className="pip-slider-value">{selectedLayer.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={selectedLayer.height}
|
|
onChange={(e) => handleHeightChange(Number(e.target.value))}
|
|
/>
|
|
<span className="pip-slider-value">{selectedLayer.height}%</span>
|
|
</div>
|
|
<div
|
|
className="pip-lock-row"
|
|
style={{ marginTop: 6 }}
|
|
onClick={() =>
|
|
updateLayer(selectedLayer.id, {
|
|
aspect_lock: !selectedLayer.aspect_lock,
|
|
})
|
|
}
|
|
>
|
|
<span className="pip-lock-icon">{selectedLayer.aspect_lock ? "🔒" : "🔓"}</span>
|
|
<span>{selectedLayer.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={selectedLayer.border_radius}
|
|
onChange={(e) =>
|
|
updateLayer(selectedLayer.id, {
|
|
border_radius: Number(e.target.value),
|
|
})
|
|
}
|
|
/>
|
|
<span className="pip-slider-value">{selectedLayer.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={selectedLayer.opacity}
|
|
onChange={(e) =>
|
|
updateLayer(selectedLayer.id, {
|
|
opacity: Number(e.target.value),
|
|
})
|
|
}
|
|
/>
|
|
<span className="pip-slider-value">{selectedLayer.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={selectedLayer.start_time}
|
|
onChange={(e) =>
|
|
updateLayer(selectedLayer.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={selectedLayer.duration}
|
|
onChange={(e) =>
|
|
updateLayer(selectedLayer.id, {
|
|
duration: Number(e.target.value),
|
|
})
|
|
}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* ── 入场动画 ── */}
|
|
<div className="pip-field">
|
|
<label className="pip-field-label">入场动画</label>
|
|
<select
|
|
className="pip-select"
|
|
value={selectedLayer.animation}
|
|
onChange={(e) =>
|
|
updateLayer(selectedLayer.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 时显示) */}
|
|
{selectedLayer.animation === "slide_in" && (
|
|
<div className="pip-field">
|
|
<label className="pip-field-label">滑入方向</label>
|
|
<select
|
|
className="pip-select"
|
|
value={selectedLayer.slide_direction}
|
|
onChange={(e) =>
|
|
updateLayer(selectedLayer.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>
|
|
</div>
|
|
|
|
{/* ── 底部操作 ── */}
|
|
<div className="pip-footer">
|
|
<button className="pip-reset-btn" onClick={handleReset}>
|
|
重置
|
|
</button>
|
|
</div>
|
|
</Drawer>
|
|
)
|
|
}
|
|
|
|
export default PipConfigPanel
|