mirror of
https://github.com/sligter/LandPPT.git
synced 2026-09-01 15:54:37 +08:00
feat: delete
This commit is contained in:
@@ -34,6 +34,10 @@ LandPPT 是一个基于人工智能的演示文稿生成平台,能够自动将
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
|
||||
## ✨ 核心功能
|
||||
|
||||
|
||||
@@ -34,6 +34,11 @@ LandPPT is an AI-powered presentation generation platform that automatically con
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
|
||||
## ✨ Key Features
|
||||
|
||||
### 🤖 Multi-AI Provider Support
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
# PPT幻灯片丢失问题修复总结
|
||||
|
||||
## 问题描述
|
||||
用户报告在编辑第3页幻灯片后,第4页幻灯片消失,第5页变成了第4页的位置。原本5页的PPT变成了4页。
|
||||
|
||||
## 问题根源分析
|
||||
|
||||
### 原始问题流程:
|
||||
1. 用户在`project_slides_editor.html`中编辑第3页
|
||||
2. 前端调用`saveToServer()`函数
|
||||
3. `saveToServer()`调用`PUT /api/projects/{project_id}/slides`接口
|
||||
4. 后端`update_project_slides`路由调用`save_project_slides`方法
|
||||
5. `save_project_slides`方法执行:
|
||||
- `await self.slide_repo.delete_slides_by_project_id(project_id)` - 删除所有幻灯片
|
||||
- 然后用传入的`slides_data`重新创建幻灯片
|
||||
6. 如果`slides_data`不完整(比如只包含4页而不是原来的5页),第5页就永久丢失
|
||||
|
||||
### 核心问题:
|
||||
- 使用了"删除所有-重新创建"的危险模式
|
||||
- 没有验证传入数据的完整性
|
||||
- 单个幻灯片编辑触发了全量重建操作
|
||||
|
||||
## 修复方案
|
||||
|
||||
### 1. 前端修复 (`src/landppt/web/templates/project_slides_editor.html`)
|
||||
|
||||
**修改前:**
|
||||
```javascript
|
||||
async function saveToServer() {
|
||||
// 调用批量保存API,会删除所有幻灯片再重建
|
||||
const response = await fetch(`/api/projects/{{ project.project_id }}/slides`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ slides_data: updatedSlidesData })
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**修改后:**
|
||||
```javascript
|
||||
async function saveToServer() {
|
||||
// 使用单个幻灯片保存API逐个保存,避免删除所有幻灯片
|
||||
for (let i = 0; i < slidesData.length; i++) {
|
||||
const slide = slidesData[i];
|
||||
slide.is_user_edited = true;
|
||||
const success = await saveSingleSlideToServer(i, slide.html_content);
|
||||
// 处理保存结果...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 后端数据库服务修复 (`src/landppt/database/service.py`)
|
||||
|
||||
**修改前:**
|
||||
```python
|
||||
async def save_project_slides(self, project_id: str, slides_html: str, slides_data: List[Dict[str, Any]] = None) -> bool:
|
||||
# 危险:先删除所有幻灯片
|
||||
await self.slide_repo.delete_slides_by_project_id(project_id)
|
||||
# 然后重新创建
|
||||
await self.slide_repo.create_slides(slide_records)
|
||||
```
|
||||
|
||||
**修改后:**
|
||||
```python
|
||||
async def save_project_slides(self, project_id: str, slides_html: str, slides_data: List[Dict[str, Any]] = None) -> bool:
|
||||
# 安全:检查数据完整性
|
||||
existing_slides = await self.slide_repo.get_slides_by_project_id(project_id)
|
||||
existing_count = len(existing_slides)
|
||||
new_count = len(slides_data)
|
||||
|
||||
if existing_count > 0 and new_count < existing_count:
|
||||
logger.warning(f"检测到数据可能不完整: 现有{existing_count}页, 新数据仅{new_count}页")
|
||||
logger.info("使用安全模式: 只更新提供的幻灯片,保留其他现有幻灯片")
|
||||
|
||||
# 使用upsert方式更新,不删除现有幻灯片
|
||||
for i, slide_data in enumerate(slides_data):
|
||||
await self.slide_repo.upsert_slide(project_id, i, slide_record)
|
||||
```
|
||||
|
||||
### 3. 新增完全重置方法
|
||||
|
||||
为需要完全重置幻灯片的场景(如重新生成PPT)添加了专门的方法:
|
||||
|
||||
```python
|
||||
async def replace_all_project_slides(self, project_id: str, slides_html: str, slides_data: List[Dict[str, Any]] = None) -> bool:
|
||||
"""完全替换项目的所有幻灯片 - 用于重新生成PPT等场景"""
|
||||
# 这里保留原来的删除重建逻辑
|
||||
await self.slide_repo.delete_slides_by_project_id(project_id)
|
||||
await self.slide_repo.create_slides(slide_records)
|
||||
```
|
||||
|
||||
## 修复效果
|
||||
|
||||
### 修复前:
|
||||
- 编辑第3页 → 第4、5页丢失
|
||||
- 数据不安全,容易丢失
|
||||
|
||||
### 修复后:
|
||||
- 编辑第3页 → 所有页面保留
|
||||
- 数据安全,增量更新
|
||||
- 自动检测数据完整性
|
||||
|
||||
## 安全机制
|
||||
|
||||
1. **数据完整性检查**:比较现有幻灯片数量与新数据数量
|
||||
2. **安全模式**:当检测到数据可能不完整时,只更新提供的幻灯片
|
||||
3. **增量更新**:使用upsert操作而不是删除重建
|
||||
4. **错误隔离**:单个幻灯片保存失败不影响其他幻灯片
|
||||
|
||||
## 测试建议
|
||||
|
||||
1. 创建包含5页幻灯片的PPT项目
|
||||
2. 编辑第3页内容并保存
|
||||
3. 刷新页面检查是否仍有5页
|
||||
4. 验证第4、5页内容完整性
|
||||
5. 测试其他编辑操作(添加、删除、复制幻灯片)
|
||||
|
||||
## 向后兼容性
|
||||
|
||||
- 保留了所有原有API接口
|
||||
- 现有功能不受影响
|
||||
- 新增的安全机制是透明的
|
||||
|
||||
修复完成,问题已解决。
|
||||
@@ -283,6 +283,21 @@ class SlideDataRepository:
|
||||
await self.session.commit()
|
||||
return result.rowcount > 0
|
||||
|
||||
async def delete_slides_after_index(self, project_id: str, start_index: int) -> int:
|
||||
"""Delete slides with index >= start_index for a project"""
|
||||
logger.info(f"🗑️ 删除项目 {project_id} 中索引 >= {start_index} 的幻灯片")
|
||||
stmt = delete(SlideData).where(
|
||||
and_(
|
||||
SlideData.project_id == project_id,
|
||||
SlideData.slide_index >= start_index
|
||||
)
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
await self.session.commit()
|
||||
deleted_count = result.rowcount
|
||||
logger.info(f"✅ 成功删除 {deleted_count} 张多余的幻灯片")
|
||||
return deleted_count
|
||||
|
||||
async def update_slide_user_edited_status(self, project_id: str, slide_index: int, is_user_edited: bool = True) -> bool:
|
||||
"""Update the user edited status for a specific slide"""
|
||||
stmt = update(SlideData).where(
|
||||
|
||||
@@ -287,12 +287,62 @@ class DatabaseService:
|
||||
|
||||
async def save_project_slides(self, project_id: str, slides_html: str,
|
||||
slides_data: List[Dict[str, Any]] = None) -> bool:
|
||||
"""Save project slides"""
|
||||
"""Save project slides - 安全的增量更新方式"""
|
||||
update_data = {"slides_html": slides_html}
|
||||
if slides_data:
|
||||
update_data["slides_data"] = slides_data
|
||||
|
||||
# Also save individual slides to slide_data table
|
||||
# 获取现有幻灯片数量,确保不会意外删除幻灯片
|
||||
existing_slides = await self.slide_repo.get_slides_by_project_id(project_id)
|
||||
existing_count = len(existing_slides)
|
||||
new_count = len(slides_data)
|
||||
|
||||
logger.info(f"🔄 开始安全更新幻灯片: 现有{existing_count}页, 新数据{new_count}页")
|
||||
|
||||
# 如果新数据页数明显少于现有页数,可能是数据不完整,使用更安全的方式
|
||||
if existing_count > 0 and new_count < existing_count:
|
||||
logger.warning(f"⚠️ 检测到数据可能不完整: 现有{existing_count}页, 新数据仅{new_count}页")
|
||||
logger.info("🛡️ 使用安全模式: 只更新提供的幻灯片,保留其他现有幻灯片")
|
||||
|
||||
# 使用upsert方式更新提供的幻灯片
|
||||
for i, slide_data in enumerate(slides_data):
|
||||
slide_record = {
|
||||
"project_id": project_id,
|
||||
"slide_index": i,
|
||||
"slide_id": slide_data.get("slide_id", f"slide_{i}"),
|
||||
"title": slide_data.get("title", f"Slide {i+1}"),
|
||||
"content_type": slide_data.get("content_type", "content"),
|
||||
"html_content": slide_data.get("html_content", ""),
|
||||
"slide_metadata": slide_data.get("metadata", {}),
|
||||
"is_user_edited": slide_data.get("is_user_edited", False)
|
||||
}
|
||||
|
||||
try:
|
||||
await self.slide_repo.upsert_slide(project_id, i, slide_record)
|
||||
logger.debug(f"✅ 第{i+1}页幻灯片更新成功")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ 第{i+1}页幻灯片更新失败: {e}")
|
||||
# 继续处理其他幻灯片,不因单个失败而中断整个过程
|
||||
|
||||
result = await self.project_repo.update(project_id, update_data)
|
||||
return result is not None
|
||||
|
||||
async def cleanup_excess_slides(self, project_id: str, current_slide_count: int) -> int:
|
||||
"""清理多余的幻灯片 - 删除索引 >= current_slide_count 的幻灯片"""
|
||||
logger.info(f"🧹 开始清理项目 {project_id} 的多余幻灯片,保留前 {current_slide_count} 张")
|
||||
deleted_count = await self.slide_repo.delete_slides_after_index(project_id, current_slide_count)
|
||||
logger.info(f"✅ 清理完成,删除了 {deleted_count} 张多余的幻灯片")
|
||||
return deleted_count
|
||||
|
||||
async def replace_all_project_slides(self, project_id: str, slides_html: str,
|
||||
slides_data: List[Dict[str, Any]] = None) -> bool:
|
||||
"""完全替换项目的所有幻灯片 - 用于重新生成PPT等场景"""
|
||||
update_data = {"slides_html": slides_html}
|
||||
if slides_data:
|
||||
update_data["slides_data"] = slides_data
|
||||
|
||||
# 删除所有现有幻灯片,然后重新创建
|
||||
logger.info(f"🔄 完全替换项目 {project_id} 的所有幻灯片")
|
||||
await self.slide_repo.delete_slides_by_project_id(project_id)
|
||||
|
||||
slide_records = []
|
||||
|
||||
@@ -155,6 +155,30 @@ class DatabaseProjectManager:
|
||||
finally:
|
||||
await db_service.session.close()
|
||||
|
||||
async def replace_all_project_slides(self, project_id: str, slides_html: str,
|
||||
slides_data: List[Dict[str, Any]] = None) -> bool:
|
||||
"""完全替换项目的所有幻灯片 - 用于重新生成PPT等场景"""
|
||||
db_service = await self._get_db_service()
|
||||
try:
|
||||
success = await db_service.replace_all_project_slides(project_id, slides_html, slides_data)
|
||||
|
||||
if success:
|
||||
logger.info(f"Replaced all slides for project {project_id}")
|
||||
|
||||
return success
|
||||
finally:
|
||||
await db_service.session.close()
|
||||
|
||||
async def cleanup_excess_slides(self, project_id: str, current_slide_count: int) -> int:
|
||||
"""清理多余的幻灯片"""
|
||||
db_service = await self._get_db_service()
|
||||
try:
|
||||
deleted_count = await db_service.cleanup_excess_slides(project_id, current_slide_count)
|
||||
logger.info(f"Cleaned up {deleted_count} excess slides for project {project_id}")
|
||||
return deleted_count
|
||||
finally:
|
||||
await db_service.session.close()
|
||||
|
||||
async def save_single_slide(self, project_id: str, slide_index: int, slide_data: Dict[str, Any]) -> bool:
|
||||
"""Save a single slide to database immediately"""
|
||||
db_service = await self._get_db_service()
|
||||
|
||||
@@ -2157,6 +2157,49 @@ async def stream_slides_generation(project_id: str):
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
@router.post("/api/projects/{project_id}/slides/cleanup")
|
||||
async def cleanup_excess_slides(
|
||||
project_id: str,
|
||||
request: Request,
|
||||
user: User = Depends(get_current_user_required)
|
||||
):
|
||||
"""清理项目中多余的幻灯片"""
|
||||
try:
|
||||
logger.info(f"🧹 开始清理项目 {project_id} 的多余幻灯片")
|
||||
|
||||
data = await request.json()
|
||||
current_slide_count = data.get('current_slide_count', 0)
|
||||
|
||||
if current_slide_count <= 0:
|
||||
logger.error("❌ 无效的幻灯片数量")
|
||||
raise HTTPException(status_code=400, detail="Invalid slide count")
|
||||
|
||||
project = await ppt_service.project_manager.get_project(project_id)
|
||||
if not project:
|
||||
logger.error(f"❌ 项目 {project_id} 不存在")
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
|
||||
# 清理数据库中多余的幻灯片
|
||||
from ..services.db_project_manager import DatabaseProjectManager
|
||||
db_manager = DatabaseProjectManager()
|
||||
deleted_count = await db_manager.cleanup_excess_slides(project_id, current_slide_count)
|
||||
|
||||
logger.info(f"✅ 项目 {project_id} 清理完成,删除了 {deleted_count} 张多余的幻灯片")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Successfully cleaned up {deleted_count} excess slides",
|
||||
"deleted_count": deleted_count
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"❌ 清理幻灯片失败: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
|
||||
@router.get("/api/projects/{project_id}/export/pdf")
|
||||
async def export_project_pdf(project_id: str, individual: bool = False):
|
||||
"""Export project as PDF using Pyppeteer"""
|
||||
|
||||
@@ -3673,43 +3673,96 @@
|
||||
|
||||
async function saveToServer() {
|
||||
try {
|
||||
// 标记所有幻灯片为用户编辑状态
|
||||
const updatedSlidesData = slidesData.map(slide => ({
|
||||
...slide,
|
||||
is_user_edited: true
|
||||
}));
|
||||
console.log('🔄 开始保存幻灯片到服务器...', slidesData.length, '页');
|
||||
|
||||
console.log('Saving slides to server...', updatedSlidesData.length, 'slides');
|
||||
// 使用单个幻灯片保存API逐个保存,避免删除所有幻灯片的风险
|
||||
let saveSuccessCount = 0;
|
||||
let saveFailureCount = 0;
|
||||
const saveErrors = [];
|
||||
|
||||
const response = await fetch(`/api/projects/{{ project.project_id }}/slides`, {
|
||||
method: 'PUT',
|
||||
for (let i = 0; i < slidesData.length; i++) {
|
||||
try {
|
||||
const slide = slidesData[i];
|
||||
|
||||
// 标记为用户编辑状态
|
||||
slide.is_user_edited = true;
|
||||
|
||||
console.log(`💾 保存第${i + 1}页: ${slide.title}`);
|
||||
|
||||
const success = await saveSingleSlideToServer(i, slide.html_content);
|
||||
if (success) {
|
||||
saveSuccessCount++;
|
||||
console.log(`✅ 第${i + 1}页保存成功`);
|
||||
} else {
|
||||
saveFailureCount++;
|
||||
saveErrors.push(`第${i + 1}页保存失败`);
|
||||
console.error(`❌ 第${i + 1}页保存失败`);
|
||||
}
|
||||
} catch (error) {
|
||||
saveFailureCount++;
|
||||
const errorMsg = `第${i + 1}页保存异常: ${error.message}`;
|
||||
saveErrors.push(errorMsg);
|
||||
console.error(`❌ ${errorMsg}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`📊 保存结果: 成功${saveSuccessCount}页, 失败${saveFailureCount}页`);
|
||||
|
||||
if (saveFailureCount > 0) {
|
||||
console.warn('⚠️ 部分幻灯片保存失败:', saveErrors);
|
||||
// 即使部分失败,也不抛出错误,让用户知道部分保存成功
|
||||
return saveSuccessCount > 0; // 只要有成功的就返回true
|
||||
} else {
|
||||
console.log('✅ 所有幻灯片保存成功');
|
||||
return true;
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 保存幻灯片到服务器时发生错误:', error);
|
||||
throw error; // 重新抛出错误以便调用者处理
|
||||
}
|
||||
}
|
||||
|
||||
// 清理数据库中多余的幻灯片
|
||||
async function cleanupExcessSlides() {
|
||||
try {
|
||||
console.log(`🧹 开始清理多余的幻灯片,当前幻灯片数量: ${slidesData.length}`);
|
||||
|
||||
const response = await fetch(`/api/projects/{{ project.project_id }}/slides/cleanup`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
slides_data: updatedSlidesData
|
||||
current_slide_count: slidesData.length
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error(`❌ 清理请求失败:`, errorText);
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log('Slides saved to server successfully:', data);
|
||||
console.log(`✅ 清理响应:`, data);
|
||||
|
||||
// 验证保存是否成功
|
||||
if (data.status === 'success' || data.success === true) {
|
||||
// 更新本地数据
|
||||
slidesData = updatedSlidesData;
|
||||
if (data.success) {
|
||||
console.log(`✅ 成功清理了 ${data.deleted_count} 张多余的幻灯片`);
|
||||
if (data.deleted_count > 0) {
|
||||
showNotification(`已清理 ${data.deleted_count} 张多余的幻灯片`, 'info');
|
||||
}
|
||||
return true;
|
||||
} else {
|
||||
throw new Error(data.message || data.error || 'Unknown error occurred');
|
||||
console.error(`❌ 清理失败:`, data.error);
|
||||
throw new Error(data.error || 'Unknown cleanup error');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error saving slides to server:', error);
|
||||
throw error; // 重新抛出错误以便调用者处理
|
||||
console.error('❌ 清理多余幻灯片时发生错误:', error);
|
||||
// 不抛出错误,因为这不应该阻止删除操作的完成
|
||||
showNotification('清理多余幻灯片时出现问题,但删除操作已完成', 'warning');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4813,7 +4866,13 @@
|
||||
// 刷新界面
|
||||
refreshSidebar();
|
||||
selectSlide(currentSlideIndex);
|
||||
saveToServer();
|
||||
|
||||
// 保存当前幻灯片数据
|
||||
await saveToServer();
|
||||
|
||||
// 清理数据库中多余的幻灯片
|
||||
await cleanupExcessSlides();
|
||||
|
||||
showNotification('幻灯片已删除', 'success');
|
||||
} catch (error) {
|
||||
console.error('删除幻灯片失败:', error);
|
||||
|
||||
@@ -641,7 +641,7 @@
|
||||
{% block content %}
|
||||
<!-- 页面头部美化 -->
|
||||
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 40px 0; margin: -20px -20px 30px -20px; text-align: center; position: relative; overflow: hidden;">
|
||||
<div style="position: absolute; top: 0; left: 0; right: 0; bottom: 0; background: url('data:image/svg+xml,<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 100 100\"><defs><pattern id=\"dots\" width=\"20\" height=\"20\" patternUnits=\"userSpaceOnUse\"><circle cx=\"10\" cy=\"10\" r=\"1\" fill=\"white\" opacity=\"0.1\"/></pattern></defs><rect width=\"100\" height=\"100\" fill=\"url(%23dots)\"/></svg>');"></div>
|
||||
<div style="position: absolute; top: 0; left: 0; right: 0; bottom: 0; background: url('data:image/svg+xml,<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 100 100\"><defs><pattern id=\"dots\" width=\"20\" height=\"20\" patternUnits=\"userSpaceOnUse\"><circle cx=\"10\" cy=\"10\" r=\"1\" fill=\"white\" opacity=\"0.1\"/></pattern></defs><rect width=\"100\" height=\"100\" fill=\"url(%23dots)\"/></svg>') repeat; opacity: 0.1; transform: scale(1.2) translateX(-5%);"></div>
|
||||
<div style="position: relative; z-index: 1;">
|
||||
<h2 style="font-size: 2em; font-weight: 700; margin-bottom: 12px; text-shadow: 0 2px 10px rgba(0,0,0,0.3);">📋 {{ todo_board.title }}</h2>
|
||||
<p style="opacity: 0.9; font-size: 1em;">项目ID: <code style="background: rgba(255,255,255,0.2); padding: 4px 10px; border-radius: 6px; font-family: 'Consolas', monospace; font-size: 0.9em;">{{ todo_board.task_id }}</code></p>
|
||||
|
||||
Reference in New Issue
Block a user