mirror of
https://github.com/zhouxiaoka/autoclip.git
synced 2026-05-06 14:04:32 +08:00
完善合集标题编辑功能和优化切片卡片布局
合集标题编辑功能: - 创建专门的合集标题生成prompt,参考切片标题的吸引人风格 - 在collections API中添加生成和更新标题的接口 - 创建EditableCollectionTitle组件,支持编辑和AI生成 - 在CollectionCard和CollectionCardMini中集成编辑功能 - 修复合集标题更新后页面不立即刷新的问题 切片卡片布局优化: - 固定各元素高度,避免按钮位置受内容影响 - 标题区域固定44px高度,内容要点固定58px高度 - 按钮区域固定在底部,与边缘保持16px边距 - 文本超出时显示省略号,hover时通过Tooltip显示完整内容 - 优化布局结构,确保所有卡片元素高度一致
This commit is contained in:
43
backend.log
43
backend.log
@@ -125,3 +125,46 @@ NFO - WebSocket网关服务已禁用
|
||||
2025-09-15 19:33:31,936 - backend.main - INFO - WebSocket网关服务已禁用,使用新的简化进度系统
|
||||
2025-09-15 19:33:48,972 - backend.core.llm_manager - INFO - 已初始化dashscope提供商,模型: qwen-plus
|
||||
2025-09-15 20:19:52,449 - backend.api.v1.projects - ERROR - 生成合集视频失败: clips_dir 参数是必需的,不能使用全局路径
|
||||
2025-09-15 20:26:48,205 - backend.main - INFO - 正在关闭AutoClip API服务...
|
||||
2025-09-15 20:26:48,206 - backend.main - INFO - WebSocket网关服务已禁用
|
||||
2025-09-15 20:26:48,851 - backend.services.processing_orchestrator - INFO - 流水线模块导入成功
|
||||
2025-09-15 20:26:49,103 - backend.services.simple_progress - INFO - Redis连接成功
|
||||
2025-09-15 20:26:49,174 - backend.main - INFO - 启动AutoClip API服务...
|
||||
2025-09-15 20:26:49,175 - backend.main - INFO - 数据库表创建完成
|
||||
2025-09-15 20:26:49,175 - backend.main - INFO - API密钥已加载到环境变量
|
||||
2025-09-15 20:26:49,175 - backend.main - INFO - WebSocket网关服务已禁用,使用新的简化进度系统
|
||||
2025-09-15 20:26:55,341 - backend.main - INFO - 正在关闭AutoClip API服务...
|
||||
2025-09-15 20:26:55,341 - backend.main - INFO - WebSocket网关服务已禁用
|
||||
2025-09-15 20:26:55,844 - backend.services.processing_orchestrator - INFO - 流水线模块导入成功
|
||||
2025-09-15 20:26:56,041 - backend.services.simple_progress - INFO - Redis连接成功
|
||||
2025-09-15 20:26:56,111 - backend.main - INFO - 启动AutoClip API服务...
|
||||
2025-09-15 20:26:56,112 - backend.main - INFO - 数据库表创建完成
|
||||
2025-09-15 20:26:56,112 - backend.main - INFO - API密钥已加载到环境变量
|
||||
2025-09-15 20:26:56,112 - backend.main - INFO - WebSocket网关服务已禁用,使用新的简化进度系统
|
||||
2025-09-15 20:27:05,918 - backend.main - INFO - 正在关闭AutoClip API服务...
|
||||
2025-09-15 20:27:05,918 - backend.main - INFO - WebSocket网关服务已禁用
|
||||
2025-09-15 20:27:06,434 - backend.services.processing_orchestrator - INFO - 流水线模块导入成功
|
||||
2025-09-15 20:27:06,631 - backend.services.simple_progress - INFO - Redis连接成功
|
||||
2025-09-15 20:27:06,700 - backend.main - INFO - 启动AutoClip API服务...
|
||||
2025-09-15 20:27:06,701 - backend.main - INFO - 数据库表创建完成
|
||||
2025-09-15 20:27:06,701 - backend.main - INFO - API密钥已加载到环境变量
|
||||
2025-09-15 20:27:06,701 - backend.main - INFO - WebSocket网关服务已禁用,使用新的简化进度系统
|
||||
2025-09-15 20:29:47,869 - backend.api.v1.collections - ERROR - 生成合集标题失败: 'ClipRepository' object has no attribute 'get'
|
||||
2025-09-15 20:30:00,211 - backend.main - INFO - 正在关闭AutoClip API服务...
|
||||
2025-09-15 20:30:00,211 - backend.main - INFO - WebSocket网关服务已禁用
|
||||
2025-09-15 20:30:00,840 - backend.services.processing_orchestrator - INFO - 流水线模块导入成功
|
||||
2025-09-15 20:30:01,091 - backend.services.simple_progress - INFO - Redis连接成功
|
||||
2025-09-15 20:30:01,164 - backend.main - INFO - 启动AutoClip API服务...
|
||||
2025-09-15 20:30:01,165 - backend.main - INFO - 数据库表创建完成
|
||||
2025-09-15 20:30:01,165 - backend.main - INFO - API密钥已加载到环境变量
|
||||
2025-09-15 20:30:01,166 - backend.main - INFO - WebSocket网关服务已禁用,使用新的简化进度系统
|
||||
2025-09-15 20:30:04,931 - backend.core.llm_manager - INFO - 已初始化dashscope提供商,模型: qwen-plus
|
||||
2025-09-15 20:30:30,747 - backend.main - INFO - 正在关闭AutoClip API服务...
|
||||
2025-09-15 20:30:30,747 - backend.main - INFO - WebSocket网关服务已禁用
|
||||
2025-09-15 20:30:31,383 - backend.services.processing_orchestrator - INFO - 流水线模块导入成功
|
||||
2025-09-15 20:30:31,593 - backend.services.simple_progress - INFO - Redis连接成功
|
||||
2025-09-15 20:30:31,665 - backend.main - INFO - 启动AutoClip API服务...
|
||||
2025-09-15 20:30:31,666 - backend.main - INFO - 数据库表创建完成
|
||||
2025-09-15 20:30:31,666 - backend.main - INFO - API密钥已加载到环境变量
|
||||
2025-09-15 20:30:31,666 - backend.main - INFO - WebSocket网关服务已禁用,使用新的简化进度系统
|
||||
2025-09-15 20:37:07,166 - backend.core.llm_manager - INFO - 已初始化dashscope提供商,模型: qwen-plus
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
合集API路由
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import List, Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -10,6 +11,8 @@ from ...services.collection_service import CollectionService
|
||||
from ...schemas.collection import CollectionCreate, CollectionUpdate, CollectionResponse, CollectionListResponse
|
||||
from ...schemas.base import PaginationParams
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@@ -238,4 +241,123 @@ async def reorder_collection_clips(
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/{collection_id}/generate-title", response_model=dict)
|
||||
async def generate_collection_title(
|
||||
collection_id: str,
|
||||
collection_service: CollectionService = Depends(get_collection_service)
|
||||
):
|
||||
"""Generate a new title for a collection using LLM."""
|
||||
try:
|
||||
collection = collection_service.get(collection_id)
|
||||
if not collection:
|
||||
raise HTTPException(status_code=404, detail="合集不存在")
|
||||
|
||||
# 获取合集元数据
|
||||
collection_metadata = getattr(collection, 'collection_metadata', {}) or {}
|
||||
|
||||
if not collection_metadata:
|
||||
raise HTTPException(status_code=404, detail="合集元数据不存在")
|
||||
|
||||
# 获取合集中的切片信息
|
||||
clip_ids = collection_metadata.get('clip_ids', [])
|
||||
if not clip_ids:
|
||||
raise HTTPException(status_code=404, detail="合集中没有切片")
|
||||
|
||||
# 获取切片详细信息
|
||||
from ...repositories.clip_repository import ClipRepository
|
||||
clip_repo = ClipRepository(collection_service.db)
|
||||
|
||||
clips_data = []
|
||||
for clip_id in clip_ids:
|
||||
clip = clip_repo.get_by_id(clip_id)
|
||||
if clip:
|
||||
clip_metadata = getattr(clip, 'clip_metadata', {}) or {}
|
||||
clips_data.append({
|
||||
"id": clip_id,
|
||||
"title": clip_metadata.get('outline', '') or getattr(clip, 'title', ''),
|
||||
"content": clip_metadata.get('content', []),
|
||||
"recommend_reason": clip_metadata.get('recommend_reason', '')
|
||||
})
|
||||
|
||||
if not clips_data:
|
||||
raise HTTPException(status_code=404, detail="无法获取切片内容")
|
||||
|
||||
# 构建LLM输入
|
||||
llm_input = {
|
||||
"collection_id": collection_id,
|
||||
"collection_title": collection.name,
|
||||
"collection_description": collection.description or "",
|
||||
"clips": clips_data,
|
||||
"total_duration_minutes": collection_metadata.get('total_duration_minutes', 0),
|
||||
"target_audience": collection_metadata.get('target_audience', ''),
|
||||
"key_themes": collection_metadata.get('key_themes', [])
|
||||
}
|
||||
|
||||
# 调用LLM生成标题
|
||||
from ...utils.llm_client import LLMClient
|
||||
from ...core.shared_config import PROMPT_FILES
|
||||
|
||||
llm_client = LLMClient()
|
||||
|
||||
with open(PROMPT_FILES['collection_title'], 'r', encoding='utf-8') as f:
|
||||
title_prompt = f.read()
|
||||
|
||||
raw_response = llm_client.call_with_retry(title_prompt, llm_input)
|
||||
|
||||
if not raw_response:
|
||||
raise HTTPException(status_code=500, detail="LLM调用失败")
|
||||
|
||||
title_result = llm_client.parse_json_response(raw_response)
|
||||
|
||||
if not isinstance(title_result, dict) or 'generated_title' not in title_result:
|
||||
raise HTTPException(status_code=500, detail="LLM返回格式错误")
|
||||
|
||||
generated_title = title_result['generated_title']
|
||||
|
||||
return {
|
||||
"collection_id": collection_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.put("/{collection_id}/title", response_model=dict)
|
||||
async def update_collection_title(
|
||||
collection_id: str,
|
||||
title_data: dict,
|
||||
collection_service: CollectionService = Depends(get_collection_service)
|
||||
):
|
||||
"""Update collection title."""
|
||||
try:
|
||||
new_title = title_data.get('title')
|
||||
if not new_title:
|
||||
raise HTTPException(status_code=400, detail="标题不能为空")
|
||||
|
||||
collection = collection_service.get(collection_id)
|
||||
if not collection:
|
||||
raise HTTPException(status_code=404, detail="合集不存在")
|
||||
|
||||
# 更新合集标题
|
||||
collection.name = new_title
|
||||
collection_service.db.commit()
|
||||
|
||||
return {
|
||||
"collection_id": collection_id,
|
||||
"title": new_title,
|
||||
"success": True
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"更新合集标题失败: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"更新合集标题失败: {str(e)}")
|
||||
@@ -95,7 +95,8 @@ PROMPT_FILES = {
|
||||
"timeline": PROMPT_DIR / "时间点.txt",
|
||||
"recommendation": PROMPT_DIR / "推荐理由.txt",
|
||||
"title": PROMPT_DIR / "标题生成.txt",
|
||||
"clustering": PROMPT_DIR / "主题聚类.txt"
|
||||
"clustering": PROMPT_DIR / "主题聚类.txt",
|
||||
"collection_title": PROMPT_DIR / "collection_title.txt"
|
||||
}
|
||||
|
||||
# API配置
|
||||
|
||||
79
backend/prompt/collection_title.txt
Normal file
79
backend/prompt/collection_title.txt
Normal file
@@ -0,0 +1,79 @@
|
||||
#角色设定
|
||||
你是一位顶级的短视频内容策划,深谙爆款合集标题的创作逻辑。你的任务是为视频合集生成一个最佳的、高质量、高点击率但**绝不脱离内容**的标题。
|
||||
|
||||
## 核心原则
|
||||
1. **忠于内容**: 标题的立意必须直接源自合集内容,严禁无中生有。
|
||||
2. **拒绝夸张**: 避免使用"震惊"、"惊呆了"等过度营销的词汇。
|
||||
3. **突出亮点**: 标题需精准捕捉合集最核心的价值、最吸引人的看点或最有用的信息。
|
||||
4. **精炼有力**: 标题必须简洁、有冲击力,能迅速抓住用户眼球。
|
||||
5. **合集特色**: 体现合集的完整性和系列性,让用户感受到这是一个完整的内容体系。
|
||||
|
||||
## 输入格式
|
||||
你将收到一个JSON对象,包含合集的详细信息:
|
||||
```json
|
||||
{
|
||||
"collection_id": "collection_123",
|
||||
"collection_title": "科技投资全景分析",
|
||||
"collection_description": "从政策红利到具体标的,全面解析当前科技投资机会与策略,帮助投资者把握科技股投资节奏。",
|
||||
"clips": [
|
||||
{
|
||||
"id": "clip_1",
|
||||
"title": "科技股投资别追高,抓住AI基建这个核心才是关键",
|
||||
"content": ["算力基建是核心", "AI基建值得关注", "避免追高"],
|
||||
"recommend_reason": "观点犀利,信息密度极高,精准剖析了当前科技股的核心投资逻辑。"
|
||||
},
|
||||
{
|
||||
"id": "clip_2",
|
||||
"title": "从迷信大牌到爱上国货,这届年轻人的消费观有多清醒?",
|
||||
"content": ["不再迷信大牌", "更注重性价比和国货", "理性消费成为主流"],
|
||||
"recommend_reason": "视角独特,紧贴年轻消费趋势,具有很强的话题性和讨论潜力。"
|
||||
}
|
||||
],
|
||||
"total_duration_minutes": 18,
|
||||
"target_audience": "科技股投资者",
|
||||
"key_themes": ["科技股投资", "政策解读", "选股策略"]
|
||||
}
|
||||
```
|
||||
|
||||
## 任务要求
|
||||
为输入的合集生成**1个**最佳标题,要求:
|
||||
- 体现合集的核心价值和看点
|
||||
- 吸引目标受众点击
|
||||
- 突出内容的实用性和完整性
|
||||
- 长度控制在15-25字之间
|
||||
|
||||
## 标题风格参考
|
||||
### 投资理财类:
|
||||
- "科技股投资全攻略:从政策到实操,教你抓住AI时代红利"
|
||||
- "年轻人理财觉醒:从月光到财务自由,这套方法太实用了"
|
||||
- "A股投资避坑指南:散户必知的5个关键策略"
|
||||
|
||||
### 职场成长类:
|
||||
- "职场逆袭秘籍:从社恐到自信,这套沟通技巧改变了我"
|
||||
- "技能变现指南:如何把爱好变成赚钱的副业"
|
||||
- "职场新人必看:避开这些坑,让你少走3年弯路"
|
||||
|
||||
### 社会观察类:
|
||||
- "当代年轻人图鉴:从消费观到价值观,这届年轻人太清醒了"
|
||||
- "网络时代生存指南:如何在信息爆炸中保持独立思考"
|
||||
- "社会现象深度解析:那些被忽视的真相和背后的逻辑"
|
||||
|
||||
### 生活情感类:
|
||||
- "恋爱脱单攻略:从搭讪到表白,这套方法成功率90%"
|
||||
- "健康生活指南:从熬夜到早睡,我是如何改变生活方式的"
|
||||
- "情感关系解析:为什么有些人总是遇不到对的人"
|
||||
|
||||
## 输出格式
|
||||
请严格按照下面的JSON格式输出:
|
||||
```json
|
||||
{
|
||||
"collection_id": "collection_123",
|
||||
"generated_title": "科技股投资全攻略:从政策到实操,教你抓住AI时代红利"
|
||||
}
|
||||
```
|
||||
|
||||
## 注意事项:
|
||||
- 输出的`collection_id`必须是输入的`collection_id`。
|
||||
- `generated_title`必须是一个字符串,长度控制在15-25字之间。
|
||||
- 标题要体现合集的价值和完整性,让用户感受到这是一个完整的内容体系。
|
||||
- 最终输出必须是**一个完整的JSON对象**,不要添加任何其他解释性文字。
|
||||
BIN
data/autoclip.db
BIN
data/autoclip.db
Binary file not shown.
@@ -354,53 +354,84 @@ const ClipCard: React.FC<ClipCardProps> = ({
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div style={{ padding: '16px', height: '180px', display: 'flex', flexDirection: 'column' }}>
|
||||
{/* 标题区域 */}
|
||||
<div style={{ marginBottom: '8px' }}>
|
||||
<EditableTitle
|
||||
title={clip.title || clip.generated_title || '未命名片段'}
|
||||
clipId={clip.id}
|
||||
onTitleUpdate={handleTitleUpdate}
|
||||
style={{
|
||||
fontSize: '16px',
|
||||
fontWeight: 600,
|
||||
lineHeight: '1.4',
|
||||
color: '#ffffff',
|
||||
minHeight: '44px'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 内容要点 */}
|
||||
<div style={{ flex: 1, marginBottom: '12px', minHeight: '58px' }}>
|
||||
<Tooltip
|
||||
title={getDisplayContent()}
|
||||
placement="top"
|
||||
overlayStyle={{ maxWidth: '300px' }}
|
||||
mouseEnterDelay={0.5}
|
||||
>
|
||||
<div
|
||||
ref={textRef}
|
||||
<div style={{
|
||||
padding: '16px',
|
||||
height: '180px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'space-between'
|
||||
}}>
|
||||
{/* 内容区域 - 固定高度 */}
|
||||
<div style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
minHeight: 0 // 允许flex子项收缩
|
||||
}}>
|
||||
{/* 标题区域 - 固定高度 */}
|
||||
<div style={{
|
||||
height: '44px',
|
||||
marginBottom: '8px',
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start'
|
||||
}}>
|
||||
<EditableTitle
|
||||
title={clip.title || clip.generated_title || '未命名片段'}
|
||||
clipId={clip.id}
|
||||
onTitleUpdate={handleTitleUpdate}
|
||||
style={{
|
||||
fontSize: '13px',
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 3,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden',
|
||||
lineHeight: '1.5',
|
||||
color: '#b0b0b0',
|
||||
cursor: 'pointer',
|
||||
wordBreak: 'break-word',
|
||||
textOverflow: 'ellipsis'
|
||||
fontSize: '16px',
|
||||
fontWeight: 600,
|
||||
lineHeight: '1.4',
|
||||
color: '#ffffff',
|
||||
width: '100%'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 内容要点 - 固定高度 */}
|
||||
<div style={{
|
||||
height: '58px',
|
||||
marginBottom: '12px',
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start'
|
||||
}}>
|
||||
<Tooltip
|
||||
title={getDisplayContent()}
|
||||
placement="top"
|
||||
overlayStyle={{ maxWidth: '300px' }}
|
||||
mouseEnterDelay={0.5}
|
||||
>
|
||||
{getDisplayContent()}
|
||||
</div>
|
||||
</Tooltip>
|
||||
<div
|
||||
ref={textRef}
|
||||
style={{
|
||||
fontSize: '13px',
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 3,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden',
|
||||
lineHeight: '1.5',
|
||||
color: '#b0b0b0',
|
||||
cursor: 'pointer',
|
||||
wordBreak: 'break-word',
|
||||
textOverflow: 'ellipsis',
|
||||
width: '100%'
|
||||
}}
|
||||
>
|
||||
{getDisplayContent()}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
{/* 操作按钮 - 固定在底部 */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
height: '28px',
|
||||
alignItems: 'center',
|
||||
marginTop: 'auto'
|
||||
}}>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Card, Typography, Button, Space, Input, Tag, List, Modal, Tooltip, Divi
|
||||
import { EditOutlined, DownloadOutlined, SaveOutlined, CloseOutlined, PlayCircleOutlined, DragOutlined, DeleteOutlined } from '@ant-design/icons'
|
||||
import { Collection, Clip } from '../store/useProjectStore'
|
||||
import { DragDropContext, Droppable, Draggable, DropResult } from 'react-beautiful-dnd'
|
||||
import EditableCollectionTitle from './EditableCollectionTitle'
|
||||
|
||||
const { Text, Title } = Typography
|
||||
const { TextArea } = Input
|
||||
@@ -236,9 +237,21 @@ const CollectionCard: React.FC<CollectionCardProps> = ({
|
||||
</Space>
|
||||
) : (
|
||||
<Space direction="vertical" style={{ width: '100%' }} size="small">
|
||||
<Title level={5} ellipsis={{ rows: 2 }} style={{ margin: 0, minHeight: '44px' }}>
|
||||
{collection.collection_title}
|
||||
</Title>
|
||||
<div style={{ minHeight: '44px' }}>
|
||||
<EditableCollectionTitle
|
||||
title={collection.collection_title}
|
||||
collectionId={collection.id}
|
||||
onTitleUpdate={(newTitle) => {
|
||||
// 更新合集标题
|
||||
onUpdate(collection.id, { collection_title: newTitle })
|
||||
}}
|
||||
style={{
|
||||
fontSize: '16px',
|
||||
fontWeight: '600',
|
||||
color: '#ffffff'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{collection.collection_type === 'manual' ? (
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useState } from 'react'
|
||||
import { Card, Typography, Button, Popconfirm } from 'antd'
|
||||
import { DeleteOutlined } from '@ant-design/icons'
|
||||
import { Collection, Clip } from '../store/useProjectStore'
|
||||
import EditableCollectionTitle from './EditableCollectionTitle'
|
||||
|
||||
const { Text, Title } = Typography
|
||||
|
||||
@@ -11,6 +12,7 @@ interface CollectionCardMiniProps {
|
||||
onView: (collection: Collection) => void
|
||||
onGenerateVideo?: (collectionId: string) => void
|
||||
onDelete?: (collectionId: string) => void
|
||||
onUpdate?: (collectionId: string, updates: Partial<Collection>) => void
|
||||
}
|
||||
|
||||
const CollectionCardMini: React.FC<CollectionCardMiniProps> = ({
|
||||
@@ -18,7 +20,8 @@ const CollectionCardMini: React.FC<CollectionCardMiniProps> = ({
|
||||
clips,
|
||||
onView,
|
||||
onGenerateVideo,
|
||||
onDelete
|
||||
onDelete,
|
||||
onUpdate
|
||||
}) => {
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
// 按照collection.clip_ids的顺序排列clips
|
||||
@@ -75,21 +78,27 @@ const CollectionCardMini: React.FC<CollectionCardMiniProps> = ({
|
||||
alignItems: 'flex-start',
|
||||
marginBottom: '12px'
|
||||
}}>
|
||||
<Title
|
||||
level={5}
|
||||
ellipsis={{ rows: 2 }}
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: '15px',
|
||||
fontWeight: 600,
|
||||
lineHeight: '1.4',
|
||||
color: '#ffffff',
|
||||
flex: 1,
|
||||
paddingRight: '8px'
|
||||
}}
|
||||
>
|
||||
{collection.collection_title}
|
||||
</Title>
|
||||
<div style={{
|
||||
flex: 1,
|
||||
paddingRight: '8px'
|
||||
}}>
|
||||
<EditableCollectionTitle
|
||||
title={collection.collection_title}
|
||||
collectionId={collection.id}
|
||||
onTitleUpdate={(newTitle) => {
|
||||
// 更新合集标题
|
||||
if (onUpdate) {
|
||||
onUpdate(collection.id, { collection_title: newTitle })
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
fontSize: '15px',
|
||||
fontWeight: 600,
|
||||
lineHeight: '1.4',
|
||||
color: '#ffffff'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '10px',
|
||||
|
||||
226
frontend/src/components/EditableCollectionTitle.tsx
Normal file
226
frontend/src/components/EditableCollectionTitle.tsx
Normal file
@@ -0,0 +1,226 @@
|
||||
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 EditableCollectionTitleProps {
|
||||
title: string
|
||||
collectionId: string
|
||||
onTitleUpdate?: (newTitle: string) => void
|
||||
maxLength?: number
|
||||
style?: React.CSSProperties
|
||||
className?: string
|
||||
}
|
||||
|
||||
const EditableCollectionTitle: React.FC<EditableCollectionTitleProps> = ({
|
||||
title,
|
||||
collectionId,
|
||||
onTitleUpdate,
|
||||
maxLength = 50,
|
||||
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)
|
||||
|
||||
useEffect(() => {
|
||||
setEditValue(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.updateCollectionTitle(collectionId, 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('开始生成合集标题,collectionId:', collectionId)
|
||||
setGenerating(true)
|
||||
try {
|
||||
const result = await projectApi.generateCollectionTitle(collectionId)
|
||||
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 style={{
|
||||
textAlign: 'right',
|
||||
marginTop: '8px',
|
||||
fontSize: '12px',
|
||||
color: '#999'
|
||||
}}>
|
||||
{editValue.length}/{maxLength}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center'
|
||||
}}>
|
||||
<Button
|
||||
onClick={handleCancel}
|
||||
disabled={loading || generating}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
|
||||
<Space>
|
||||
<Button
|
||||
icon={<MagicWandIcon />}
|
||||
loading={generating}
|
||||
onClick={handleGenerateTitle}
|
||||
disabled={loading}
|
||||
>
|
||||
AI生成标题
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<CheckOutlined />}
|
||||
loading={loading}
|
||||
onClick={handleSave}
|
||||
disabled={generating}
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
padding: '4px 0',
|
||||
...style
|
||||
}}
|
||||
className={className}
|
||||
onClick={handleStartEdit}
|
||||
title="点击编辑合集标题"
|
||||
>
|
||||
<span style={{
|
||||
wordBreak: 'break-word',
|
||||
lineHeight: '1.5',
|
||||
fontSize: '14px',
|
||||
minHeight: '20px',
|
||||
display: 'inline'
|
||||
}}>
|
||||
{title}
|
||||
<EditOutlined
|
||||
style={{
|
||||
color: '#1890ff',
|
||||
fontSize: '12px',
|
||||
opacity: 0.7,
|
||||
transition: 'opacity 0.2s',
|
||||
marginLeft: '6px',
|
||||
display: 'inline'
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default EditableCollectionTitle
|
||||
@@ -381,6 +381,9 @@ const ProjectDetailPage: React.FC = () => {
|
||||
collection={collection}
|
||||
clips={currentProject.clips || []}
|
||||
onView={handleViewCollection}
|
||||
onUpdate={(collectionId, updates) =>
|
||||
updateCollection(currentProject.id, collectionId, updates)
|
||||
}
|
||||
onGenerateVideo={async (collectionId) => {
|
||||
const collection = currentProject.collections?.find(c => c.id === collectionId)
|
||||
if (collection) {
|
||||
|
||||
@@ -328,6 +328,16 @@ export const projectApi = {
|
||||
return api.delete(`/collections/${collectionId}`)
|
||||
},
|
||||
|
||||
// 生成合集标题
|
||||
generateCollectionTitle: (collectionId: string): Promise<{collection_id: string, generated_title: string, success: boolean}> => {
|
||||
return api.post(`/collections/${collectionId}/generate-title`)
|
||||
},
|
||||
|
||||
// 更新合集标题
|
||||
updateCollectionTitle: (collectionId: string, title: string): Promise<{collection_id: string, title: string, success: boolean}> => {
|
||||
return api.put(`/collections/${collectionId}/title`, { title })
|
||||
},
|
||||
|
||||
// 下载切片视频
|
||||
downloadClip: (_projectId: string, clipId: string): Promise<Blob> => {
|
||||
return api.get(`/files/projects/${_projectId}/clips/${clipId}`, {
|
||||
|
||||
Reference in New Issue
Block a user