0fcb77b991
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Successful in 1m6s
CI Build & Deploy Pipeline / Build Staging Web Image (push) Successful in 1m49s
CI Build & Deploy Pipeline / Build Production API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (push) Has been skipped
CI Build & Deploy Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 3m1s
CI/CD Pipeline / Unit Tests (push) Successful in 3m20s
CI/CD Pipeline / Integration Tests (push) Successful in 1m29s
CI Build & Deploy Pipeline / Build Staging API Image (push) Successful in 7m4s
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Successful in 7m54s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 45s
CI Build & Deploy Pipeline / Staging E2E Tests (push) Failing after 2m50s
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Successful in 3m28s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com> Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
622 lines
19 KiB
TypeScript
Executable File
622 lines
19 KiB
TypeScript
Executable File
/**
|
|
* 剪辑计划片段管理页面
|
|
* 对接后端 PR#389 片段 CRUD API
|
|
* 功能:列表查看、创建、编辑、删除、批量删除、拖拽排序、从素材导入
|
|
*/
|
|
import { useState, useCallback } from "react"
|
|
import { useParams, useNavigate } from "react-router-dom"
|
|
import { useQuery, useQueryClient } from "@tanstack/react-query"
|
|
import {
|
|
Table,
|
|
Button,
|
|
Space,
|
|
message,
|
|
Popconfirm,
|
|
Modal,
|
|
Form,
|
|
Input,
|
|
InputNumber,
|
|
Select,
|
|
Tag,
|
|
Drawer,
|
|
Empty,
|
|
Card,
|
|
} from "antd"
|
|
import {
|
|
ArrowLeftOutlined,
|
|
PlusOutlined,
|
|
DeleteOutlined,
|
|
EditOutlined,
|
|
UploadOutlined,
|
|
OrderedListOutlined,
|
|
SaveOutlined,
|
|
} from "@ant-design/icons"
|
|
import type { ColumnsType } from "antd/es/table"
|
|
import {
|
|
getEditPlan,
|
|
getEditPlanClips,
|
|
createEditPlanClip,
|
|
updateEditPlanClip,
|
|
deleteEditPlanClip,
|
|
batchDeleteEditPlanClips,
|
|
reorderEditPlanClips,
|
|
createClipsFromAssets,
|
|
getMediaAssets,
|
|
type EditPlanClip,
|
|
type EditPlanClipStatus,
|
|
} from "@/api/editPlans"
|
|
import "./plan-clips.css"
|
|
|
|
/* ──────────── 常量 ──────────── */
|
|
|
|
const CLIP_TYPE_OPTIONS = [
|
|
{ value: "main", label: "主片段" },
|
|
{ value: "intro", label: "片头" },
|
|
{ value: "outro", label: "片尾" },
|
|
{ value: "overlay", label: "叠加层" },
|
|
{ value: "background", label: "背景" },
|
|
{ value: "b_roll", label: "B-roll" },
|
|
]
|
|
|
|
const STATUS_COLORS: Record<EditPlanClipStatus, string> = {
|
|
pending: "default",
|
|
processing: "processing",
|
|
ready: "success",
|
|
failed: "error",
|
|
}
|
|
|
|
const STATUS_LABELS: Record<EditPlanClipStatus, string> = {
|
|
pending: "待处理",
|
|
processing: "处理中",
|
|
ready: "就绪",
|
|
failed: "失败",
|
|
}
|
|
|
|
const TRANSITION_OPTIONS = [
|
|
{ value: "cut", label: "硬切" },
|
|
{ value: "fade", label: "淡入淡出" },
|
|
{ value: "dissolve", label: "溶解" },
|
|
{ value: "zoom", label: "缩放" },
|
|
{ value: "slide_left", label: "左滑" },
|
|
{ value: "slide_right", label: "右滑" },
|
|
{ value: "slide_up", label: "上滑" },
|
|
{ value: "slide_down", label: "下滑" },
|
|
{ value: "wipe_left", label: "左擦除" },
|
|
{ value: "wipe_right", label: "右擦除" },
|
|
{ value: "wipe_up", label: "上擦除" },
|
|
{ value: "wipe_down", label: "下擦除" },
|
|
{ value: "circlecrop", label: "圆形裁切" },
|
|
{ value: "rectcrop", label: "矩形裁切" },
|
|
]
|
|
|
|
/* ──────────── 组件 ──────────── */
|
|
|
|
const PlanClipsManager: React.FC = () => {
|
|
const { planId } = useParams<{ planId: string }>()
|
|
const navigate = useNavigate()
|
|
const queryClient = useQueryClient()
|
|
|
|
/* ── 计划信息 ── */
|
|
const { data: plan, isLoading: planLoading } = useQuery({
|
|
queryKey: ["editPlan", planId],
|
|
queryFn: () => getEditPlan(planId!),
|
|
enabled: !!planId,
|
|
})
|
|
|
|
/* ── 片段列表 ── */
|
|
const { data: clipsData, isLoading: clipsLoading } = useQuery({
|
|
queryKey: ["editPlanClips", planId],
|
|
queryFn: () => getEditPlanClips(planId!, { limit: 500 }),
|
|
enabled: !!planId,
|
|
})
|
|
|
|
const clips = clipsData?.items ?? []
|
|
|
|
/* ── 选中的片段(批量操作) ── */
|
|
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([])
|
|
|
|
/* ── 编辑弹窗 ── */
|
|
const [editModalOpen, setEditModalOpen] = useState(false)
|
|
const [editingClip, setEditingClip] = useState<EditPlanClip | null>(null)
|
|
const [editForm] = Form.useForm()
|
|
const [editLoading, setEditLoading] = useState(false)
|
|
|
|
/* ── 素材导入抽屉 ── */
|
|
const [importDrawerOpen, setImportDrawerOpen] = useState(false)
|
|
const [selectedAssetIds, setSelectedAssetIds] = useState<string[]>([])
|
|
const [importLoading, setImportLoading] = useState(false)
|
|
|
|
const { data: assets } = useQuery({
|
|
queryKey: ["mediaAssets"],
|
|
queryFn: () => getMediaAssets(),
|
|
enabled: importDrawerOpen,
|
|
})
|
|
|
|
/* ── 重新排序模式 ── */
|
|
const [reorderMode, setReorderMode] = useState(false)
|
|
const [reorderItems, setReorderItems] = useState<EditPlanClip[]>([])
|
|
|
|
/* ── 列定义 ── */
|
|
const columns: ColumnsType<EditPlanClip> = [
|
|
{
|
|
title: "序号",
|
|
dataIndex: "order",
|
|
width: 70,
|
|
render: (_, __, index) => index + 1,
|
|
},
|
|
{
|
|
title: "类型",
|
|
dataIndex: "clip_type",
|
|
width: 100,
|
|
render: (type: string) => {
|
|
const opt = CLIP_TYPE_OPTIONS.find((o) => o.value === type)
|
|
return <Tag>{opt?.label || type}</Tag>
|
|
},
|
|
},
|
|
{
|
|
title: "素材",
|
|
dataIndex: "asset_id",
|
|
width: 150,
|
|
ellipsis: true,
|
|
render: (assetId: string) =>
|
|
assetId ? (
|
|
<code className="clip-asset-id">{assetId.slice(0, 12)}...</code>
|
|
) : (
|
|
<span style={{ color: "#999" }}>无素材</span>
|
|
),
|
|
},
|
|
{
|
|
title: "文本内容",
|
|
dataIndex: "text_content",
|
|
ellipsis: true,
|
|
render: (text: string) => text || <span style={{ color: "#999" }}>-</span>,
|
|
},
|
|
{
|
|
title: "时长",
|
|
dataIndex: "duration",
|
|
width: 90,
|
|
render: (d: number) => `${d?.toFixed(1) || 0}s`,
|
|
},
|
|
{
|
|
title: "转场",
|
|
dataIndex: "transition_effect",
|
|
width: 100,
|
|
render: (effect: string) => {
|
|
const opt = TRANSITION_OPTIONS.find((o) => o.value === effect)
|
|
return opt?.label || effect || "硬切"
|
|
},
|
|
},
|
|
{
|
|
title: "播放速度",
|
|
dataIndex: "playback_speed",
|
|
width: 90,
|
|
render: (s: number) => `${s || 1.0}x`,
|
|
},
|
|
{
|
|
title: "状态",
|
|
dataIndex: "status",
|
|
width: 90,
|
|
render: (status: EditPlanClipStatus) => (
|
|
<Tag color={STATUS_COLORS[status] || "default"}>{STATUS_LABELS[status] || status}</Tag>
|
|
),
|
|
},
|
|
{
|
|
title: "操作",
|
|
key: "action",
|
|
width: 140,
|
|
fixed: "right",
|
|
render: (_, record) => (
|
|
<Space size="small">
|
|
<Button
|
|
type="link"
|
|
size="small"
|
|
icon={<EditOutlined />}
|
|
onClick={() => handleEditClip(record)}
|
|
>
|
|
编辑
|
|
</Button>
|
|
<Popconfirm
|
|
title="删除片段"
|
|
description="确定删除这个片段吗?"
|
|
onConfirm={() => handleDeleteClip(record.id)}
|
|
okText="确定"
|
|
cancelText="取消"
|
|
okButtonProps={{ danger: true }}
|
|
>
|
|
<Button type="link" size="small" danger icon={<DeleteOutlined />}>
|
|
删除
|
|
</Button>
|
|
</Popconfirm>
|
|
</Space>
|
|
),
|
|
},
|
|
]
|
|
|
|
/* ── 编辑片段 ── */
|
|
const handleEditClip = useCallback(
|
|
(clip: EditPlanClip) => {
|
|
setEditingClip(clip)
|
|
editForm.setFieldsValue({
|
|
clip_type: clip.clip_type,
|
|
asset_id: clip.asset_id,
|
|
text_content: clip.text_content,
|
|
duration: clip.duration,
|
|
start_time: clip.start_time,
|
|
transition_effect: clip.transition_effect,
|
|
transition_duration: clip.transition_duration,
|
|
playback_speed: clip.playback_speed,
|
|
})
|
|
setEditModalOpen(true)
|
|
},
|
|
[editForm],
|
|
)
|
|
|
|
const handleNewClip = useCallback(() => {
|
|
setEditingClip(null)
|
|
editForm.resetFields()
|
|
editForm.setFieldsValue({
|
|
clip_type: "main",
|
|
duration: 5,
|
|
transition_effect: "cut",
|
|
transition_duration: 0,
|
|
playback_speed: 1.0,
|
|
})
|
|
setEditModalOpen(true)
|
|
}, [editForm])
|
|
|
|
const handleSaveClip = async () => {
|
|
if (!planId) return
|
|
try {
|
|
const values = await editForm.validateFields()
|
|
setEditLoading(true)
|
|
|
|
if (editingClip) {
|
|
// 更新
|
|
await updateEditPlanClip(planId, editingClip.id, values)
|
|
message.success("片段已更新")
|
|
} else {
|
|
// 新建
|
|
const maxOrder = clips.length > 0 ? Math.max(...clips.map((c) => c.order)) : -1
|
|
await createEditPlanClip(planId, {
|
|
...values,
|
|
order: maxOrder + 1,
|
|
})
|
|
message.success("片段已创建")
|
|
}
|
|
|
|
queryClient.invalidateQueries({ queryKey: ["editPlanClips", planId] })
|
|
setEditModalOpen(false)
|
|
} catch (err) {
|
|
console.error(err)
|
|
message.error(editingClip ? "更新失败" : "创建失败")
|
|
} finally {
|
|
setEditLoading(false)
|
|
}
|
|
}
|
|
|
|
/* ── 删除片段 ── */
|
|
const handleDeleteClip = async (clipId: string) => {
|
|
if (!planId) return
|
|
try {
|
|
await deleteEditPlanClip(planId, clipId)
|
|
message.success("已删除")
|
|
queryClient.invalidateQueries({ queryKey: ["editPlanClips", planId] })
|
|
setSelectedRowKeys((prev) => prev.filter((k) => k !== clipId))
|
|
} catch {
|
|
message.error("删除失败")
|
|
}
|
|
}
|
|
|
|
/* ── 批量删除 ── */
|
|
const handleBatchDelete = async () => {
|
|
if (!planId || selectedRowKeys.length === 0) return
|
|
try {
|
|
await batchDeleteEditPlanClips(
|
|
planId,
|
|
selectedRowKeys.map((k) => String(k)),
|
|
)
|
|
message.success(`已删除 ${selectedRowKeys.length} 个片段`)
|
|
queryClient.invalidateQueries({ queryKey: ["editPlanClips", planId] })
|
|
setSelectedRowKeys([])
|
|
} catch {
|
|
message.error("批量删除失败")
|
|
}
|
|
}
|
|
|
|
/* ── 从素材导入 ── */
|
|
const handleImportFromAssets = async () => {
|
|
if (!planId || selectedAssetIds.length === 0) return
|
|
try {
|
|
setImportLoading(true)
|
|
const res = await createClipsFromAssets(planId, selectedAssetIds)
|
|
message.success(`已导入 ${res.created_count} 个片段`)
|
|
queryClient.invalidateQueries({ queryKey: ["editPlanClips", planId] })
|
|
setImportDrawerOpen(false)
|
|
setSelectedAssetIds([])
|
|
} catch {
|
|
message.error("导入失败")
|
|
} finally {
|
|
setImportLoading(false)
|
|
}
|
|
}
|
|
|
|
/* ── 排序模式 ── */
|
|
const enterReorderMode = () => {
|
|
setReorderItems([...clips].sort((a, b) => a.order - b.order))
|
|
setReorderMode(true)
|
|
}
|
|
|
|
const moveClip = (fromIndex: number, toIndex: number) => {
|
|
if (toIndex < 0 || toIndex >= reorderItems.length) return
|
|
const newItems = [...reorderItems]
|
|
const [moved] = newItems.splice(fromIndex, 1)
|
|
newItems.splice(toIndex, 0, moved)
|
|
setReorderItems(newItems)
|
|
}
|
|
|
|
const saveReorder = async () => {
|
|
if (!planId) return
|
|
const items = reorderItems.map((clip, index) => ({
|
|
clip_id: clip.id,
|
|
new_order: index,
|
|
}))
|
|
try {
|
|
await reorderEditPlanClips(planId, items)
|
|
message.success("排序已保存")
|
|
queryClient.invalidateQueries({ queryKey: ["editPlanClips", planId] })
|
|
setReorderMode(false)
|
|
} catch {
|
|
message.error("排序保存失败")
|
|
}
|
|
}
|
|
|
|
const cancelReorder = () => {
|
|
setReorderMode(false)
|
|
setReorderItems([])
|
|
}
|
|
|
|
/* ── 渲染 ── */
|
|
const displayClips = reorderMode ? reorderItems : [...clips].sort((a, b) => a.order - b.order)
|
|
|
|
return (
|
|
<div className="plan-clips-page">
|
|
{/* 顶部 */}
|
|
<div className="plan-clips-header">
|
|
<div className="plan-clips-header-left">
|
|
<Button
|
|
type="text"
|
|
icon={<ArrowLeftOutlined />}
|
|
onClick={() => navigate("/app/edit-plans")}
|
|
>
|
|
返回计划列表
|
|
</Button>
|
|
<div className="plan-clips-title">
|
|
<h2>{plan?.name || "加载中..."}</h2>
|
|
<p>
|
|
{planLoading
|
|
? "加载中..."
|
|
: `共 ${clipsData?.total || 0} 个片段 · ${plan?.status || ""}`}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="plan-clips-header-right">
|
|
<Space>
|
|
<Button icon={<UploadOutlined />} onClick={() => setImportDrawerOpen(true)}>
|
|
从素材导入
|
|
</Button>
|
|
{reorderMode ? (
|
|
<>
|
|
<Button onClick={cancelReorder}>取消排序</Button>
|
|
<Button type="primary" icon={<SaveOutlined />} onClick={saveReorder}>
|
|
保存排序
|
|
</Button>
|
|
</>
|
|
) : (
|
|
<>
|
|
<Button
|
|
icon={<OrderedListOutlined />}
|
|
onClick={enterReorderMode}
|
|
disabled={clips.length === 0}
|
|
>
|
|
调整顺序
|
|
</Button>
|
|
<Button type="primary" icon={<PlusOutlined />} onClick={handleNewClip}>
|
|
添加片段
|
|
</Button>
|
|
</>
|
|
)}
|
|
</Space>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 批量操作栏 */}
|
|
{!reorderMode && selectedRowKeys.length > 0 && (
|
|
<div className="plan-clips-batch-bar">
|
|
<span>已选择 {selectedRowKeys.length} 个片段</span>
|
|
<Popconfirm
|
|
title="批量删除"
|
|
description={`确定删除选中的 ${selectedRowKeys.length} 个片段吗?`}
|
|
onConfirm={handleBatchDelete}
|
|
okText="确定"
|
|
cancelText="取消"
|
|
okButtonProps={{ danger: true }}
|
|
>
|
|
<Button danger icon={<DeleteOutlined />}>
|
|
批量删除
|
|
</Button>
|
|
</Popconfirm>
|
|
</div>
|
|
)}
|
|
|
|
{/* 排序列表 */}
|
|
{reorderMode && (
|
|
<Card className="plan-clips-reorder-card" title="拖拽调整顺序">
|
|
<div className="plan-clips-reorder-list">
|
|
{reorderItems.map((clip, index) => (
|
|
<div key={clip.id} className="plan-clips-reorder-item">
|
|
<span className="reorder-index">{index + 1}</span>
|
|
<span className="reorder-type">
|
|
{CLIP_TYPE_OPTIONS.find((o) => o.value === clip.clip_type)?.label ||
|
|
clip.clip_type}
|
|
</span>
|
|
<span className="reorder-content">
|
|
{clip.text_content || clip.asset_id || "无内容"}
|
|
</span>
|
|
<span className="reorder-duration">{clip.duration.toFixed(1)}s</span>
|
|
<Space>
|
|
<Button
|
|
size="small"
|
|
onClick={() => moveClip(index, index - 1)}
|
|
disabled={index === 0}
|
|
>
|
|
↑
|
|
</Button>
|
|
<Button
|
|
size="small"
|
|
onClick={() => moveClip(index, index + 1)}
|
|
disabled={index === reorderItems.length - 1}
|
|
>
|
|
↓
|
|
</Button>
|
|
</Space>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</Card>
|
|
)}
|
|
|
|
{/* 片段列表 */}
|
|
{!reorderMode && (
|
|
<div className="plan-clips-table-wrap">
|
|
<Table
|
|
rowKey="id"
|
|
columns={columns}
|
|
dataSource={displayClips}
|
|
loading={clipsLoading}
|
|
rowSelection={{
|
|
selectedRowKeys,
|
|
onChange: setSelectedRowKeys,
|
|
}}
|
|
pagination={false}
|
|
locale={{
|
|
emptyText: (
|
|
<Empty
|
|
description="暂无片段,点击上方按钮添加或从素材导入"
|
|
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
|
/>
|
|
),
|
|
}}
|
|
scroll={{ x: 1000 }}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* 编辑弹窗 */}
|
|
<Modal
|
|
title={editingClip ? "编辑片段" : "添加片段"}
|
|
open={editModalOpen}
|
|
onCancel={() => setEditModalOpen(false)}
|
|
onOk={handleSaveClip}
|
|
confirmLoading={editLoading}
|
|
okText="保存"
|
|
cancelText="取消"
|
|
width={560}
|
|
>
|
|
<Form form={editForm} layout="vertical">
|
|
<Form.Item
|
|
label="片段类型"
|
|
name="clip_type"
|
|
rules={[{ required: true, message: "请选择类型" }]}
|
|
>
|
|
<Select options={CLIP_TYPE_OPTIONS} />
|
|
</Form.Item>
|
|
<Form.Item label="素材 ID" name="asset_id">
|
|
<Input placeholder="关联的素材 ID(可选)" />
|
|
</Form.Item>
|
|
<Form.Item label="文本内容" name="text_content">
|
|
<Input.TextArea rows={3} placeholder="字幕/配音文案等" />
|
|
</Form.Item>
|
|
<div style={{ display: "flex", gap: 16 }}>
|
|
<Form.Item label="起始时间(秒)" name="start_time" style={{ flex: 1 }}>
|
|
<InputNumber min={0} step={0.1} style={{ width: "100%" }} />
|
|
</Form.Item>
|
|
<Form.Item label="时长(秒)" name="duration" style={{ flex: 1 }}>
|
|
<InputNumber min={0} step={0.1} style={{ width: "100%" }} />
|
|
</Form.Item>
|
|
</div>
|
|
<div style={{ display: "flex", gap: 16 }}>
|
|
<Form.Item label="转场效果" name="transition_effect" style={{ flex: 1 }}>
|
|
<Select options={TRANSITION_OPTIONS} />
|
|
</Form.Item>
|
|
<Form.Item label="转场时长" name="transition_duration" style={{ flex: 1 }}>
|
|
<InputNumber min={0} step={0.1} style={{ width: "100%" }} />
|
|
</Form.Item>
|
|
</div>
|
|
<Form.Item label="播放速度" name="playback_speed">
|
|
<InputNumber min={0.1} max={10} step={0.1} style={{ width: "100%" }} />
|
|
</Form.Item>
|
|
</Form>
|
|
</Modal>
|
|
|
|
{/* 素材导入抽屉 */}
|
|
<Drawer
|
|
title="从素材库导入"
|
|
open={importDrawerOpen}
|
|
onClose={() => setImportDrawerOpen(false)}
|
|
width={480}
|
|
extra={
|
|
<Button
|
|
type="primary"
|
|
onClick={handleImportFromAssets}
|
|
loading={importLoading}
|
|
disabled={selectedAssetIds.length === 0}
|
|
>
|
|
导入 {selectedAssetIds.length > 0 ? `(${selectedAssetIds.length})` : ""}
|
|
</Button>
|
|
}
|
|
>
|
|
{assets && assets.length > 0 ? (
|
|
<div className="asset-import-list">
|
|
{assets.map((asset) => (
|
|
<div
|
|
key={asset.id}
|
|
className={`asset-import-item ${
|
|
selectedAssetIds.includes(asset.id) ? "selected" : ""
|
|
}`}
|
|
onClick={() => {
|
|
setSelectedAssetIds((prev) =>
|
|
prev.includes(asset.id)
|
|
? prev.filter((id) => id !== asset.id)
|
|
: [...prev, asset.id],
|
|
)
|
|
}}
|
|
>
|
|
<div className="asset-thumb">
|
|
{asset.thumbnail_url ? (
|
|
<img src={asset.thumbnail_url} alt={asset.name} />
|
|
) : (
|
|
<div className="asset-thumb-placeholder">{asset.type}</div>
|
|
)}
|
|
</div>
|
|
<div className="asset-info">
|
|
<div className="asset-name" title={asset.name}>
|
|
{asset.name}
|
|
</div>
|
|
<div className="asset-meta">
|
|
{asset.type}
|
|
{asset.duration ? ` · ${asset.duration.toFixed(1)}s` : ""}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<Empty description="素材库为空" />
|
|
)}
|
|
</Drawer>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default PlanClipsManager
|