Compare commits

..

2 Commits

Author SHA1 Message Date
xiaoxia 6d6abb338a Merge branch 'develop' into refactor/templates-api
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 15s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 33s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m4s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m4s
CI/CD Pipeline / Validate - Code Quality (pull_request) Failing after 1m44s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 28s
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m44s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 1m13s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 4m52s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 49s
AI Code Review / AI Code Review (pull_request) Successful in 4m40s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 15m36s
CI/CD Pipeline / CI Gate (pull_request) 失败: CI/CD Pipeline / Validate - Code Quality (pull_request) [frontend-only]
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 18s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 17s
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
2026-07-27 12:34:57 +08:00
xiaoxia 26e99b28cc refactor(api): 拆分 templates.ts 为目录结构(types/constants/templates/index)
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 22s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 38s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m21s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m19s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 40s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Code Quality (pull_request) Failing after 3m1s
AI Code Review / AI Code Review (pull_request) Successful in 1m27s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 42s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 2m50s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 2m29s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 25s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 45s
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 29s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
将 168 行的 templates.ts 拆分为目录化结构:
- types.ts: 类型定义(TemplateItem/TemplateSegment 等)
- constants.ts: 常量配置(分类/时长筛选选项)
- templates.ts: 全部 6 个 API 函数
- index.ts: 统一入口 re-export,保持 @/api/templates 路径向后兼容

