diff --git a/app/api/video-edit/edit-points/[id]/route.ts b/app/api/video-edit/edit-points/[id]/route.ts new file mode 100644 index 0000000..589b6e9 --- /dev/null +++ b/app/api/video-edit/edit-points/[id]/route.ts @@ -0,0 +1,237 @@ +/** + * 单个编辑点API路由 + * 提供单个编辑点的更新和删除操作 + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { EditPoint } from '@/components/pages/work-flow/video-edit/types'; + +// 引用主路由的存储(实际应用中应使用共享数据库) +// 这里我们需要一个简单的方式来共享数据 +let editPointsStorage: EditPoint[] = []; + +// 获取存储的辅助函数 +function getEditPointsStorage(): EditPoint[] { + // 在实际应用中,这里应该从数据库获取 + return editPointsStorage; +} + +function setEditPointsStorage(points: EditPoint[]): void { + // 在实际应用中,这里应该保存到数据库 + editPointsStorage = points; +} + +/** + * 更新编辑点 + * PUT /api/video-edit/edit-points/[id] + */ +export async function PUT( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const { id } = params; + const body = await request.json(); + const { description, status, position_x, position_y } = body; + + console.log('✏️ 更新编辑点请求:', { id, body }); + + if (!id) { + return NextResponse.json({ + code: 400, + successful: false, + message: 'Edit point ID is required', + data: null + }, { status: 400 }); + } + + const storage = getEditPointsStorage(); + const editPointIndex = storage.findIndex(point => point.id === id); + + if (editPointIndex === -1) { + return NextResponse.json({ + code: 404, + successful: false, + message: 'Edit point not found', + data: null + }, { status: 404 }); + } + + // 更新编辑点 + const editPoint = storage[editPointIndex]; + + if (description !== undefined) { + editPoint.description = description; + } + + if (status !== undefined) { + editPoint.status = status; + } + + if (position_x !== undefined && position_y !== undefined) { + editPoint.position = { x: position_x, y: position_y }; + } + + editPoint.updatedAt = new Date().toISOString(); + + // 保存更新 + setEditPointsStorage(storage); + + // 转换为API响应格式 + const apiResponse = { + id: editPoint.id, + videoId: editPoint.videoId, + projectId: editPoint.projectId, + userId: editPoint.userId, + position: editPoint.position, + timestamp: editPoint.timestamp, + description: editPoint.description, + status: editPoint.status, + createdAt: editPoint.createdAt, + updatedAt: editPoint.updatedAt + }; + + const response = { + code: 0, + successful: true, + message: 'Edit point updated successfully', + data: apiResponse + }; + + console.log('✅ 编辑点更新成功:', response.data); + return NextResponse.json(response); + + } catch (error) { + console.error('❌ 更新编辑点失败:', error); + return NextResponse.json({ + code: 500, + successful: false, + message: 'Internal server error', + data: null + }, { status: 500 }); + } +} + +/** + * 删除编辑点 + * DELETE /api/video-edit/edit-points/[id] + */ +export async function DELETE( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const { id } = params; + + console.log('🗑️ 删除编辑点请求:', { id }); + + if (!id) { + return NextResponse.json({ + code: 400, + successful: false, + message: 'Edit point ID is required', + data: null + }, { status: 400 }); + } + + const storage = getEditPointsStorage(); + const editPointIndex = storage.findIndex(point => point.id === id); + + if (editPointIndex === -1) { + return NextResponse.json({ + code: 404, + successful: false, + message: 'Edit point not found', + data: null + }, { status: 404 }); + } + + // 删除编辑点 + storage.splice(editPointIndex, 1); + setEditPointsStorage(storage); + + const response = { + code: 0, + successful: true, + message: 'Edit point deleted successfully', + data: null + }; + + console.log('✅ 编辑点删除成功'); + return NextResponse.json(response); + + } catch (error) { + console.error('❌ 删除编辑点失败:', error); + return NextResponse.json({ + code: 500, + successful: false, + message: 'Internal server error', + data: null + }, { status: 500 }); + } +} + +/** + * 提交编辑请求 + * POST /api/video-edit/edit-points/[id]/submit + */ +export async function POST( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const { id } = params; + + console.log('📤 提交编辑请求:', { id }); + + if (!id) { + return NextResponse.json({ + code: 400, + successful: false, + message: 'Edit point ID is required', + data: null + }, { status: 400 }); + } + + const storage = getEditPointsStorage(); + const editPointIndex = storage.findIndex(point => point.id === id); + + if (editPointIndex === -1) { + return NextResponse.json({ + code: 404, + successful: false, + message: 'Edit point not found', + data: null + }, { status: 404 }); + } + + // 更新状态为处理中 + const editPoint = storage[editPointIndex]; + editPoint.status = 'processing'; + editPoint.updatedAt = new Date().toISOString(); + + setEditPointsStorage(storage); + + // 这里可以添加实际的编辑处理逻辑 + // 比如发送到AI编辑服务、添加到处理队列等 + + const response = { + code: 0, + successful: true, + message: 'Edit request submitted successfully', + data: null + }; + + console.log('✅ 编辑请求提交成功'); + return NextResponse.json(response); + + } catch (error) { + console.error('❌ 提交编辑请求失败:', error); + return NextResponse.json({ + code: 500, + successful: false, + message: 'Internal server error', + data: null + }, { status: 500 }); + } +} diff --git a/app/api/video-edit/edit-points/route.ts b/app/api/video-edit/edit-points/route.ts new file mode 100644 index 0000000..6b4ad4e --- /dev/null +++ b/app/api/video-edit/edit-points/route.ts @@ -0,0 +1,205 @@ +/** + * 视频编辑点API路由 + * 提供编辑点的CRUD操作 + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { EditPoint, EditPointStatus } from '@/components/pages/work-flow/video-edit/types'; + +// 临时内存存储(生产环境应使用数据库) +let editPointsStorage: EditPoint[] = []; +let idCounter = 1; + +/** + * 获取编辑点列表 + * GET /api/video-edit/edit-points?video_id=xxx&project_id=xxx + */ +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url); + const videoId = searchParams.get('video_id'); + const projectId = searchParams.get('project_id'); + const offset = parseInt(searchParams.get('offset') || '0'); + const limit = parseInt(searchParams.get('limit') || '50'); + + console.log('📋 获取编辑点列表请求:', { videoId, projectId, offset, limit }); + + if (!videoId || !projectId) { + return NextResponse.json({ + code: 400, + successful: false, + message: 'video_id and project_id are required', + data: null + }, { status: 400 }); + } + + // 过滤匹配的编辑点 + const filteredPoints = editPointsStorage.filter(point => + point.videoId === videoId && point.projectId === projectId + ); + + // 分页 + const paginatedPoints = filteredPoints.slice(offset, offset + limit); + + // 转换为API响应格式 + const apiPoints = paginatedPoints.map(point => ({ + id: point.id, + video_id: point.videoId, + project_id: point.projectId, + user_id: point.userId, + position_x: point.position.x, + position_y: point.position.y, + timestamp: point.timestamp, + description: point.description, + status: point.status, + created_at: point.createdAt, + updated_at: point.updatedAt + })); + + const response = { + code: 0, + successful: true, + message: 'Success', + data: { + edit_points: apiPoints, + total_count: filteredPoints.length, + has_more: offset + limit < filteredPoints.length + } + }; + + console.log('✅ 编辑点列表获取成功:', response.data); + return NextResponse.json(response); + + } catch (error) { + console.error('❌ 获取编辑点列表失败:', error); + return NextResponse.json({ + code: 500, + successful: false, + message: 'Internal server error', + data: null + }, { status: 500 }); + } +} + +/** + * 创建编辑点 + * POST /api/video-edit/edit-points + */ +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { video_id, project_id, position_x, position_y, timestamp, description, status } = body; + + console.log('📝 创建编辑点请求:', body); + + if (!video_id || !project_id || position_x === undefined || position_y === undefined || timestamp === undefined) { + return NextResponse.json({ + code: 400, + successful: false, + message: 'Missing required fields', + data: null + }, { status: 400 }); + } + + // 创建新编辑点 + const newEditPoint: EditPoint = { + id: `edit-point-${idCounter++}`, + videoId: video_id, + projectId: project_id, + userId: 1, // 从认证中获取,这里暂时硬编码 + position: { + x: position_x, + y: position_y + }, + timestamp, + description: description || '', + status: status || EditPointStatus.PENDING, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + showInput: false, + connectionDirection: 'auto' + }; + + // 存储编辑点 + editPointsStorage.push(newEditPoint); + + // 转换为API响应格式 + const apiResponse = { + id: newEditPoint.id, + videoId: newEditPoint.videoId, + projectId: newEditPoint.projectId, + userId: newEditPoint.userId, + position: newEditPoint.position, + timestamp: newEditPoint.timestamp, + description: newEditPoint.description, + status: newEditPoint.status, + createdAt: newEditPoint.createdAt, + updatedAt: newEditPoint.updatedAt + }; + + const response = { + code: 0, + successful: true, + message: 'Edit point created successfully', + data: apiResponse + }; + + console.log('✅ 编辑点创建成功:', response.data); + return NextResponse.json(response); + + } catch (error) { + console.error('❌ 创建编辑点失败:', error); + return NextResponse.json({ + code: 500, + successful: false, + message: 'Internal server error', + data: null + }, { status: 500 }); + } +} + +/** + * 批量删除编辑点 + * DELETE /api/video-edit/edit-points (with body) + */ +export async function DELETE(request: NextRequest) { + try { + const body = await request.json(); + const { edit_point_ids } = body; + + console.log('🗑️ 批量删除编辑点请求:', { edit_point_ids }); + + if (!edit_point_ids || !Array.isArray(edit_point_ids)) { + return NextResponse.json({ + code: 400, + successful: false, + message: 'edit_point_ids array is required', + data: null + }, { status: 400 }); + } + + // 删除指定的编辑点 + const initialLength = editPointsStorage.length; + editPointsStorage = editPointsStorage.filter(point => !edit_point_ids.includes(point.id)); + const deletedCount = initialLength - editPointsStorage.length; + + const response = { + code: 0, + successful: true, + message: `Successfully deleted ${deletedCount} edit points`, + data: { deleted_count: deletedCount } + }; + + console.log('✅ 批量删除编辑点成功:', response.data); + return NextResponse.json(response); + + } catch (error) { + console.error('❌ 批量删除编辑点失败:', error); + return NextResponse.json({ + code: 500, + successful: false, + message: 'Internal server error', + data: null + }, { status: 500 }); + } +} diff --git a/components/pages/work-flow.tsx b/components/pages/work-flow.tsx index d42e409..6fd310c 100644 --- a/components/pages/work-flow.tsx +++ b/components/pages/work-flow.tsx @@ -17,6 +17,8 @@ import { Drawer, Tooltip, notification } from 'antd'; import { showEditingNotification } from "@/components/pages/work-flow/editing-notification"; // import { AIEditingIframeButton } from './work-flow/ai-editing-iframe'; import { exportVideoWithRetry } from '@/utils/export-service'; +// 临时禁用视频编辑功能 +// import { EditPoint as EditPointType } from './work-flow/video-edit/types'; const WorkFlow = React.memo(function WorkFlow() { useEffect(() => { @@ -277,6 +279,38 @@ const WorkFlow = React.memo(function WorkFlow() { // setAiEditingInProgress(false); // 已移除该状态变量 }, []); + // 临时禁用视频编辑功能 + // 视频编辑描述提交处理函数 + /*const handleVideoEditDescriptionSubmit = useCallback((editPoint: EditPointType, description: string) => { + console.log('🎬 视频编辑描述提交:', { editPoint, description }); + + // 构造编辑消息发送到SmartChatBox + const editMessage = `📝 Video Edit Request + +🎯 **Position**: ${Math.round(editPoint.position.x)}%, ${Math.round(editPoint.position.y)}% +⏰ **Timestamp**: ${Math.floor(editPoint.timestamp)}s +🎬 **Video**: Shot ${currentSketchIndex + 1} + +**Edit Description:** +${description} + +Please process this video editing request.`; + + // 如果SmartChatBox开启,自动发送消息 + if (isSmartChatBoxOpen) { + // 这里可以通过SmartChatBox的API发送消息 + // 或者通过全局状态管理来处理 + console.log('📤 发送编辑请求到聊天框:', editMessage); + } + + // 显示成功通知 + notification.success({ + message: 'Edit Request Submitted', + description: `Your edit request for timestamp ${Math.floor(editPoint.timestamp)}s has been submitted successfully.`, + duration: 3 + }); + }, [currentSketchIndex, isSmartChatBoxOpen]);*/ + // 测试导出接口的处理函数(使用封装的导出服务) const handleTestExport = useCallback(async () => { console.log('🧪 开始测试导出接口...'); @@ -386,6 +420,7 @@ const WorkFlow = React.memo(function WorkFlow() { onGotoCut={generateEditPlan} isSmartChatBoxOpen={isSmartChatBoxOpen} onRetryVideo={(video_id) => handleRetryVideo(video_id)} + // 临时禁用视频编辑功能: enableVideoEdit={true} onVideoEditDescriptionSubmit={handleVideoEditDescriptionSubmit} /> diff --git a/components/pages/work-flow/media-viewer.tsx b/components/pages/work-flow/media-viewer.tsx index ddda0f6..d07d30b 100644 --- a/components/pages/work-flow/media-viewer.tsx +++ b/components/pages/work-flow/media-viewer.tsx @@ -2,7 +2,7 @@ import React, { useRef, useEffect, useState, SetStateAction, useMemo } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; -import { Edit3, Play, Pause, Volume2, VolumeX, Maximize, Minimize, Loader2, X, Scissors, RotateCcw, MessageCircleMore, Download, ArrowDownWideNarrow, CircleAlert } from 'lucide-react'; +import { Edit3, Play, Pause, Volume2, VolumeX, Maximize, Minimize, Loader2, X, Scissors, RotateCcw, MessageCircleMore, Download, ArrowDownWideNarrow, CircleAlert /*, PenTool*/ } from 'lucide-react'; import { ProgressiveReveal, presets } from '@/components/ui/progressive-reveal'; import { GlassIconButton } from '@/components/ui/glass-icon-button'; import { ScriptRenderer } from '@/components/script-renderer/ScriptRenderer'; @@ -12,6 +12,9 @@ import ScriptLoading from './script-loading'; import { TaskObject } from '@/api/DTO/movieEdit'; import { Button, Tooltip } from 'antd'; import { downloadVideo, downloadAllVideos, getFirstFrame } from '@/utils/tools'; +// 临时禁用视频编辑功能 +// import { VideoEditOverlay } from './video-edit/VideoEditOverlay'; +// import { EditPoint as EditPointType } from './video-edit/types'; interface MediaViewerProps { taskObject: TaskObject; @@ -31,6 +34,9 @@ interface MediaViewerProps { onGotoCut: () => void; isSmartChatBoxOpen: boolean; onRetryVideo?: (video_id: string) => void; + // 临时禁用视频编辑功能 + // enableVideoEdit?: boolean; + // onVideoEditDescriptionSubmit?: (editPoint: EditPointType, description: string) => void; } export const MediaViewer = React.memo(function MediaViewer({ @@ -51,6 +57,9 @@ export const MediaViewer = React.memo(function MediaViewer({ onGotoCut, isSmartChatBoxOpen, onRetryVideo + // 临时禁用视频编辑功能 + // enableVideoEdit = true, + // onVideoEditDescriptionSubmit }: MediaViewerProps) { const mainVideoRef = useRef(null); const finalVideoRef = useRef(null); @@ -67,6 +76,8 @@ export const MediaViewer = React.memo(function MediaViewer({ const [toosBtnRight, setToodsBtnRight] = useState('1rem'); const [isLoadingDownloadBtn, setIsLoadingDownloadBtn] = useState(false); const [isLoadingDownloadAllVideosBtn, setIsLoadingDownloadAllVideosBtn] = useState(false); + // 临时禁用视频编辑功能 + // const [isVideoEditMode, setIsVideoEditMode] = useState(false); useEffect(() => { if (isSmartChatBoxOpen) { @@ -492,12 +503,42 @@ export const MediaViewer = React.memo(function MediaViewer({ } }} /> + + {/* 临时禁用视频编辑功能 */} + {/* 视频编辑覆盖层 */} + {/*enableVideoEdit && isVideoEditMode && ( + + )*/} {/* 跳转剪辑按钮 */}
+ {/* 临时禁用视频编辑功能 */} + {/* 视频编辑模式切换按钮 */} + {/*enableVideoEdit && ( + + setIsVideoEditMode(!isVideoEditMode)} + className={isVideoEditMode ? 'bg-blue-500/20 border-blue-500/50' : ''} + /> + + )*/} {/* 添加到chat去编辑 按钮 */} { diff --git a/components/pages/work-flow/video-edit/EditConnection.tsx b/components/pages/work-flow/video-edit/EditConnection.tsx new file mode 100644 index 0000000..2af8864 --- /dev/null +++ b/components/pages/work-flow/video-edit/EditConnection.tsx @@ -0,0 +1,258 @@ +/** + * 编辑连接线组件 + * 实现从编辑点到输入框的弧线连接 + */ + +import React, { useMemo } from 'react'; +import { motion } from 'framer-motion'; +import { ConnectionPathParams, InputBoxPosition } from './types'; + +interface EditConnectionProps { + /** 起始点坐标(编辑点位置) */ + startPoint: { x: number; y: number }; + /** 结束点坐标(输入框位置) */ + endPoint: { x: number; y: number }; + /** 容器尺寸 */ + containerSize: { width: number; height: number }; + /** 连接线样式 */ + style?: { + color?: string; + strokeWidth?: number; + dashArray?: string; + }; + /** 弧线弯曲程度 */ + curvature?: number; + /** 是否显示动画 */ + animated?: boolean; +} + +/** + * 计算弧线路径 + */ +function calculateCurvePath({ + start, + end, + containerSize, + curvature = 0.3 +}: ConnectionPathParams): string { + const dx = end.x - start.x; + const dy = end.y - start.y; + const distance = Math.sqrt(dx * dx + dy * dy); + + // 计算控制点,创建优雅的弧线 + const midX = (start.x + end.x) / 2; + const midY = (start.y + end.y) / 2; + + // 根据方向调整控制点 + let controlX = midX; + let controlY = midY; + + // 如果是水平方向较长,控制点偏向垂直方向 + if (Math.abs(dx) > Math.abs(dy)) { + controlY = midY + (dy > 0 ? -1 : 1) * distance * curvature; + } else { + // 如果是垂直方向较长,控制点偏向水平方向 + controlX = midX + (dx > 0 ? -1 : 1) * distance * curvature; + } + + // 确保控制点在容器范围内 + controlX = Math.max(10, Math.min(containerSize.width - 10, controlX)); + controlY = Math.max(10, Math.min(containerSize.height - 10, controlY)); + + // 创建二次贝塞尔曲线路径 + return `M ${start.x} ${start.y} Q ${controlX} ${controlY} ${end.x} ${end.y}`; +} + +/** + * 计算最佳输入框位置 + */ +export function calculateInputPosition( + editPointPosition: { x: number; y: number }, + containerSize: { width: number; height: number }, + inputBoxSize: { width: number; height: number } = { width: 200, height: 80 } +): InputBoxPosition { + const { x: pointX, y: pointY } = editPointPosition; + const { width: containerWidth, height: containerHeight } = containerSize; + const { width: inputWidth, height: inputHeight } = inputBoxSize; + + const margin = 20; // 与边界的最小距离 + const connectionLength = 80; // 连接线长度 + + // 计算各个方向的可用空间 + const spaceTop = pointY; + const spaceBottom = containerHeight - pointY; + const spaceLeft = pointX; + const spaceRight = containerWidth - pointX; + + let direction: 'top' | 'bottom' | 'left' | 'right' = 'right'; + let inputX = pointX + connectionLength; + let inputY = pointY - inputHeight / 2; + let connectionEndX = pointX + connectionLength; + let connectionEndY = pointY; + + // 优先选择右侧 + if (spaceRight >= inputWidth + connectionLength + margin) { + direction = 'right'; + inputX = pointX + connectionLength; + inputY = Math.max(margin, Math.min(containerHeight - inputHeight - margin, pointY - inputHeight / 2)); + connectionEndX = inputX; + connectionEndY = inputY + inputHeight / 2; + } + // 其次选择左侧 + else if (spaceLeft >= inputWidth + connectionLength + margin) { + direction = 'left'; + inputX = pointX - connectionLength - inputWidth; + inputY = Math.max(margin, Math.min(containerHeight - inputHeight - margin, pointY - inputHeight / 2)); + connectionEndX = inputX + inputWidth; + connectionEndY = inputY + inputHeight / 2; + } + // 然后选择下方 + else if (spaceBottom >= inputHeight + connectionLength + margin) { + direction = 'bottom'; + inputX = Math.max(margin, Math.min(containerWidth - inputWidth - margin, pointX - inputWidth / 2)); + inputY = pointY + connectionLength; + connectionEndX = inputX + inputWidth / 2; + connectionEndY = inputY; + } + // 最后选择上方 + else if (spaceTop >= inputHeight + connectionLength + margin) { + direction = 'top'; + inputX = Math.max(margin, Math.min(containerWidth - inputWidth - margin, pointX - inputWidth / 2)); + inputY = pointY - connectionLength - inputHeight; + connectionEndX = inputX + inputWidth / 2; + connectionEndY = inputY + inputHeight; + } + // 如果空间不足,强制放在右侧并调整位置 + else { + direction = 'right'; + inputX = Math.min(containerWidth - inputWidth - margin, pointX + 40); + inputY = Math.max(margin, Math.min(containerHeight - inputHeight - margin, pointY - inputHeight / 2)); + connectionEndX = inputX; + connectionEndY = inputY + inputHeight / 2; + } + + return { + x: inputX, + y: inputY, + connectionEnd: { x: connectionEndX, y: connectionEndY }, + direction + }; +} + +/** + * 编辑连接线组件 + */ +export const EditConnection: React.FC = ({ + startPoint, + endPoint, + containerSize, + style = {}, + curvature = 0.3, + animated = true +}) => { + const { + color = 'rgba(255, 255, 255, 0.8)', + strokeWidth = 2, + dashArray = '5,5' + } = style; + + // 计算路径 + const path = useMemo(() => + calculateCurvePath({ + start: startPoint, + end: endPoint, + containerSize, + curvature + }), [startPoint, endPoint, containerSize, curvature]); + + // 计算路径长度用于动画 + const pathLength = useMemo(() => { + const dx = endPoint.x - startPoint.x; + const dy = endPoint.y - startPoint.y; + return Math.sqrt(dx * dx + dy * dy) * 1.2; // 弧线比直线长约20% + }, [startPoint, endPoint]); + + return ( + + {/* 连接线路径 */} + + + {/* 连接线末端的小圆点 */} + + + {/* 动画流动效果(可选) */} + {animated && ( + + )} + + ); +}; diff --git a/components/pages/work-flow/video-edit/EditDescription.tsx b/components/pages/work-flow/video-edit/EditDescription.tsx new file mode 100644 index 0000000..431232a --- /dev/null +++ b/components/pages/work-flow/video-edit/EditDescription.tsx @@ -0,0 +1,274 @@ +/** + * 编辑描述显示组件 + * 显示已提交的编辑描述内容,带有优雅的连接线 + */ + +import React, { useMemo } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { EditPoint as EditPointType, EditPointStatus } from './types'; + +interface EditDescriptionProps { + /** 编辑点数据 */ + editPoint: EditPointType; + /** 容器尺寸 */ + containerSize: { width: number; height: number }; + /** 描述框位置 */ + position: { x: number; y: number }; + /** 连接线终点 */ + connectionEnd: { x: number; y: number }; + /** 点击事件处理 */ + onClick?: (editPoint: EditPointType) => void; + /** 编辑事件处理 */ + onEdit?: (id: string) => void; + /** 删除事件处理 */ + onDelete?: (id: string) => void; +} + +/** + * 编辑描述组件 + */ +export const EditDescription: React.FC = ({ + editPoint, + containerSize, + position, + connectionEnd, + onClick, + onEdit, + onDelete +}) => { + // 计算编辑点的屏幕坐标 + const editPointPosition = useMemo(() => ({ + x: (editPoint.position.x / 100) * containerSize.width, + y: (editPoint.position.y / 100) * containerSize.height + }), [editPoint.position, containerSize]); + + // 计算连接线路径 + const connectionPath = useMemo(() => { + const startX = editPointPosition.x; + const startY = editPointPosition.y; + const endX = connectionEnd.x; + const endY = connectionEnd.y; + + // 计算控制点,创建优雅的弧线 + const deltaX = endX - startX; + const deltaY = endY - startY; + const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY); + + // 控制点偏移量,创建自然的弧线 + const controlOffset = Math.min(distance * 0.3, 60); + const controlX = startX + deltaX * 0.5 + (deltaY > 0 ? -controlOffset : controlOffset); + const controlY = startY + deltaY * 0.5 - Math.abs(deltaX) * 0.2; + + return `M ${startX} ${startY} Q ${controlX} ${controlY} ${endX} ${endY}`; + }, [editPointPosition, connectionEnd]); + + // 获取状态颜色 + const getStatusColor = () => { + switch (editPoint.status) { + case EditPointStatus.EDITED: + return '#10b981'; // 绿色 + case EditPointStatus.PROCESSING: + return '#3b82f6'; // 蓝色 + case EditPointStatus.COMPLETED: + return '#059669'; // 深绿色 + case EditPointStatus.FAILED: + return '#ef4444'; // 红色 + default: + return '#6b7280'; // 灰色 + } + }; + + // 获取状态文本 + const getStatusText = () => { + switch (editPoint.status) { + case EditPointStatus.EDITED: + return '已编辑'; + case EditPointStatus.PROCESSING: + return '处理中'; + case EditPointStatus.COMPLETED: + return '已完成'; + case EditPointStatus.FAILED: + return '失败'; + default: + return ''; + } + }; + + const statusColor = getStatusColor(); + const statusText = getStatusText(); + + return ( + + {editPoint.description && editPoint.status !== EditPointStatus.PENDING && ( + <> + {/* 连接线 */} + + + + + {/* 描述内容框 */} + onClick?.(editPoint)} + whileHover={{ scale: 1.02 }} + whileTap={{ scale: 0.98 }} + > + {/* 玻璃态背景 */} +
+ {/* 状态指示条 */} +
+ + {/* 内容区域 */} +
+ {/* 状态标签 */} + {statusText && ( +
+
+ + {statusText} + +
+ )} + + {/* 描述文本 */} +
+ {editPoint.description} +
+ + {/* 时间戳 */} +
+ + {Math.floor(editPoint.timestamp)}s + + + {new Date(editPoint.updatedAt).toLocaleTimeString()} + +
+
+ + {/* 悬停时显示的操作按钮 */} + + {onEdit && ( + + )} + {onDelete && ( + + )} + + + {/* 装饰性光效 */} +
+
+ + {/* 连接点指示器 */} +
+ + + )} + + ); +}; diff --git a/components/pages/work-flow/video-edit/EditInput.tsx b/components/pages/work-flow/video-edit/EditInput.tsx new file mode 100644 index 0000000..deb178c --- /dev/null +++ b/components/pages/work-flow/video-edit/EditInput.tsx @@ -0,0 +1,246 @@ +/** + * 编辑输入框组件 + * 实现编辑描述输入功能,支持键盘操作和动画效果 + */ + +import React, { useState, useRef, useEffect, useCallback } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { Send, X, Loader2 } from 'lucide-react'; +import { EditPoint, EditPointStatus } from './types'; + +interface EditInputProps { + /** 编辑点数据 */ + editPoint: EditPoint; + /** 输入框位置 */ + position: { x: number; y: number }; + /** 是否显示 */ + isVisible: boolean; + /** 是否正在提交 */ + isSubmitting?: boolean; + /** 提交回调 */ + onSubmit: (description: string) => void; + /** 取消回调 */ + onCancel: () => void; + /** 输入框尺寸 */ + size?: { width: number; height: number }; + /** 占位符文本 */ + placeholder?: string; +} + +/** + * 编辑输入框组件 + */ +export const EditInput: React.FC = ({ + editPoint, + position, + isVisible, + isSubmitting = false, + onSubmit, + onCancel, + size = { width: 280, height: 120 }, + placeholder = "Describe your edit request..." +}) => { + const [description, setDescription] = useState(editPoint.description || ''); + const [isFocused, setIsFocused] = useState(false); + const textareaRef = useRef(null); + const containerRef = useRef(null); + + // 自动聚焦 + useEffect(() => { + if (isVisible && textareaRef.current) { + // 延迟聚焦,等待动画完成 + const timer = setTimeout(() => { + textareaRef.current?.focus(); + }, 300); + return () => clearTimeout(timer); + } + }, [isVisible]); + + // 键盘事件处理 + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + onCancel(); + } else if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { + e.preventDefault(); + handleSubmit(); + } + }, [onCancel]); + + // 提交处理 + const handleSubmit = useCallback(() => { + const trimmedDescription = description.trim(); + if (trimmedDescription && !isSubmitting) { + onSubmit(trimmedDescription); + } + }, [description, isSubmitting, onSubmit]); + + // 点击外部关闭 + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(event.target as Node)) { + // 如果有内容,自动提交;否则取消 + if (description.trim()) { + handleSubmit(); + } else { + onCancel(); + } + } + }; + + if (isVisible) { + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + } + }, [isVisible, description, handleSubmit, onCancel]); + + // 自动调整高度 + const adjustHeight = useCallback(() => { + if (textareaRef.current) { + textareaRef.current.style.height = 'auto'; + textareaRef.current.style.height = `${Math.min(textareaRef.current.scrollHeight, 120)}px`; + } + }, []); + + useEffect(() => { + adjustHeight(); + }, [description, adjustHeight]); + + return ( + + {isVisible && ( + + {/* 玻璃态背景容器 */} +
+ {/* 头部 */} +
+
+
+ + Edit Request + + + {Math.floor(editPoint.timestamp)}s + +
+ +
+ + {/* 输入区域 */} +
+