44e6c8e10f
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com> Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
269 lines
8.7 KiB
TypeScript
269 lines
8.7 KiB
TypeScript
import React from "react"
|
|
import type { PipLayer, PipGridPosition, PipAnimType, PipSlideDirection } from "../../../types"
|
|
import { GRID_POSITIONS, ANIM_OPTIONS, SLIDE_DIR_OPTIONS } from "../constants"
|
|
|
|
export interface LayerConfigProps {
|
|
layer: PipLayer
|
|
totalDuration: number
|
|
onUpdate: (id: string, partial: Partial<PipLayer>) => void
|
|
onGridClick: (pos: PipGridPosition) => void
|
|
onWidthChange: (val: number) => void
|
|
onHeightChange: (val: number) => void
|
|
}
|
|
|
|
export const LayerConfig: React.FC<LayerConfigProps> = ({
|
|
layer,
|
|
totalDuration,
|
|
onUpdate,
|
|
onGridClick,
|
|
onWidthChange,
|
|
onHeightChange,
|
|
}) => {
|
|
return (
|
|
<div className="pip-config-area">
|
|
{/* ── 素材类型 ── */}
|
|
<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) => {
|
|
const val = Number(e.target.value)
|
|
if (!isNaN(val) && val >= 0 && val <= 100) onUpdate(layer.id, { x: val })
|
|
}}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="pip-field-label">Y (%)</label>
|
|
<input
|
|
className="pip-number"
|
|
type="number"
|
|
min={0}
|
|
max={100}
|
|
value={layer.y}
|
|
onChange={(e) => {
|
|
const val = Number(e.target.value)
|
|
if (!isNaN(val) && val >= 0 && val <= 100) onUpdate(layer.id, { y: val })
|
|
}}
|
|
/>
|
|
</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) => {
|
|
const val = Number(e.target.value)
|
|
if (!isNaN(val)) onWidthChange(val)
|
|
}}
|
|
/>
|
|
<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) => {
|
|
const val = Number(e.target.value)
|
|
if (!isNaN(val)) onHeightChange(val)
|
|
}}
|
|
/>
|
|
<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) => {
|
|
const val = Number(e.target.value)
|
|
if (!isNaN(val)) onUpdate(layer.id, { border_radius: val })
|
|
}}
|
|
/>
|
|
<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) => {
|
|
const val = Number(e.target.value)
|
|
if (!isNaN(val)) onUpdate(layer.id, { opacity: val })
|
|
}}
|
|
/>
|
|
<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) => {
|
|
const val = Number(e.target.value)
|
|
if (!isNaN(val) && val >= 0 && val <= totalDuration)
|
|
onUpdate(layer.id, { start_time: val })
|
|
}}
|
|
/>
|
|
</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) => {
|
|
const val = Number(e.target.value)
|
|
if (!isNaN(val) && val > 0 && val <= totalDuration)
|
|
onUpdate(layer.id, { duration: val })
|
|
}}
|
|
/>
|
|
</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>
|
|
)
|
|
}
|