优化标题编辑交互:使用Modal浮层,AI生成后允许用户继续编辑

- 将标题输入框改为Modal浮层形式,不影响卡片布局
- AI生成标题后不自动关闭编辑态,允许用户继续编辑
- 优化用户体验,提供更大的编辑空间
- 支持键盘快捷键和字符数显示
This commit is contained in:
Kris Ka committed 2025-09-15 20:05:31 +08:00
1 parent 45efda4e2a
commit 7ef91fc5ba
16 files changed
+817 -6574

No files matched your search

+126 -6542
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -1 +1 @@
4803
13361
+109
View File
@@ -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
View File
@@ -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
View File
@@ -1 +1 @@
4728
13281
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
4827
13393
+19 -15
View File
@@ -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('投稿成功')
+2 -2
View File
@@ -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>
)}
+224
View File
@@ -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)
}
+14 -2
View File
@@ -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>
+10
View File
@@ -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/`, {