diff --git a/apps/web/src/pages/editing-planner/components/PipConfigPanel/components/LayerConfig.tsx b/apps/web/src/pages/editing-planner/components/PipConfigPanel/components/LayerConfig.tsx new file mode 100644 index 000000000..1af03d1e4 --- /dev/null +++ b/apps/web/src/pages/editing-planner/components/PipConfigPanel/components/LayerConfig.tsx @@ -0,0 +1,268 @@ +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) => void + onGridClick: (pos: PipGridPosition) => void + onWidthChange: (val: number) => void + onHeightChange: (val: number) => void +} + +export const LayerConfig: React.FC = ({ + layer, + totalDuration, + onUpdate, + onGridClick, + onWidthChange, + onHeightChange, +}) => { + return ( +
+ {/* ── 素材类型 ── */} +
+ +
+ + +
+
+ + {/* ── 素材 URL ── */} +
+ + onUpdate(layer.id, { material_url: e.target.value })} + /> +
+ + {/* ── 位置:九宫格 + 坐标 ── */} +
+ +
+
+ {GRID_POSITIONS.map((pos) => ( + + ))} +
+
+
+ + { + const val = Number(e.target.value) + if (!isNaN(val) && val >= 0 && val <= 100) onUpdate(layer.id, { x: val }) + }} + /> +
+
+ + { + const val = Number(e.target.value) + if (!isNaN(val) && val >= 0 && val <= 100) onUpdate(layer.id, { y: val }) + }} + /> +
+
+
+
+ + {/* ── 尺寸 ── */} +
+ +
+ + { + const val = Number(e.target.value) + if (!isNaN(val)) onWidthChange(val) + }} + /> + {layer.width}% +
+
+ + { + const val = Number(e.target.value) + if (!isNaN(val)) onHeightChange(val) + }} + /> + {layer.height}% +
+
onUpdate(layer.id, { aspect_lock: !layer.aspect_lock })} + > + {layer.aspect_lock ? "🔒" : "🔓"} + {layer.aspect_lock ? "已锁定比例" : "锁定宽高比"} +
+
+ + {/* ── 圆角 ── */} +
+ +
+ { + const val = Number(e.target.value) + if (!isNaN(val)) onUpdate(layer.id, { border_radius: val }) + }} + /> + {layer.border_radius}% +
+
+ + {/* ── 透明度 ── */} +
+ +
+ { + const val = Number(e.target.value) + if (!isNaN(val)) onUpdate(layer.id, { opacity: val }) + }} + /> + {layer.opacity}% +
+
+ + {/* ── 时间 ── */} +
+ +
+
+ + { + const val = Number(e.target.value) + if (!isNaN(val) && val >= 0 && val <= totalDuration) + onUpdate(layer.id, { start_time: val }) + }} + /> +
+
+ + { + const val = Number(e.target.value) + if (!isNaN(val) && val > 0 && val <= totalDuration) + onUpdate(layer.id, { duration: val }) + }} + /> +
+
+
+ + {/* ── 入场动画 ── */} +
+ + +
+ + {/* 滑入方向(仅 slide_in 时显示) */} + {layer.animation === "slide_in" && ( +
+ + +
+ )} +
+ ) +} diff --git a/apps/web/src/pages/editing-planner/components/PipConfigPanel/components/LayerList.tsx b/apps/web/src/pages/editing-planner/components/PipConfigPanel/components/LayerList.tsx new file mode 100644 index 000000000..135d507d6 --- /dev/null +++ b/apps/web/src/pages/editing-planner/components/PipConfigPanel/components/LayerList.tsx @@ -0,0 +1,58 @@ +import React from "react" +import type { PipLayer } from "../../../types" +import { LAYER_COLORS } from "../constants" + +export interface LayerListProps { + layers: PipLayer[] + selectedId: string + onSelect: (id: string) => void + onDelete: (id: string) => void +} + +export const LayerList: React.FC = ({ layers, selectedId, onSelect, onDelete }) => { + if (layers.length === 0) { + return ( +
+
暂无图层,点击上方添加
+
+ ) + } + + return ( +
+ {layers.map((layer, idx) => ( +
onSelect(layer.id)} + > + {layer.thumbnail_url || layer.material_url ? ( + {layer.name} + ) : ( +
+ )} + {layer.name} + +
+ ))} +
+ ) +} diff --git a/apps/web/src/pages/editing-planner/components/PipConfigPanel/components/PipPreview.tsx b/apps/web/src/pages/editing-planner/components/PipConfigPanel/components/PipPreview.tsx new file mode 100644 index 000000000..e29eb6d2f --- /dev/null +++ b/apps/web/src/pages/editing-planner/components/PipConfigPanel/components/PipPreview.tsx @@ -0,0 +1,33 @@ +import React from "react" +import type { PipLayer } from "../../../types" +import { LAYER_COLORS } from "../constants" + +export interface PipPreviewProps { + layers: PipLayer[] + selectedId: string +} + +export const PipPreview: React.FC = ({ layers, selectedId }) => { + return ( +
+ {layers.map((layer, idx) => ( +
+ {layer.name} +
+ ))} +
+ ) +} diff --git a/apps/web/src/pages/editing-planner/components/PipConfigPanel/constants.ts b/apps/web/src/pages/editing-planner/components/PipConfigPanel/constants.ts new file mode 100644 index 000000000..a15bd7589 --- /dev/null +++ b/apps/web/src/pages/editing-planner/components/PipConfigPanel/constants.ts @@ -0,0 +1,55 @@ +import type { PipGridPosition, PipAnimType, PipSlideDirection } from "../../types" + +/** 九宫格位置 → 百分比坐标映射 */ +export const GRID_POSITION_MAP: Record = { + 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 }, +} + +/** 九宫格位置选项 */ +export const GRID_POSITIONS: PipGridPosition[] = [ + "top_left", + "top_center", + "top_right", + "center_left", + "center", + "center_right", + "bottom_left", + "bottom_center", + "bottom_right", +] + +/** 入场动画选项 */ +export const ANIM_OPTIONS: { value: PipAnimType; label: string }[] = [ + { value: "none", label: "无" }, + { value: "fade_in", label: "淡入" }, + { value: "slide_in", label: "滑入" }, +] + +/** 滑入方向选项 */ +export const SLIDE_DIR_OPTIONS: { value: PipSlideDirection; label: string }[] = [ + { value: "left", label: "← 左" }, + { value: "right", label: "→ 右" }, + { value: "up", label: "↑ 上" }, + { value: "down", label: "↓ 下" }, +] + +/** 预览图层颜色池 */ +export 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)", +] + +let layerIdCounter = 0 +export const genLayerId = () => `pip_layer_${Date.now()}_${++layerIdCounter}` diff --git a/apps/web/src/pages/editing-planner/components/PipConfigPanel/hooks/usePipConfigPanel.ts b/apps/web/src/pages/editing-planner/components/PipConfigPanel/hooks/usePipConfigPanel.ts new file mode 100644 index 000000000..80378c261 --- /dev/null +++ b/apps/web/src/pages/editing-planner/components/PipConfigPanel/hooks/usePipConfigPanel.ts @@ -0,0 +1,130 @@ +import { useState, useCallback, useMemo } from "react" +import type { PipConfig, PipLayer, PipGridPosition } from "../../../types" +import { DEFAULT_PIP_LAYER, DEFAULT_PIP_CONFIG } from "../../../types" +import { GRID_POSITION_MAP, genLayerId } from "../constants" + +const MIN_DIMENSION = 10 +const MAX_DIMENSION = 80 + +interface UsePipConfigPanelOptions { + config: PipConfig + onChange: (config: PipConfig) => void +} + +export const usePipConfigPanel = ({ config, onChange }: UsePipConfigPanelOptions) => { + const [selectedId, setSelectedId] = useState("") + + 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) => { + 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 = { width: val } + if (selectedLayer.aspect_lock && selectedLayer.height > 0) { + const ratio = selectedLayer.width / selectedLayer.height + const newHeight = Math.round(val / ratio) + partial.height = Math.max(MIN_DIMENSION, Math.min(MAX_DIMENSION, newHeight)) + } + updateLayer(selectedLayer.id, partial) + }, + [selectedLayer, updateLayer], + ) + + const handleHeightChange = useCallback( + (val: number) => { + if (!selectedLayer) return + const partial: Partial = { height: val } + if (selectedLayer.aspect_lock && selectedLayer.width > 0 && selectedLayer.height > 0) { + const ratio = selectedLayer.width / selectedLayer.height + const newWidth = Math.round(val * ratio) + partial.width = Math.max(MIN_DIMENSION, Math.min(MAX_DIMENSION, newWidth)) + } + updateLayer(selectedLayer.id, partial) + }, + [selectedLayer, updateLayer], + ) + + return { + selectedId, + setSelectedId, + selectedLayer, + handleAddLayer, + handleDeleteLayer, + updateLayer, + handleEnableToggle, + handleReset, + handleGridClick, + handleWidthChange, + handleHeightChange, + } +} diff --git a/apps/web/src/pages/editing-planner/components/PipConfigPanel.tsx b/apps/web/src/pages/editing-planner/components/PipConfigPanel/index.tsx similarity index 60% rename from apps/web/src/pages/editing-planner/components/PipConfigPanel.tsx rename to apps/web/src/pages/editing-planner/components/PipConfigPanel/index.tsx index 0c9240ac6..81867fde1 100644 --- a/apps/web/src/pages/editing-planner/components/PipConfigPanel.tsx +++ b/apps/web/src/pages/editing-planner/components/PipConfigPanel/index.tsx @@ -4,12 +4,14 @@ */ import React from "react" import { Drawer, Switch } from "antd" -import type { PipConfig } from "@/pages/editing-planner/types" -import LayerList from "./pip-config/LayerList" -import LayerConfig from "./pip-config/LayerConfig" -import { usePipLayers } from "@/pages/editing-planner/hooks/usePipLayers" +import type { PipConfig } from "../../types" -interface PipConfigPanelProps { +import { usePipConfigPanel } from "./hooks/usePipConfigPanel" +import { LayerList } from "./components/LayerList" +import { PipPreview } from "./components/PipPreview" +import { LayerConfig } from "./components/LayerConfig" + +export interface PipConfigPanelProps { open: boolean onClose: () => void config: PipConfig @@ -36,7 +38,7 @@ const PipConfigPanel: React.FC = ({ handleGridClick, handleWidthChange, handleHeightChange, - } = usePipLayers({ config, onChange }) + } = usePipConfigPanel({ config, onChange }) return ( = ({ {/* ═══ 顶部工具栏 ═══ */}
- 共 {config.layers.length} 个图层 +
启用 @@ -60,22 +64,32 @@ const PipConfigPanel: React.FC = ({ {/* ═══ 主体:图层列表 + 配置区 ═══ */}
+ {/* 左侧图层列表 */} - + + {/* 右侧配置区 */} + {!selectedLayer ? ( +
+
选择或添加图层以配置
+
+ ) : ( + <> + + + + )}
{/* ── 底部操作 ── */} diff --git a/apps/web/src/test/pages/editing-planner/components/PipConfigPanel/components/LayerConfig.test.tsx b/apps/web/src/test/pages/editing-planner/components/PipConfigPanel/components/LayerConfig.test.tsx new file mode 100644 index 000000000..145d92f00 --- /dev/null +++ b/apps/web/src/test/pages/editing-planner/components/PipConfigPanel/components/LayerConfig.test.tsx @@ -0,0 +1,11 @@ +/** + * Smoke test for LayerConfig.test + */ +import { describe, it, expect } from "vitest" +import "@/pages/editing-planner/components/PipConfigPanel/components/LayerConfig" + +describe("LayerConfig.test smoke", () => { + it("should load module successfully", () => { + expect(true).toBe(true) + }) +}) diff --git a/apps/web/src/test/pages/editing-planner/components/PipConfigPanel/components/LayerList.test.tsx b/apps/web/src/test/pages/editing-planner/components/PipConfigPanel/components/LayerList.test.tsx new file mode 100644 index 000000000..9f8a6f18c --- /dev/null +++ b/apps/web/src/test/pages/editing-planner/components/PipConfigPanel/components/LayerList.test.tsx @@ -0,0 +1,11 @@ +/** + * Smoke test for LayerList.test + */ +import { describe, it, expect } from "vitest" +import "@/pages/editing-planner/components/PipConfigPanel/components/LayerList" + +describe("LayerList.test smoke", () => { + it("should load module successfully", () => { + expect(true).toBe(true) + }) +}) diff --git a/apps/web/src/test/pages/editing-planner/components/PipConfigPanel/components/PipPreview.test.tsx b/apps/web/src/test/pages/editing-planner/components/PipConfigPanel/components/PipPreview.test.tsx new file mode 100644 index 000000000..ac9b4c14d --- /dev/null +++ b/apps/web/src/test/pages/editing-planner/components/PipConfigPanel/components/PipPreview.test.tsx @@ -0,0 +1,11 @@ +/** + * Smoke test for PipPreview.test + */ +import { describe, it, expect } from "vitest" +import "@/pages/editing-planner/components/PipConfigPanel/components/PipPreview" + +describe("PipPreview.test smoke", () => { + it("should load module successfully", () => { + expect(true).toBe(true) + }) +}) diff --git a/apps/web/src/test/pages/editing-planner/components/PipConfigPanel/constants.test.ts b/apps/web/src/test/pages/editing-planner/components/PipConfigPanel/constants.test.ts new file mode 100644 index 000000000..0414e6a2b --- /dev/null +++ b/apps/web/src/test/pages/editing-planner/components/PipConfigPanel/constants.test.ts @@ -0,0 +1,11 @@ +/** + * Smoke test for constants.test + */ +import { describe, it, expect } from "vitest" +import "@/pages/editing-planner/components/PipConfigPanel/constants" + +describe("constants.test smoke", () => { + it("should load module successfully", () => { + expect(true).toBe(true) + }) +}) diff --git a/apps/web/src/test/pages/editing-planner/components/PipConfigPanel/hooks/usePipConfigPanel.test.ts b/apps/web/src/test/pages/editing-planner/components/PipConfigPanel/hooks/usePipConfigPanel.test.ts new file mode 100644 index 000000000..9d515dece --- /dev/null +++ b/apps/web/src/test/pages/editing-planner/components/PipConfigPanel/hooks/usePipConfigPanel.test.ts @@ -0,0 +1,11 @@ +/** + * Smoke test for usePipConfigPanel.test + */ +import { describe, it, expect } from "vitest" +import "@/pages/editing-planner/components/PipConfigPanel/hooks/usePipConfigPanel" + +describe("usePipConfigPanel.test smoke", () => { + it("should load module successfully", () => { + expect(true).toBe(true) + }) +})