Files
xiaoxia-saas/apps/web/src/pages/editing-planner/components/IntroOutroPanel.tsx
T
xiaoxia 78d1c88ca1
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 1m44s
CI/CD Pipeline / Unit Tests (push) Successful in 1m44s
CI/CD Pipeline / Frontend Lint (push) Successful in 2m28s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 1m8s
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web 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
feat: 剪辑规划器完整交互 — 7大配置面板 + 时间轴拖拽/缩放/播放头 + 预览播放器 (#284)
2026-07-14 18:15:55 +08:00

310 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 片头片尾配置面板 — Drawer 形式
* 两个区块:片头(Intro)/ 片尾(Outro
* 每个区块支持:类型选择(无/视频/图片)、素材 URL、时长、过渡动画
*/
import React, { useCallback } from "react";
import { Drawer } from "antd";
import type {
IntroOutroConfig,
IntroOutroItem,
IntroOutroKind,
TransitionType,
} from "../types";
import { DEFAULT_INTRO_OUTRO } from "../types";
import { TRANSITION_OPTIONS } from "@/api/editPlans";
/* ──────────── 常量 ──────────── */
const KIND_OPTIONS: { value: IntroOutroKind; label: string; icon: string }[] = [
{ value: "none", label: "无", icon: "🚫" },
{ value: "video", label: "视频", icon: "🎬" },
{ value: "image", label: "图片", icon: "🖼️" },
];
/* ──────────── Props ──────────── */
interface IntroOutroPanelProps {
open: boolean;
onClose: () => void;
config: IntroOutroConfig;
onChange: (config: IntroOutroConfig) => void;
}
const IntroOutroPanel: React.FC<IntroOutroPanelProps> = ({
open,
onClose,
config,
onChange,
}) => {
/* ── 更新片头 ── */
const handleIntroChange = useCallback(
(partial: Partial<IntroOutroItem>) => {
onChange({ ...config, intro: { ...config.intro, ...partial } });
},
[config, onChange],
);
/* ── 更新片尾 ── */
const handleOutroChange = useCallback(
(partial: Partial<IntroOutroItem>) => {
onChange({ ...config, outro: { ...config.outro, ...partial } });
},
[config, onChange],
);
/* ── 切换片头类型 ── */
const handleIntroKindChange = useCallback(
(kind: IntroOutroKind) => {
handleIntroChange({ kind, url: kind === "none" ? undefined : "" });
},
[handleIntroChange],
);
/* ── 切换片尾类型 ── */
const handleOutroKindChange = useCallback(
(kind: IntroOutroKind) => {
handleOutroChange({ kind, url: kind === "none" ? undefined : "" });
},
[handleOutroChange],
);
/* ── 重置 ── */
const handleReset = useCallback(() => {
onChange({ ...DEFAULT_INTRO_OUTRO });
}, [onChange]);
return (
<Drawer
title="🎬 片头片尾设置"
placement="right"
width={420}
open={open}
onClose={onClose}
className="intro-outro-panel-drawer"
>
{/* ═══ 片头区块 ═══ */}
<div className="iop-block">
<div className="iop-block-header">
<span className="iop-block-icon">🎞️</span>
<span className="iop-block-title">片头</span>
</div>
{/* 类型选择 */}
<div className="iop-kind-row">
{KIND_OPTIONS.map((opt) => (
<button
key={opt.value}
className={`iop-kind-btn${config.intro.kind === opt.value ? " active" : ""}`}
onClick={() => handleIntroKindChange(opt.value)}
>
<span className="iop-kind-icon">{opt.icon}</span>
<span className="iop-kind-label">{opt.label}</span>
</button>
))}
</div>
{/* 视频/图片配置 */}
{config.intro.kind !== "none" && (
<>
<div className="iop-field">
<label className="iop-field-label">
{config.intro.kind === "video" ? "视频" : "图片"} URL
</label>
<input
className="iop-input"
type="text"
placeholder={
config.intro.kind === "video"
? "https://example.com/intro.mp4"
: "https://example.com/intro.png"
}
value={config.intro.url ?? ""}
onChange={(e) => handleIntroChange({ url: e.target.value })}
/>
</div>
<div className="iop-field">
<label className="iop-field-label">显示时长</label>
<div className="iop-slider-row">
<input
className="iop-slider"
type="range"
min={1}
max={15}
step={0.5}
value={config.intro.duration}
onChange={(e) =>
handleIntroChange({ duration: Number(e.target.value) })
}
/>
<span className="iop-slider-value">
{config.intro.duration}s
</span>
</div>
</div>
<div className="iop-field">
<label className="iop-field-label">进入过渡动画</label>
<select
className="iop-select"
value={config.intro.transition ?? "none"}
onChange={(e) =>
handleIntroChange({
transition: e.target.value as TransitionType,
})
}
>
{TRANSITION_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
{config.intro.transition && config.intro.transition !== "none" && (
<div className="iop-field">
<label className="iop-field-label">过渡时长</label>
<div className="iop-slider-row">
<input
className="iop-slider"
type="range"
min={0.3}
max={2.0}
step={0.1}
value={config.intro.transition_duration ?? 0.5}
onChange={(e) =>
handleIntroChange({
transition_duration: Number(e.target.value),
})
}
/>
<span className="iop-slider-value">
{(config.intro.transition_duration ?? 0.5).toFixed(1)}s
</span>
</div>
</div>
)}
</>
)}
</div>
{/* ═══ 片尾区块 ═══ */}
<div className="iop-block">
<div className="iop-block-header">
<span className="iop-block-icon">🏁</span>
<span className="iop-block-title">片尾</span>
</div>
{/* 类型选择 */}
<div className="iop-kind-row">
{KIND_OPTIONS.map((opt) => (
<button
key={opt.value}
className={`iop-kind-btn${config.outro.kind === opt.value ? " active" : ""}`}
onClick={() => handleOutroKindChange(opt.value)}
>
<span className="iop-kind-icon">{opt.icon}</span>
<span className="iop-kind-label">{opt.label}</span>
</button>
))}
</div>
{/* 视频/图片配置 */}
{config.outro.kind !== "none" && (
<>
<div className="iop-field">
<label className="iop-field-label">
{config.outro.kind === "video" ? "视频" : "图片"} URL
</label>
<input
className="iop-input"
type="text"
placeholder={
config.outro.kind === "video"
? "https://example.com/outro.mp4"
: "https://example.com/outro.png"
}
value={config.outro.url ?? ""}
onChange={(e) => handleOutroChange({ url: e.target.value })}
/>
</div>
<div className="iop-field">
<label className="iop-field-label">显示时长</label>
<div className="iop-slider-row">
<input
className="iop-slider"
type="range"
min={1}
max={15}
step={0.5}
value={config.outro.duration}
onChange={(e) =>
handleOutroChange({ duration: Number(e.target.value) })
}
/>
<span className="iop-slider-value">
{config.outro.duration}s
</span>
</div>
</div>
<div className="iop-field">
<label className="iop-field-label">退出过渡动画</label>
<select
className="iop-select"
value={config.outro.transition ?? "none"}
onChange={(e) =>
handleOutroChange({
transition: e.target.value as TransitionType,
})
}
>
{TRANSITION_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
{config.outro.transition && config.outro.transition !== "none" && (
<div className="iop-field">
<label className="iop-field-label">过渡时长</label>
<div className="iop-slider-row">
<input
className="iop-slider"
type="range"
min={0.3}
max={2.0}
step={0.1}
value={config.outro.transition_duration ?? 0.5}
onChange={(e) =>
handleOutroChange({
transition_duration: Number(e.target.value),
})
}
/>
<span className="iop-slider-value">
{(config.outro.transition_duration ?? 0.5).toFixed(1)}s
</span>
</div>
</div>
)}
</>
)}
</div>
{/* ── 底部操作 ── */}
<div className="iop-footer">
<button className="iop-reset-btn" onClick={handleReset}>
重置
</button>
</div>
</Drawer>
);
};
export default IntroOutroPanel;