主入口从 168 行减少到约 35 行,按职责清晰分层。
2026-07-26 17:09:47 +08:00
10 changed files with 483 additions and 520 deletions
-168
View File
@@ -1,168 +0,0 @@
/**
* 模板相关 API
* 对接后端模板管理接口:
* - GET /api/v1/templates — 模板列表(分页/筛选)
* - GET /api/v1/templates/{id} — 模板详情
* - POST /api/v1/templates/{id}/copy — 复制模板
* - POST /api/v1/templates/{id}/generate — 从模板生成
* - POST /api/v1/templates/{id}/toggle-favorite — 收藏/取消收藏
*/
import apiClient from "./client"
import type { TitleConfig, SubtitleConfig, BgmConfig } from "./editing-planner"
import type { EditPlanConfig } from "./template-editor"
/* ──────────── 类型定义 ──────────── */
/** 模板条目(后端 TemplateResponse */
export interface TemplateItem {
id: string
user_id?: string
name: string
description?: string
mode?: string
category: string
tags?: string[]
/** 预估时长(后端字段名 estimated_duration */
estimated_duration?: number
/** @deprecated 后端已改名为 estimated_duration,保留兼容 */
target_duration?: number
clip_count?: number
/** 使用次数 */
usage_count?: number
thumbnail_url?: string
preview_url?: string
is_active?: boolean
is_favorite?: boolean
/** 素材规则(片段配置) */
segments?: TemplateSegment[]
/** 字幕样式 */
subtitle_config?: SubtitleConfig
/** BGM 配置 */
bgm_config?: BgmConfig
/** 标题配置 */
title_config?: TitleConfig
/** 视频比例 */
aspect_ratio?: string
created_at?: string
updated_at?: string
}
/** 模板片段(素材规则) */
export interface TemplateSegment {
id?: string
segment_order: number
duration_min: number
duration_max: number
material_type: string | null
description?: string
}
/** 模板列表查询参数 */
export interface TemplateListParams {
page?: number
page_size?: number
category?: string
tags?: string
keyword?: string
/** 时长筛选(秒):short < 30, medium 30-120, long > 120 */
duration_range?: "short" | "medium" | "long"
}
/** 模板列表分页响应 */
export interface TemplateListResponse {
items: TemplateItem[]
total: number
page: number
page_size: number
}
/** 从模板生成请求 */
export interface GenerateFromTemplateRequest {
asset_ids?: string[]
name?: string
config?: EditPlanConfig
}
/** 从模板生成响应 */
export interface GenerateFromTemplateResponse {
plan_id: string
template_id: string
status: string
name: string
}
/** 复制模板响应 */
export interface CopyTemplateResponse {
id: string
name: string
source_template_id: string
}
/* ──────────── API 函数 ──────────── */
/** 获取模板列表(支持分页和筛选) */
export const getTemplates = async (params?: TemplateListParams): Promise<TemplateListResponse> => {
const { data } = await apiClient.get<TemplateListResponse>("/templates", {
params,
})
return data
}
/** 获取模板列表(兼容旧接口,返回数组) */
export const getTemplatesList = async (): Promise<TemplateItem[]> => {
const response = await apiClient.get("/templates")
return response.data.items || response.data || []
}
/** 获取单个模板详情 */
export const getTemplate = async (templateId: string): Promise<TemplateItem> => {
const response = await apiClient.get(`/templates/${templateId}`)
return response.data
}
/** 收藏 / 取消收藏模板 */
export const toggleFavoriteTemplate = async (
templateId: string,
): Promise<{ is_favorite: boolean }> => {
const response = await apiClient.post(`/templates/${templateId}/toggle-favorite`)
return response.data
}
/** 复制模板(创建副本到我的模板) */
export const copyTemplate = async (templateId: string): Promise<CopyTemplateResponse> => {
const response = await apiClient.post<CopyTemplateResponse>(`/templates/${templateId}/copy`)
return response.data
}
/** 从模板生成 */
export const generateFromTemplate = async (
templateId: string,
data?: GenerateFromTemplateRequest,
): Promise<GenerateFromTemplateResponse> => {
const response = await apiClient.post<GenerateFromTemplateResponse>(
`/templates/${templateId}/generate`,
data,
)
return response.data
}
/* ──────────── 常量 ──────────── */
/** 模板分类选项 */
export const TEMPLATE_CATEGORY_OPTIONS = [
{ value: "", label: "全部分类" },
{ value: "口播", label: "口播" },
{ value: "种草", label: "种草" },
{ value: "产品", label: "产品" },
{ value: "品牌", label: "品牌" },
{ value: "混剪", label: "混剪" },
{ value: "Vlog", label: "Vlog" },
]
/** 时长筛选选项 */
export const TEMPLATE_DURATION_OPTIONS = [
{ value: "", label: "全部时长" },
{ value: "short", label: "30秒以内" },
{ value: "medium", label: "30秒-2分钟" },
{ value: "long", label: "2分钟以上" },
]
+22
View File
@@ -0,0 +1,22 @@
/**
* 模板相关常量
*/
/** 模板分类选项 */
export const TEMPLATE_CATEGORY_OPTIONS = [
{ value: "", label: "全部分类" },
{ value: "口播", label: "口播" },
{ value: "种草", label: "种草" },
{ value: "产品", label: "产品" },
{ value: "品牌", label: "品牌" },
{ value: "混剪", label: "混剪" },
{ value: "Vlog", label: "Vlog" },
]
/** 时长筛选选项 */
export const TEMPLATE_DURATION_OPTIONS = [
{ value: "", label: "全部时长" },
{ value: "short", label: "30秒以内" },
{ value: "medium", label: "30秒-2分钟" },
{ value: "long", label: "2分钟以上" },
]
+28
View File
@@ -0,0 +1,28 @@
/**
* 模板相关 API — 目录化入口
* 保持与原 templates.ts 相同导出,向后兼容
*/
// 类型
export type {
TemplateItem,
TemplateSegment,
TemplateListParams,
TemplateListResponse,
GenerateFromTemplateRequest,
GenerateFromTemplateResponse,
CopyTemplateResponse,
} from "./types"
// 常量
export { TEMPLATE_CATEGORY_OPTIONS, TEMPLATE_DURATION_OPTIONS } from "./constants"
// API 函数
export {
getTemplates,
getTemplatesList,
getTemplate,
toggleFavoriteTemplate,
copyTemplate,
generateFromTemplate,
} from "./templates"
+59
View File
@@ -0,0 +1,59 @@
/**
* 模板相关 API 函数
* 对接后端模板管理接口
*/
import apiClient from "../client"
import type {
CopyTemplateResponse,
GenerateFromTemplateRequest,
GenerateFromTemplateResponse,
TemplateItem,
TemplateListParams,
TemplateListResponse,
} from "./types"
/** 获取模板列表(支持分页和筛选) */
export const getTemplates = async (params?: TemplateListParams): Promise<TemplateListResponse> => {
const { data } = await apiClient.get<TemplateListResponse>("/templates", {
params,
})
return data
}
/** 获取模板列表(兼容旧接口,返回数组) */
export const getTemplatesList = async (): Promise<TemplateItem[]> => {
const response = await apiClient.get("/templates")
return response.data.items || response.data || []
}
/** 获取单个模板详情 */
export const getTemplate = async (templateId: string): Promise<TemplateItem> => {
const response = await apiClient.get(`/templates/${templateId}`)
return response.data
}
/** 收藏 / 取消收藏模板 */
export const toggleFavoriteTemplate = async (
templateId: string,
): Promise<{ is_favorite: boolean }> => {
const response = await apiClient.post(`/templates/${templateId}/toggle-favorite`)
return response.data
}
/** 复制模板(创建副本到我的模板) */
export const copyTemplate = async (templateId: string): Promise<CopyTemplateResponse> => {
const response = await apiClient.post<CopyTemplateResponse>(`/templates/${templateId}/copy`)
return response.data
}
/** 从模板生成 */
export const generateFromTemplate = async (
templateId: string,
data?: GenerateFromTemplateRequest,
): Promise<GenerateFromTemplateResponse> => {
const response = await apiClient.post<GenerateFromTemplateResponse>(
`/templates/${templateId}/generate`,
data,
)
return response.data
}
+90
View File
@@ -0,0 +1,90 @@
/**
* 模板相关类型定义
*/
import type { TitleConfig, SubtitleConfig, BgmConfig } from "../editing-planner"
import type { EditPlanConfig } from "../template-editor"
/** 模板条目(后端 TemplateResponse */
export interface TemplateItem {
id: string
user_id?: string
name: string
description?: string
mode?: string
category: string
tags?: string[]
/** 预估时长(后端字段名 estimated_duration */
estimated_duration?: number
/** @deprecated 后端已改名为 estimated_duration,保留兼容 */
target_duration?: number
clip_count?: number
/** 使用次数 */
usage_count?: number
thumbnail_url?: string
preview_url?: string
is_active?: boolean
is_favorite?: boolean
/** 素材规则(片段配置) */
segments?: TemplateSegment[]
/** 字幕样式 */
subtitle_config?: SubtitleConfig
/** BGM 配置 */
bgm_config?: BgmConfig
/** 标题配置 */
title_config?: TitleConfig
/** 视频比例 */
aspect_ratio?: string
created_at?: string
updated_at?: string
}
/** 模板片段(素材规则) */
export interface TemplateSegment {
id?: string
segment_order: number
duration_min: number
duration_max: number
material_type: string | null
description?: string
}
/** 模板列表查询参数 */
export interface TemplateListParams {
page?: number
page_size?: number
category?: string
tags?: string
keyword?: string
/** 时长筛选(秒):short < 30, medium 30-120, long > 120 */
duration_range?: "short" | "medium" | "long"
}
/** 模板列表分页响应 */
export interface TemplateListResponse {
items: TemplateItem[]
total: number
page: number
page_size: number
}
/** 从模板生成请求 */
export interface GenerateFromTemplateRequest {
asset_ids?: string[]
name?: string
config?: EditPlanConfig
}
/** 从模板生成响应 */
export interface GenerateFromTemplateResponse {
plan_id: string
template_id: string
status: string
name: string
}
/** 复制模板响应 */
export interface CopyTemplateResponse {
id: string
name: string
source_template_id: string
}
+284 -3
View File
@@ -1,5 +1,286 @@
/**
* LayerConfig 入口(向后兼容)
* 实际实现位于 ./layer-config/ 目录
* 混剪单图层配置区
*/
export { default } from "./layer-config"
import React from "react"
import type {
PipLayer,
PipAnimType,
PipSlideDirection,
PipGridPosition,
} from "@/pages/editing-planner/types"
import {
GRID_POSITIONS,
ANIM_OPTIONS,
SLIDE_DIR_OPTIONS,
LAYER_COLORS,
} from "@/pages/editing-planner/constants/pipConfig"
interface LayerConfigProps {
layer: PipLayer | null
layers: PipLayer[]
totalDuration: number
onUpdate: (id: string, partial: Partial<PipLayer>) => void
onGridClick: (pos: PipGridPosition) => void
onWidthChange: (val: number) => void
onHeightChange: (val: number) => void
}
const LayerConfig: React.FC<LayerConfigProps> = ({
layer,
layers,
totalDuration,
onUpdate,
onGridClick,
onWidthChange,
onHeightChange,
}) => {
if (!layer) {
return (
<div className="pip-config-area">
<div className="pip-config-empty"></div>
</div>
)
}
return (
<div className="pip-config-area">
{/* ── 迷你预览 ── */}
<div className="pip-preview-box">
{layers.map((l, idx) => (
<div
key={l.id}
className={`pip-preview-layer${layer.id === l.id ? " selected" : ""}`}
style={{
left: `${l.x}%`,
top: `${l.y}%`,
width: `${l.width}%`,
height: `${l.height}%`,
background: LAYER_COLORS[idx % LAYER_COLORS.length],
opacity: l.opacity / 100,
borderRadius: `${l.border_radius}%`,
}}
>
<span className="pip-preview-label">{l.name}</span>
</div>
))}
</div>
{/* ── 素材类型 ── */}
<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) => onUpdate(layer.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={layer.y}
onChange={(e) => onUpdate(layer.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={layer.width}
onChange={(e) => onWidthChange(Number(e.target.value))}
/>
<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) => onHeightChange(Number(e.target.value))}
/>
<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) => onUpdate(layer.id, { border_radius: Number(e.target.value) })}
/>
<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) => onUpdate(layer.id, { opacity: Number(e.target.value) })}
/>
<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) => onUpdate(layer.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={layer.duration}
onChange={(e) => onUpdate(layer.id, { duration: Number(e.target.value) })}
/>
</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>
)
}
export default LayerConfig
@@ -1,172 +0,0 @@
import React from "react"
import type { PipLayer, PipGridPosition } from "@/pages/editing-planner/types"
import { GRID_POSITIONS } from "@/pages/editing-planner/constants/pipConfig"
interface LayerPositionSizeProps {
layer: PipLayer
onUpdate: (id: string, partial: Partial<PipLayer>) => void
onGridClick: (pos: PipGridPosition) => void
onWidthChange: (val: number) => void
onHeightChange: (val: number) => void
}
/**
* 图层位置与尺寸配置面板
*/
export const LayerPositionSize: React.FC<LayerPositionSizeProps> = ({
layer,
onUpdate,
onGridClick,
onWidthChange,
onHeightChange,
}) => (
<>
{/* 素材类型 */}
<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) => onUpdate(layer.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={layer.y}
onChange={(e) => onUpdate(layer.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={layer.width}
onChange={(e) => onWidthChange(Number(e.target.value))}
/>
<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) => onHeightChange(Number(e.target.value))}
/>
<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) => onUpdate(layer.id, { border_radius: Number(e.target.value) })}
/>
<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) => onUpdate(layer.id, { opacity: Number(e.target.value) })}
/>
<span className="pip-slider-value">{layer.opacity}%</span>
</div>
</div>
</>
)
@@ -1,87 +0,0 @@
import React from "react"
import type { PipLayer, PipAnimType, PipSlideDirection } from "@/pages/editing-planner/types"
import { ANIM_OPTIONS, SLIDE_DIR_OPTIONS } from "@/pages/editing-planner/constants/pipConfig"
interface LayerTimingAnimationProps {
layer: PipLayer
totalDuration: number
onUpdate: (id: string, partial: Partial<PipLayer>) => void
}
/**
* 图层时间与动画配置面板
*/
export const LayerTimingAnimation: React.FC<LayerTimingAnimationProps> = ({
layer,
totalDuration,
onUpdate,
}) => (
<>
{/* 时间 */}
<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) => onUpdate(layer.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={layer.duration}
onChange={(e) => onUpdate(layer.id, { duration: Number(e.target.value) })}
/>
</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>
)}
</>
)
@@ -1,33 +0,0 @@
import React from "react"
import type { PipLayer } from "@/pages/editing-planner/types"
import { LAYER_COLORS } from "@/pages/editing-planner/constants/pipConfig"
interface PipPreviewProps {
layers: PipLayer[]
selectedId: string
}
/**
* PIP 图层迷你预览组件
*/
export const PipPreview: React.FC<PipPreviewProps> = ({ layers, selectedId }) => (
<div className="pip-preview-box">
{layers.map((l, idx) => (
<div
key={l.id}
className={`pip-preview-layer${selectedId === l.id ? " selected" : ""}`}
style={{
left: `${l.x}%`,
top: `${l.y}%`,
width: `${l.width}%`,
height: `${l.height}%`,
background: LAYER_COLORS[idx % LAYER_COLORS.length],
opacity: l.opacity / 100,
borderRadius: `${l.border_radius}%`,
}}
>
<span className="pip-preview-label">{l.name}</span>
</div>
))}
</div>
)
@@ -1,57 +0,0 @@
/**
* 混剪单图层配置区
*/
import React from "react"
import type { PipLayer, PipGridPosition } from "@/pages/editing-planner/types"
import { PipPreview } from "./PipPreview"
import { LayerPositionSize } from "./LayerPositionSize"
import { LayerTimingAnimation } from "./LayerTimingAnimation"
interface LayerConfigProps {
layer: PipLayer | null
layers: PipLayer[]
totalDuration: number
onUpdate: (id: string, partial: Partial<PipLayer>) => void
onGridClick: (pos: PipGridPosition) => void
onWidthChange: (val: number) => void
onHeightChange: (val: number) => void
}
const LayerConfig: React.FC<LayerConfigProps> = ({
layer,
layers,
totalDuration,
onUpdate,
onGridClick,
onWidthChange,
onHeightChange,
}) => {
if (!layer) {
return (
<div className="pip-config-area">
<div className="pip-config-empty"></div>
</div>
)
}
return (
<div className="pip-config-area">
{/* 迷你预览 */}
<PipPreview layers={layers} selectedId={layer.id} />
{/* 位置与尺寸 */}
<LayerPositionSize
layer={layer}
onUpdate={onUpdate}
onGridClick={onGridClick}
onWidthChange={onWidthChange}
onHeightChange={onHeightChange}
/>
{/* 时间与动画 */}
<LayerTimingAnimation layer={layer} totalDuration={totalDuration} onUpdate={onUpdate} />
</div>
)
}
export default LayerConfig