优化标题编辑交互:使用Modal浮层,AI生成后允许用户继续编辑
- 将标题输入框改为Modal浮层形式,不影响卡片布局 - AI生成标题后不自动关闭编辑态,允许用户继续编辑 - 优化用户体验,提供更大的编辑空间 - 支持键盘快捷键和字符数显示
This commit is contained in:
1 parent
45efda4e2a
commit
7ef91fc5ba
16 files changed
+817
-6574
No files matched your search
+126
-6542
File diff suppressed because it is too large.
Load diff
+1
-1
@@ -1 +1 @@
|
||||
4803
|
||||
13361
|
||||
@@ -22,6 +22,115 @@ def get_clip_service(db: Session = Depends(get_db)) -> ClipService:
|
||||
return ClipService(db)
|
||||
|
||||
|
||||
@router.patch("/{clip_id}/title", response_model=ClipResponse)
|
||||
async def update_clip_title(
|
||||
clip_id: str,
|
||||
title_data: dict,
|
||||
clip_service: ClipService = Depends(get_clip_service)
|
||||
):
|
||||
"""Update clip title."""
|
||||
try:
|
||||
new_title = title_data.get("title", "").strip()
|
||||
if not new_title:
|
||||
raise HTTPException(status_code=400, detail="标题不能为空")
|
||||
|
||||
if len(new_title) > 200:
|
||||
raise HTTPException(status_code=400, detail="标题长度不能超过200个字符")
|
||||
|
||||
# 更新切片标题
|
||||
clip = clip_service.update_clip(clip_id, ClipUpdate(title=new_title))
|
||||
if not clip:
|
||||
raise HTTPException(status_code=404, detail="切片不存在")
|
||||
|
||||
# 返回更新后的切片信息
|
||||
return ClipResponse(
|
||||
id=str(clip.id),
|
||||
project_id=str(clip.project_id),
|
||||
title=str(clip.title),
|
||||
description=str(clip.description) if clip.description else None,
|
||||
start_time=getattr(clip, 'start_time', 0),
|
||||
end_time=getattr(clip, 'end_time', 0),
|
||||
duration=int(getattr(clip, 'duration', 0)),
|
||||
score=getattr(clip, 'score', None),
|
||||
status=getattr(clip, 'status', 'pending'),
|
||||
video_path=getattr(clip, 'video_path', None),
|
||||
tags=getattr(clip, 'tags', []) or [],
|
||||
clip_metadata=getattr(clip, 'clip_metadata', {}) or {},
|
||||
created_at=getattr(clip, 'created_at', None),
|
||||
updated_at=getattr(clip, 'updated_at', None),
|
||||
collection_ids=[]
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"更新切片标题失败: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"更新切片标题失败: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/{clip_id}/generate-title", response_model=dict)
|
||||
async def generate_clip_title(
|
||||
clip_id: str,
|
||||
clip_service: ClipService = Depends(get_clip_service)
|
||||
):
|
||||
"""Generate a new title for a clip using LLM."""
|
||||
try:
|
||||
# 获取切片信息
|
||||
clip = clip_service.get(clip_id)
|
||||
if not clip:
|
||||
raise HTTPException(status_code=404, detail="切片不存在")
|
||||
|
||||
# 直接从clip_metadata获取内容,不需要从文件系统获取
|
||||
clip_metadata = getattr(clip, 'clip_metadata', {}) or {}
|
||||
|
||||
if not clip_metadata:
|
||||
raise HTTPException(status_code=404, detail="切片元数据不存在")
|
||||
|
||||
# 准备LLM输入数据
|
||||
llm_input = [{
|
||||
"id": clip_id,
|
||||
"title": clip_metadata.get('outline', '') or getattr(clip, 'title', ''),
|
||||
"content": clip_metadata.get('content', []),
|
||||
"recommend_reason": clip_metadata.get('recommend_reason', '')
|
||||
}]
|
||||
|
||||
# 调用LLM生成标题
|
||||
from ...utils.llm_client import LLMClient
|
||||
from ...core.shared_config import PROMPT_FILES
|
||||
|
||||
llm_client = LLMClient()
|
||||
|
||||
# 加载标题生成提示词
|
||||
with open(PROMPT_FILES['title'], 'r', encoding='utf-8') as f:
|
||||
title_prompt = f.read()
|
||||
|
||||
# 调用LLM
|
||||
raw_response = llm_client.call_with_retry(title_prompt, llm_input)
|
||||
|
||||
if not raw_response:
|
||||
raise HTTPException(status_code=500, detail="LLM调用失败")
|
||||
|
||||
# 解析LLM响应
|
||||
titles_map = llm_client.parse_json_response(raw_response)
|
||||
|
||||
if not isinstance(titles_map, dict) or clip_id not in titles_map:
|
||||
raise HTTPException(status_code=500, detail="LLM返回格式错误")
|
||||
|
||||
generated_title = titles_map[clip_id]
|
||||
|
||||
return {
|
||||
"clip_id": clip_id,
|
||||
"generated_title": generated_title,
|
||||
"success": True
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"生成切片标题失败: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"生成切片标题失败: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/", response_model=ClipResponse)
|
||||
async def create_clip(
|
||||
clip_data: ClipCreate,
|
||||
|
||||
+253
-1
@@ -984,6 +984,55 @@ async def sync_all_projects_from_filesystem(
|
||||
raise HTTPException(status_code=500, detail=f"同步失败: {str(e)}")
|
||||
|
||||
|
||||
@router.patch("/{project_id}/collections/{collection_id}/reorder")
|
||||
async def reorder_collection_clips(
|
||||
project_id: str,
|
||||
collection_id: str,
|
||||
clip_ids: List[str],
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""重新排序合集中的切片"""
|
||||
try:
|
||||
from backend.services.collection_service import CollectionService
|
||||
|
||||
# 创建合集服务
|
||||
collection_service = CollectionService(db)
|
||||
|
||||
# 获取合集
|
||||
collection = collection_service.get(collection_id)
|
||||
if not collection:
|
||||
raise HTTPException(status_code=404, detail="Collection not found")
|
||||
|
||||
# 验证合集属于指定项目
|
||||
if str(collection.project_id) != project_id:
|
||||
raise HTTPException(status_code=400, detail="Collection does not belong to the specified project")
|
||||
|
||||
# 更新collection_metadata中的clip_ids
|
||||
metadata = getattr(collection, 'collection_metadata', {}) or {}
|
||||
metadata['clip_ids'] = clip_ids
|
||||
|
||||
# 直接更新数据库中的collection_metadata字段
|
||||
from sqlalchemy import update
|
||||
from backend.models.collection import Collection
|
||||
|
||||
stmt = update(Collection).where(Collection.id == collection_id).values(
|
||||
collection_metadata=metadata
|
||||
)
|
||||
collection_service.db.execute(stmt)
|
||||
collection_service.db.commit()
|
||||
|
||||
return {
|
||||
"message": "Collection clips reordered successfully",
|
||||
"clip_ids": clip_ids
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"重新排序合集 {collection_id} 切片失败: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"重新排序失败: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/sync/{project_id}")
|
||||
async def sync_project_from_filesystem(
|
||||
project_id: str,
|
||||
@@ -1019,4 +1068,207 @@ async def sync_project_from_filesystem(
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"同步项目 {project_id} 数据失败: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"同步失败: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=f"同步失败: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/{project_id}/collections/{collection_id}/generate")
|
||||
async def generate_collection_video(
|
||||
project_id: str,
|
||||
collection_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
project_service: ProjectService = Depends(get_project_service)
|
||||
):
|
||||
"""生成合集视频"""
|
||||
try:
|
||||
from ...models.collection import Collection
|
||||
from ...models.clip import Clip
|
||||
from ...utils.video_processor import VideoProcessor
|
||||
from ...core.path_utils import get_project_directory
|
||||
from pathlib import Path
|
||||
import json
|
||||
|
||||
# 验证项目是否存在
|
||||
project = project_service.get(project_id)
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="项目不存在")
|
||||
|
||||
# 获取合集记录
|
||||
collection = db.query(Collection).filter(Collection.id == collection_id).first()
|
||||
if not collection:
|
||||
raise HTTPException(status_code=404, detail="合集不存在")
|
||||
|
||||
# 验证合集属于该项目
|
||||
if str(collection.project_id) != project_id:
|
||||
raise HTTPException(status_code=400, detail="合集不属于指定项目")
|
||||
|
||||
# 获取合集的切片ID列表
|
||||
metadata = getattr(collection, 'collection_metadata', {}) or {}
|
||||
clip_ids = metadata.get('clip_ids', [])
|
||||
|
||||
if not clip_ids:
|
||||
raise HTTPException(status_code=400, detail="合集没有包含任何切片")
|
||||
|
||||
# 获取切片信息,并按照clip_ids的顺序排列
|
||||
clips_dict = {clip.id: clip for clip in db.query(Clip).filter(Clip.id.in_(clip_ids)).all()}
|
||||
if len(clips_dict) != len(clip_ids):
|
||||
raise HTTPException(status_code=400, detail="部分切片不存在")
|
||||
|
||||
# 按照用户调整的顺序获取clips
|
||||
ordered_clips = [clips_dict[clip_id] for clip_id in clip_ids if clip_id in clips_dict]
|
||||
|
||||
# 获取项目目录
|
||||
project_dir = get_project_directory(project_id)
|
||||
collections_dir = project_dir / "output" / "collections"
|
||||
collections_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 准备切片视频文件路径,按照用户调整的顺序
|
||||
clips_dir = project_dir / "output" / "clips"
|
||||
clip_video_paths = []
|
||||
|
||||
for clip in ordered_clips:
|
||||
if clip.video_path and Path(clip.video_path).exists():
|
||||
clip_video_paths.append(Path(clip.video_path))
|
||||
else:
|
||||
# 尝试在clips目录中查找
|
||||
possible_paths = [
|
||||
clips_dir / f"{clip.id}_*.mp4",
|
||||
clips_dir / f"clip_{clip.id}.mp4",
|
||||
clips_dir / f"{clip.id}.mp4"
|
||||
]
|
||||
|
||||
found = False
|
||||
for pattern in possible_paths:
|
||||
if pattern.name.endswith('*'):
|
||||
# 处理通配符
|
||||
matches = list(clips_dir.glob(pattern.name))
|
||||
if matches:
|
||||
clip_video_paths.append(matches[0])
|
||||
found = True
|
||||
break
|
||||
else:
|
||||
if pattern.exists():
|
||||
clip_video_paths.append(pattern)
|
||||
found = True
|
||||
break
|
||||
|
||||
if not found:
|
||||
raise HTTPException(status_code=404, detail=f"切片视频文件不存在: {clip.id}")
|
||||
|
||||
# 生成合集视频文件名
|
||||
collection_name = collection.name or f"collection_{collection_id}"
|
||||
# 清理文件名中的特殊字符
|
||||
safe_name = "".join(c for c in collection_name if c.isalnum() or c in (' ', '-', '_')).rstrip()
|
||||
safe_name = safe_name.replace(' ', '_')
|
||||
output_filename = f"{collection_id}_{safe_name}.mp4"
|
||||
output_path = collections_dir / output_filename
|
||||
|
||||
# 使用VideoProcessor创建合集
|
||||
video_processor = VideoProcessor()
|
||||
success = video_processor.create_collection(clip_video_paths, output_path)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=500, detail="合集视频生成失败")
|
||||
|
||||
# 更新合集的export_path
|
||||
collection.export_path = str(output_path)
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "合集视频生成成功",
|
||||
"collection_id": collection_id,
|
||||
"output_path": str(output_path),
|
||||
"filename": output_filename
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"生成合集视频失败: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"生成合集视频失败: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/{project_id}/download")
|
||||
async def download_project_file(
|
||||
project_id: str,
|
||||
clip_id: Optional[str] = Query(None, description="下载指定切片"),
|
||||
collection_id: Optional[str] = Query(None, description="下载指定合集"),
|
||||
db: Session = Depends(get_db),
|
||||
project_service: ProjectService = Depends(get_project_service)
|
||||
):
|
||||
"""下载项目文件(切片或合集)"""
|
||||
try:
|
||||
from fastapi.responses import FileResponse
|
||||
from pathlib import Path
|
||||
|
||||
# 验证项目是否存在
|
||||
project = project_service.get(project_id)
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="项目不存在")
|
||||
|
||||
if collection_id:
|
||||
# 下载合集视频
|
||||
from ...models.collection import Collection
|
||||
collection = db.query(Collection).filter(Collection.id == collection_id).first()
|
||||
if not collection:
|
||||
raise HTTPException(status_code=404, detail="合集不存在")
|
||||
|
||||
if not collection.export_path:
|
||||
raise HTTPException(status_code=404, detail="合集视频文件不存在")
|
||||
|
||||
file_path = Path(collection.export_path)
|
||||
if not file_path.exists():
|
||||
raise HTTPException(status_code=404, detail="合集视频文件不存在")
|
||||
|
||||
# 生成下载文件名
|
||||
collection_name = collection.name or f"collection_{collection_id}"
|
||||
safe_name = "".join(c for c in collection_name if c.isalnum() or c in (' ', '-', '_')).rstrip()
|
||||
safe_name = safe_name.replace(' ', '_')
|
||||
filename = f"{safe_name}.mp4"
|
||||
|
||||
return FileResponse(
|
||||
path=str(file_path),
|
||||
filename=filename,
|
||||
media_type="video/mp4",
|
||||
headers={
|
||||
"Content-Disposition": f"attachment; filename*=UTF-8''{filename}"
|
||||
}
|
||||
)
|
||||
|
||||
elif clip_id:
|
||||
# 下载切片视频
|
||||
from ...models.clip import Clip
|
||||
clip = db.query(Clip).filter(Clip.id == clip_id).first()
|
||||
if not clip:
|
||||
raise HTTPException(status_code=404, detail="切片不存在")
|
||||
|
||||
if not clip.video_path:
|
||||
raise HTTPException(status_code=404, detail="切片视频文件不存在")
|
||||
|
||||
file_path = Path(clip.video_path)
|
||||
if not file_path.exists():
|
||||
raise HTTPException(status_code=404, detail="切片视频文件不存在")
|
||||
|
||||
# 生成下载文件名
|
||||
clip_title = clip.title or f"clip_{clip_id}"
|
||||
safe_name = "".join(c for c in clip_title if c.isalnum() or c in (' ', '-', '_')).rstrip()
|
||||
safe_name = safe_name.replace(' ', '_')
|
||||
filename = f"{safe_name}.mp4"
|
||||
|
||||
return FileResponse(
|
||||
path=str(file_path),
|
||||
filename=filename,
|
||||
media_type="video/mp4",
|
||||
headers={
|
||||
"Content-Disposition": f"attachment; filename*=UTF-8''{filename}"
|
||||
}
|
||||
)
|
||||
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="必须指定clip_id或collection_id")
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"下载文件失败: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"下载文件失败: {str(e)}")
|
||||
+1
-1
@@ -1 +1 @@
|
||||
4728
|
||||
13281
|
||||
Binary file not shown.
Vendored
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
4827
|
||||
13393
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import { Card, Typography, Button, Tooltip, Modal, message } from 'antd'
|
||||
import { Card, Button, Tooltip, Modal, message } from 'antd'
|
||||
import { PlayCircleOutlined, DownloadOutlined, ClockCircleOutlined, StarFilled, EditOutlined, UploadOutlined } from '@ant-design/icons'
|
||||
import ReactPlayer from 'react-player'
|
||||
import { Clip } from '../store/useProjectStore'
|
||||
@@ -7,22 +7,23 @@ import SubtitleEditor from './SubtitleEditor'
|
||||
import { subtitleEditorApi } from '../services/subtitleEditorApi'
|
||||
import { SubtitleSegment, VideoEditOperation } from '../types/subtitle'
|
||||
import BilibiliManager from './BilibiliManager'
|
||||
import EditableTitle from './EditableTitle'
|
||||
import './ClipCard.css'
|
||||
|
||||
const { Title } = Typography
|
||||
|
||||
interface ClipCardProps {
|
||||
clip: Clip
|
||||
videoUrl?: string
|
||||
onDownload: (clipId: string) => void
|
||||
projectId?: string
|
||||
onClipUpdate?: (clipId: string, updates: Partial<Clip>) => void
|
||||
}
|
||||
|
||||
const ClipCard: React.FC<ClipCardProps> = ({
|
||||
clip,
|
||||
videoUrl,
|
||||
onDownload,
|
||||
projectId
|
||||
projectId,
|
||||
onClipUpdate
|
||||
}) => {
|
||||
const [showPlayer, setShowPlayer] = useState(false)
|
||||
const [videoThumbnail, setVideoThumbnail] = useState<string | null>(null)
|
||||
@@ -63,7 +64,7 @@ const ClipCard: React.FC<ClipCardProps> = ({
|
||||
|
||||
const handleDownloadWithTitle = async () => {
|
||||
try {
|
||||
const fileName = `${clip.generated_title || clip.title || '视频片段'}.mp4`
|
||||
const fileName = `${clip.title || clip.generated_title || '视频片段'}.mp4`
|
||||
|
||||
// 使用fetch获取视频文件
|
||||
const response = await fetch(videoUrl || '')
|
||||
@@ -92,7 +93,7 @@ const ClipCard: React.FC<ClipCardProps> = ({
|
||||
} catch (error) {
|
||||
console.error('下载失败:', error)
|
||||
// 如果fetch失败,回退到原来的方法
|
||||
const fileName = `${clip.generated_title || clip.title || '视频片段'}.mp4`
|
||||
const fileName = `${clip.title || clip.generated_title || '视频片段'}.mp4`
|
||||
const link = document.createElement('a')
|
||||
link.href = videoUrl || ''
|
||||
link.download = fileName
|
||||
@@ -146,6 +147,11 @@ const ClipCard: React.FC<ClipCardProps> = ({
|
||||
}
|
||||
}
|
||||
|
||||
const handleTitleUpdate = (newTitle: string) => {
|
||||
// 更新本地状态
|
||||
onClipUpdate?.(clip.id, { title: newTitle })
|
||||
}
|
||||
|
||||
|
||||
const formatDuration = (seconds: number) => {
|
||||
if (!seconds || seconds <= 0) return '00:00'
|
||||
@@ -351,20 +357,18 @@ const ClipCard: React.FC<ClipCardProps> = ({
|
||||
<div style={{ padding: '16px', height: '180px', display: 'flex', flexDirection: 'column' }}>
|
||||
{/* 标题区域 */}
|
||||
<div style={{ marginBottom: '8px' }}>
|
||||
<Title
|
||||
level={5}
|
||||
ellipsis={{ rows: 2 }}
|
||||
<EditableTitle
|
||||
title={clip.title || clip.generated_title || '未命名片段'}
|
||||
clipId={clip.id}
|
||||
onTitleUpdate={handleTitleUpdate}
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: '16px',
|
||||
fontWeight: 600,
|
||||
lineHeight: '1.4',
|
||||
color: '#ffffff',
|
||||
minHeight: '44px'
|
||||
}}
|
||||
>
|
||||
{clip.generated_title || clip.title || '未命名片段'}
|
||||
</Title>
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 内容要点 */}
|
||||
@@ -454,7 +458,7 @@ const ClipCard: React.FC<ClipCardProps> = ({
|
||||
|
||||
{/* 视频播放模态框 */}
|
||||
<Modal
|
||||
title={clip.generated_title || clip.title || '视频预览'}
|
||||
title={clip.title || clip.generated_title || '视频预览'}
|
||||
open={showPlayer}
|
||||
onCancel={handleClosePlayer}
|
||||
footer={[
|
||||
@@ -535,7 +539,7 @@ const ClipCard: React.FC<ClipCardProps> = ({
|
||||
onClose={() => setShowBilibiliManager(false)}
|
||||
projectId={projectId || ''}
|
||||
clipIds={[clip.id]}
|
||||
clipTitles={[clip.generated_title || clip.title || '视频片段']}
|
||||
clipTitles={[clip.title || clip.generated_title || '视频片段']}
|
||||
onUploadSuccess={() => {
|
||||
// 投稿成功后可以刷新数据或显示提示
|
||||
console.log('投稿成功')
|
||||
|
||||
@@ -198,12 +198,12 @@ const CollectionCard: React.FC<CollectionCardProps> = ({
|
||||
</Button>
|
||||
</Tooltip>,
|
||||
onGenerateVideo && (
|
||||
<Tooltip key="generate" title="导出完整视频">
|
||||
<Tooltip key="generate" title="下载合集视频">
|
||||
<Button
|
||||
type="text"
|
||||
onClick={() => onGenerateVideo(collection.id)}
|
||||
>
|
||||
导出完整视频
|
||||
下载合集视频
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)
|
||||
|
||||
@@ -167,16 +167,16 @@ const CollectionCardMini: React.FC<CollectionCardMiniProps> = ({
|
||||
onGenerateVideo(collection.id)
|
||||
}}
|
||||
style={{
|
||||
color: '#52c41a',
|
||||
color: '#1890ff',
|
||||
fontSize: '10px',
|
||||
height: '20px',
|
||||
padding: '0 6px',
|
||||
background: 'rgba(82, 196, 26, 0.1)',
|
||||
border: '1px solid rgba(82, 196, 26, 0.3)',
|
||||
background: 'rgba(24, 144, 255, 0.1)',
|
||||
border: '1px solid rgba(24, 144, 255, 0.3)',
|
||||
borderRadius: '4px'
|
||||
}}
|
||||
>
|
||||
生成
|
||||
下载
|
||||
</Button>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
import React, { useState, useRef, useEffect } from 'react'
|
||||
import { Input, Button, Space, message, Tooltip, Modal } from 'antd'
|
||||
import { EditOutlined, CheckOutlined, CloseOutlined } from '@ant-design/icons'
|
||||
import { projectApi } from '../services/api'
|
||||
import MagicWandIcon from './icons/MagicWandIcon'
|
||||
|
||||
interface EditableTitleProps {
|
||||
title: string
|
||||
clipId: string
|
||||
onTitleUpdate?: (newTitle: string) => void
|
||||
maxLength?: number
|
||||
style?: React.CSSProperties
|
||||
className?: string
|
||||
}
|
||||
|
||||
const EditableTitle: React.FC<EditableTitleProps> = ({
|
||||
title,
|
||||
clipId,
|
||||
onTitleUpdate,
|
||||
maxLength = 200,
|
||||
style,
|
||||
className
|
||||
}) => {
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [editValue, setEditValue] = useState(title)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [generating, setGenerating] = useState(false)
|
||||
const inputRef = useRef<any>(null)
|
||||
|
||||
// 当外部title变化时,同步内部状态
|
||||
useEffect(() => {
|
||||
setEditValue(title)
|
||||
}, [title])
|
||||
|
||||
// 当title变化时,如果不在编辑模式,确保显示最新值
|
||||
useEffect(() => {
|
||||
if (!isEditing) {
|
||||
setEditValue(title)
|
||||
}
|
||||
}, [title, isEditing])
|
||||
|
||||
// 进入编辑模式时聚焦输入框
|
||||
useEffect(() => {
|
||||
if (isEditing && inputRef.current) {
|
||||
inputRef.current.focus()
|
||||
// TextArea组件没有select方法,使用setSelectionRange代替
|
||||
if (inputRef.current.setSelectionRange) {
|
||||
inputRef.current.setSelectionRange(0, inputRef.current.value.length)
|
||||
}
|
||||
}
|
||||
}, [isEditing])
|
||||
|
||||
const handleStartEdit = () => {
|
||||
setEditValue(title)
|
||||
setIsEditing(true)
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
setEditValue(title)
|
||||
setIsEditing(false)
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
const trimmedValue = editValue.trim()
|
||||
|
||||
if (!trimmedValue) {
|
||||
message.error('标题不能为空')
|
||||
return
|
||||
}
|
||||
|
||||
if (trimmedValue.length > maxLength) {
|
||||
message.error(`标题长度不能超过${maxLength}个字符`)
|
||||
return
|
||||
}
|
||||
|
||||
if (trimmedValue === title) {
|
||||
setIsEditing(false)
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
await projectApi.updateClipTitle(clipId, trimmedValue)
|
||||
message.success('标题更新成功')
|
||||
setIsEditing(false)
|
||||
// 先更新本地状态,再调用回调
|
||||
onTitleUpdate?.(trimmedValue)
|
||||
} catch (error: any) {
|
||||
console.error('更新标题失败:', error)
|
||||
message.error(error.userMessage || error.message || '更新标题失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleGenerateTitle = async () => {
|
||||
console.log('开始生成标题,clipId:', clipId)
|
||||
setGenerating(true)
|
||||
try {
|
||||
const result = await projectApi.generateClipTitle(clipId)
|
||||
console.log('生成标题结果:', result)
|
||||
if (result.success && result.generated_title) {
|
||||
setEditValue(result.generated_title)
|
||||
message.success('标题生成成功,您可以继续编辑或点击保存')
|
||||
} else {
|
||||
message.error('标题生成失败')
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('生成标题失败:', error)
|
||||
message.error(error.userMessage || error.message || '生成标题失败')
|
||||
} finally {
|
||||
setGenerating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyPress = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleSave()
|
||||
} else if (e.key === 'Escape') {
|
||||
handleCancel()
|
||||
}
|
||||
}
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<Modal
|
||||
title="编辑标题"
|
||||
open={isEditing}
|
||||
onCancel={handleCancel}
|
||||
footer={null}
|
||||
width={600}
|
||||
destroyOnClose
|
||||
maskClosable={false}
|
||||
>
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<Input.TextArea
|
||||
ref={inputRef}
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onKeyDown={handleKeyPress}
|
||||
maxLength={maxLength}
|
||||
placeholder="请输入标题"
|
||||
autoSize={{ minRows: 3, maxRows: 8 }}
|
||||
style={{
|
||||
resize: 'none',
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.5'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div style={{ fontSize: '12px', color: '#666' }}>
|
||||
字符数: {editValue.length}/{maxLength}
|
||||
</div>
|
||||
<Space>
|
||||
<Tooltip title="AI生成标题">
|
||||
<Button
|
||||
icon={<MagicWandIcon />}
|
||||
loading={generating}
|
||||
onClick={() => {
|
||||
console.log('AI生成标题按钮被点击');
|
||||
handleGenerateTitle();
|
||||
}}
|
||||
disabled={loading}
|
||||
>
|
||||
AI生成
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Button onClick={handleCancel} disabled={loading || generating}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<CheckOutlined />}
|
||||
loading={loading}
|
||||
onClick={handleSave}
|
||||
disabled={generating}
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: '8px',
|
||||
cursor: 'pointer',
|
||||
padding: '4px 0',
|
||||
...style
|
||||
}}
|
||||
className={className}
|
||||
onClick={handleStartEdit}
|
||||
title="点击编辑标题"
|
||||
>
|
||||
<span style={{
|
||||
flex: 1,
|
||||
wordBreak: 'break-word',
|
||||
lineHeight: '1.5',
|
||||
fontSize: '14px',
|
||||
minHeight: '20px'
|
||||
}}>
|
||||
{title}
|
||||
</span>
|
||||
<EditOutlined
|
||||
style={{
|
||||
color: '#1890ff',
|
||||
fontSize: '12px',
|
||||
opacity: 0.7,
|
||||
transition: 'opacity 0.2s',
|
||||
marginTop: '2px',
|
||||
flexShrink: 0
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default EditableTitle
|
||||
@@ -0,0 +1,46 @@
|
||||
import React from 'react';
|
||||
|
||||
interface MagicWandIconProps {
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
const MagicWandIcon: React.FC<MagicWandIconProps> = ({ className, style }) => {
|
||||
return (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
style={style}
|
||||
>
|
||||
{/* 魔法棒主体 - 白色对角线 */}
|
||||
<path
|
||||
d="M2 14L14 2"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
{/* 魔法棒顶部 - 小菱形 */}
|
||||
<path
|
||||
d="M12 2L14 4L12 6L10 4L12 2Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
{/* 左上角星星 */}
|
||||
<path
|
||||
d="M3 3L3.5 4.5L5 4.5L3.5 5.5L4 7L3 6L2 7L2.5 5.5L1 4.5L2.5 4.5L3 3Z"
|
||||
fill="#87CEEB"
|
||||
/>
|
||||
{/* 右下角星星 */}
|
||||
<path
|
||||
d="M13 13L13.5 14.5L15 14.5L13.5 15.5L14 17L13 16L12 17L12.5 15.5L11 14.5L12.5 14.5L13 13Z"
|
||||
fill="#87CEEB"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export default MagicWandIcon;
|
||||
|
||||
@@ -15,11 +15,13 @@ export const useCollectionVideoDownload = () => {
|
||||
setIsGenerating(true)
|
||||
|
||||
try {
|
||||
// 第一步:开始生成视频
|
||||
message.info('开始生成合集视频...')
|
||||
// 直接按用户当前调整的顺序生成合集视频
|
||||
message.info('正在按您的顺序生成合集视频...')
|
||||
|
||||
// 生成合集视频(按用户调整的顺序)
|
||||
await projectApi.generateCollectionVideo(projectId, collectionId)
|
||||
|
||||
// 第二步:等待3秒让后端完成文件生成,然后直接下载
|
||||
// 等待1秒让后端完成文件生成,然后下载
|
||||
message.success('合集视频生成成功,正在下载...')
|
||||
|
||||
setTimeout(async () => {
|
||||
@@ -30,11 +32,11 @@ export const useCollectionVideoDownload = () => {
|
||||
console.error('下载失败:', downloadError)
|
||||
message.error('下载失败,请稍后重试')
|
||||
}
|
||||
}, 3000) // 等待3秒让后端完成文件生成
|
||||
}, 1000)
|
||||
|
||||
} catch (error) {
|
||||
console.error('生成合集视频失败:', error)
|
||||
message.error('导出合集视频失败')
|
||||
message.error('生成合集视频失败')
|
||||
} finally {
|
||||
setIsGenerating(false)
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
PlayCircleOutlined,
|
||||
PlusOutlined
|
||||
} from '@ant-design/icons'
|
||||
import { useProjectStore } from '../store/useProjectStore'
|
||||
import { useProjectStore, Project, Clip } from '../store/useProjectStore'
|
||||
import { projectApi } from '../services/api'
|
||||
import ClipCard from '../components/ClipCard'
|
||||
import CollectionCardMini from '../components/CollectionCardMini'
|
||||
@@ -507,8 +507,20 @@ const ProjectDetailPage: React.FC = () => {
|
||||
key={clip.id}
|
||||
clip={clip}
|
||||
projectId={currentProject.id}
|
||||
videoUrl={projectApi.getClipVideoUrl(currentProject.id, clip.id, clip.generated_title || clip.title)}
|
||||
videoUrl={projectApi.getClipVideoUrl(currentProject.id, clip.id, clip.title || clip.generated_title)}
|
||||
onDownload={(clipId) => projectApi.downloadVideo(currentProject.id, clipId)}
|
||||
onClipUpdate={(clipId: string, updates: Partial<Clip>) => {
|
||||
// 更新本地状态
|
||||
if (currentProject) {
|
||||
const updatedProject = {
|
||||
...currentProject,
|
||||
clips: currentProject.clips?.map((c: Clip) =>
|
||||
c.id === clipId ? { ...c, ...updates } : c
|
||||
) || []
|
||||
}
|
||||
setCurrentProject(updatedProject)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -290,6 +290,16 @@ export const projectApi = {
|
||||
return api.patch(`/projects/${projectId}/clips/${clipId}`, updates)
|
||||
},
|
||||
|
||||
// 更新切片标题
|
||||
updateClipTitle: async (clipId: string, title: string): Promise<any> => {
|
||||
return api.patch(`/clips/${clipId}/title`, { title })
|
||||
},
|
||||
|
||||
// 生成切片标题
|
||||
generateClipTitle: async (clipId: string): Promise<{clip_id: string, generated_title: string, success: boolean}> => {
|
||||
return api.post(`/clips/${clipId}/generate-title`)
|
||||
},
|
||||
|
||||
// 创建合集
|
||||
createCollection: (projectId: string, collectionData: { collection_title: string, collection_summary: string, clip_ids: string[] }): Promise<Collection> => {
|
||||
return api.post(`/collections/`, {
|
||||
|
||||
Reference in new issue
Block a user