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
310 lines
10 KiB
TypeScript
310 lines
10 KiB
TypeScript
/**
|
||
* 片头片尾配置面板 — 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;
|