From 20fdad78466fde6ff2a8d238aa5aeb2fc018e528 Mon Sep 17 00:00:00 2001 From: sligter <1771322848@qq.com> Date: Sat, 13 Jun 2026 18:34:49 +0800 Subject: [PATCH] Refactor code structure for improved readability and maintainability --- .env.example | 8 +- docker-compose-dev.yaml | 6 +- src/landppt/api/landppt_api.py | 61 ++ src/landppt/api/models.py | 3 + src/landppt/auth/auth_service.py | 5 +- src/landppt/auth/routes.py | 4 +- src/landppt/core/config.py | 6 +- src/landppt/database/service.py | 419 +++++++++++-- src/landppt/services/community_service.py | 2 +- src/landppt/services/db_project_manager.py | 41 +- src/landppt/web/admin_routes.py | 2 +- .../outline_generation_routes.py | 26 +- src/landppt/web/route_modules/slide_routes.py | 23 + .../slides_editor/projectSlidesEditor.css | 16 +- .../projectSlidesEditor.aiChat.js | 25 +- .../slides_editor/projectSlidesEditor.core.js | 37 +- .../projectSlidesEditor.slideCrud.js | 32 +- .../projectSlidesEditor.slideGeneration.js | 23 +- .../components/project/detail/content_1.html | 234 ++++--- .../project/detail/extra_css_1.html | 359 +++++++++++ .../components/project/detail/extra_js_1.html | 144 ++++- .../pages/project/project_dashboard.html | 578 +++++++++++++++--- .../pages/project/project_slides_editor.html | 6 +- .../pages/project/projects_list.html | 341 +++++++++-- tests/test_admin_bootstrap.py | 21 + tests/test_invite_registration.py | 13 + tests/test_project_workflow_regressions.py | 133 ++++ tmp/editor-thumbnail-fill-check.png | Bin 0 -> 173702 bytes ...project-dashboard-icon-buttons-desktop.png | Bin 0 -> 140614 bytes tmp/project-dashboard-ui-check.png | Bin 0 -> 118735 bytes tmp/project-detail-outline-ui-check.png | Bin 0 -> 214386 bytes tmp/project-list-icon-buttons-check.png | Bin 0 -> 62874 bytes tmp/project-list-icon-buttons-desktop.png | Bin 0 -> 138628 bytes tmp/project-list-ui-check.png | Bin 0 -> 125176 bytes uv.lock | 20 +- 35 files changed, 2253 insertions(+), 335 deletions(-) create mode 100644 tmp/editor-thumbnail-fill-check.png create mode 100644 tmp/project-dashboard-icon-buttons-desktop.png create mode 100644 tmp/project-dashboard-ui-check.png create mode 100644 tmp/project-detail-outline-ui-check.png create mode 100644 tmp/project-list-icon-buttons-check.png create mode 100644 tmp/project-list-icon-buttons-desktop.png create mode 100644 tmp/project-list-ui-check.png diff --git a/.env.example b/.env.example index 71bfcc9..ec66e09 100644 --- a/.env.example +++ b/.env.example @@ -186,10 +186,10 @@ LANDPPT_API_KEY_USER=admin # Formats: user:key or user=key (items without user bind to LANDPPT_API_KEY_USER) # LANDPPT_API_KEYS=admin:prod-key,robot:workflow-key LANDPPT_API_KEYS= -# Optional bootstrap admin (disabled by default; requires explicit username/password) -LANDPPT_BOOTSTRAP_ADMIN_ENABLED=false -LANDPPT_BOOTSTRAP_ADMIN_USERNAME= -LANDPPT_BOOTSTRAP_ADMIN_PASSWORD= +# Local bootstrap admin for empty databases; override or disable for production. +LANDPPT_BOOTSTRAP_ADMIN_ENABLED=true +LANDPPT_BOOTSTRAP_ADMIN_USERNAME=admin +LANDPPT_BOOTSTRAP_ADMIN_PASSWORD=admin123 # API docs are enabled by default; set to false to disable /docs, /redoc, and /openapi.json LANDPPT_ENABLE_API_DOCS=true # Allow passing session_id via X-Session-Id header (disabled by default; prefer API keys for automation) diff --git a/docker-compose-dev.yaml b/docker-compose-dev.yaml index 32a0bde..577f4ec 100644 --- a/docker-compose-dev.yaml +++ b/docker-compose-dev.yaml @@ -33,9 +33,9 @@ services: - LANDPPT_API_KEY=${LANDPPT_API_KEY:-} - LANDPPT_API_KEY_USER=${LANDPPT_API_KEY_USER:-admin} - LANDPPT_API_KEYS=${LANDPPT_API_KEYS:-} - - LANDPPT_BOOTSTRAP_ADMIN_ENABLED=${LANDPPT_BOOTSTRAP_ADMIN_ENABLED:-false} - - LANDPPT_BOOTSTRAP_ADMIN_USERNAME=${LANDPPT_BOOTSTRAP_ADMIN_USERNAME:-} - - LANDPPT_BOOTSTRAP_ADMIN_PASSWORD=${LANDPPT_BOOTSTRAP_ADMIN_PASSWORD:-} + - LANDPPT_BOOTSTRAP_ADMIN_ENABLED=${LANDPPT_BOOTSTRAP_ADMIN_ENABLED:-true} + - LANDPPT_BOOTSTRAP_ADMIN_USERNAME=${LANDPPT_BOOTSTRAP_ADMIN_USERNAME:-admin} + - LANDPPT_BOOTSTRAP_ADMIN_PASSWORD=${LANDPPT_BOOTSTRAP_ADMIN_PASSWORD:-admin123} - LANDPPT_ENABLE_API_DOCS=${LANDPPT_ENABLE_API_DOCS:-true} - LANDPPT_ALLOW_HEADER_SESSION_AUTH=${LANDPPT_ALLOW_HEADER_SESSION_AUTH:-false} - DATABASE_URL=postgresql://${POSTGRES_USER:-landppt}:${POSTGRES_PASSWORD:-landppt}@postgres:5432/${POSTGRES_DB:-landppt} diff --git a/src/landppt/api/landppt_api.py b/src/landppt/api/landppt_api.py index 5857e1a..62f343b 100644 --- a/src/landppt/api/landppt_api.py +++ b/src/landppt/api/landppt_api.py @@ -15,6 +15,7 @@ import re from .models import ( PPTScenario, PPTGenerationRequest, PPTGenerationResponse, PPTOutline, PPTProject, TodoBoard, ProjectListResponse, + ProjectRenameRequest, FileUploadResponse, SlideContent, FileOutlineGenerationRequest, FileOutlineGenerationResponse, TemplateSelectionRequest, TemplateSelectionResponse ) @@ -444,6 +445,66 @@ async def get_project( except Exception as e: raise HTTPException(status_code=500, detail=f"Error getting project: {str(e)}") +@router.post("/projects/{project_id}/duplicate") +async def duplicate_project( + project_id: str, + user: User = Depends(get_current_user_required) +): + """Duplicate a project for the current user.""" + try: + user_ppt_service = get_ppt_service_for_user(user.id) + project = await user_ppt_service.project_manager.duplicate_project(project_id, user_id=user.id) + if not project: + raise HTTPException(status_code=404, detail="Project not found") + return { + "status": "success", + "message": "Project duplicated successfully", + "project_id": project.project_id, + "project": project, + } + + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=f"Error duplicating project: {str(e)}") + +@router.patch("/projects/{project_id}/rename") +async def rename_project( + project_id: str, + request: ProjectRenameRequest, + user: User = Depends(get_current_user_required) +): + """Rename a project owned by the current user.""" + title = request.title.strip() + if not title: + raise HTTPException(status_code=422, detail="Project title is required") + + try: + user_ppt_service = get_ppt_service_for_user(user.id) + project = await user_ppt_service.project_manager.get_project(project_id, user_id=user.id) + if not project: + raise HTTPException(status_code=404, detail="Project not found") + + success = await user_ppt_service.project_manager.update_project_data( + project_id, + {"title": title}, + user_id=user.id, + ) + if not success: + raise HTTPException(status_code=500, detail="Failed to rename project") + + return { + "status": "success", + "message": "Project renamed successfully", + "project_id": project_id, + "title": title, + } + + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=f"Error renaming project: {str(e)}") + @router.get("/projects/{project_id}/todo", response_model=TodoBoard) async def get_project_todo_board( project_id: str, diff --git a/src/landppt/api/models.py b/src/landppt/api/models.py index 0c7252c..b9b0137 100644 --- a/src/landppt/api/models.py +++ b/src/landppt/api/models.py @@ -162,6 +162,9 @@ class ProjectListResponse(BaseModel): page: int page_size: int +class ProjectRenameRequest(BaseModel): + title: str = Field(..., min_length=1, max_length=255) + # Enhanced Slide Models class SlideContent(BaseModel): type: Literal["title", "content", "image", "chart", "list", "thankyou", "agenda", "section", "transition", "conclusion"] diff --git a/src/landppt/auth/auth_service.py b/src/landppt/auth/auth_service.py index 357f5bb..08590ed 100644 --- a/src/landppt/auth/auth_service.py +++ b/src/landppt/auth/auth_service.py @@ -628,7 +628,7 @@ def get_auth_service() -> AuthService: def init_default_admin(db: Session) -> None: - """Optionally bootstrap an admin user when explicitly configured.""" + """Bootstrap an initial admin user when enabled and the user table is empty.""" if not app_config.bootstrap_admin_enabled: return @@ -641,8 +641,7 @@ def init_default_admin(db: Session) -> None: if not bootstrap_username or not bootstrap_password: logger.warning( - "Skipping admin bootstrap because LANDPPT_BOOTSTRAP_ADMIN_USERNAME or " - "LANDPPT_BOOTSTRAP_ADMIN_PASSWORD is missing." + "Skipping admin bootstrap because the configured admin username or password is missing." ) return diff --git a/src/landppt/auth/routes.py b/src/landppt/auth/routes.py index 26c7f64..f063e35 100644 --- a/src/landppt/auth/routes.py +++ b/src/landppt/auth/routes.py @@ -124,10 +124,10 @@ async def _registration_template_ctx() -> dict: try: settings = await community_service.get_settings() - invite_required = bool(settings.get("invite_code_required_for_registration", True)) + invite_required = bool(settings.get("invite_code_required_for_registration", False)) except Exception as exc: logger.warning("Failed to load registration template settings: %s", exc) - invite_required = True + invite_required = False return { "invite_code_required_for_registration": invite_required, diff --git a/src/landppt/core/config.py b/src/landppt/core/config.py index 060dfe4..be45791 100644 --- a/src/landppt/core/config.py +++ b/src/landppt/core/config.py @@ -556,9 +556,9 @@ class AppConfig(BaseSettings): secret_key: str = Field(default="your-secret-key-here", env="SECRET_KEY") access_token_expire_minutes: int = Field(default=20160, env="ACCESS_TOKEN_EXPIRE_MINUTES") # 2 weeks enable_api_docs: bool = Field(default=True, env="LANDPPT_ENABLE_API_DOCS") - bootstrap_admin_enabled: bool = Field(default=False, env="LANDPPT_BOOTSTRAP_ADMIN_ENABLED") - bootstrap_admin_username: Optional[str] = Field(default=None, env="LANDPPT_BOOTSTRAP_ADMIN_USERNAME") - bootstrap_admin_password: Optional[str] = Field(default=None, env="LANDPPT_BOOTSTRAP_ADMIN_PASSWORD") + bootstrap_admin_enabled: bool = Field(default=True, env="LANDPPT_BOOTSTRAP_ADMIN_ENABLED") + bootstrap_admin_username: Optional[str] = Field(default="admin", env="LANDPPT_BOOTSTRAP_ADMIN_USERNAME") + bootstrap_admin_password: Optional[str] = Field(default="admin123", env="LANDPPT_BOOTSTRAP_ADMIN_PASSWORD") # Machine-to-machine API authentication (for n8n / automation) # Single key mode: LANDPPT_API_KEY + LANDPPT_API_KEY_USER diff --git a/src/landppt/database/service.py b/src/landppt/database/service.py index c7e6101..0adca21 100644 --- a/src/landppt/database/service.py +++ b/src/landppt/database/service.py @@ -5,8 +5,10 @@ Database service layer for converting between database models and API models import time import uuid import logging +import copy from typing import List, Optional, Dict, Any, Tuple from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select logger = logging.getLogger(__name__) @@ -14,7 +16,14 @@ from .repositories import ( ProjectRepository, TodoBoardRepository, TodoStageRepository, ProjectVersionRepository, SlideDataRepository, PPTTemplateRepository, GlobalMasterTemplateRepository ) -from .models import Project as DBProject, TodoBoard as DBTodoBoard, TodoStage as DBTodoStage, PPTTemplate as DBPPTTemplate, GlobalMasterTemplate as DBGlobalMasterTemplate +from .models import ( + Project as DBProject, + TodoBoard as DBTodoBoard, + TodoStage as DBTodoStage, + SlideData as DBSlideData, + PPTTemplate as DBPPTTemplate, + GlobalMasterTemplate as DBGlobalMasterTemplate, +) from ..api.models import ( PPTProject, TodoBoard, TodoStage, ProjectListResponse, PPTGenerationRequest @@ -53,6 +62,80 @@ class DatabaseService: return 0 slides = outline.get("slides") return len(slides) if isinstance(slides, list) else 0 + + @staticmethod + def _clone_json(value: Any) -> Any: + return copy.deepcopy(value) if value is not None else None + + @staticmethod + def _get_slide_content_type(slide_data: Dict[str, Any]) -> str: + return ( + slide_data.get("content_type") + or slide_data.get("slide_type") + or slide_data.get("type") + or "content" + ) + + @classmethod + def _get_slide_metadata(cls, slide_data: Dict[str, Any]) -> Dict[str, Any]: + metadata = slide_data.get("metadata") + if not isinstance(metadata, dict): + metadata = slide_data.get("slide_metadata") + metadata = copy.deepcopy(metadata) if isinstance(metadata, dict) else {} + + for key in ( + "slide_type", + "type", + "description", + "subtitle", + "content", + "content_points", + "page_number", + ): + if key in slide_data and slide_data.get(key) is not None: + metadata[key] = copy.deepcopy(slide_data[key]) + + return metadata + + @classmethod + def _normalize_slide_json_entry( + cls, + slide_index: int, + slide_data: Dict[str, Any], + existing: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + entry = dict(existing or {}) + incoming = dict(slide_data or {}) + for key, value in incoming.items(): + if value is not None: + entry[key] = copy.deepcopy(value) + + entry["page_number"] = slide_index + 1 + entry["title"] = entry.get("title") or f"Slide {slide_index + 1}" + content_type = cls._get_slide_content_type(entry) + entry["content_type"] = content_type + entry.setdefault("slide_type", content_type) + entry["metadata"] = cls._get_slide_metadata(entry) + return entry + + @classmethod + def _slide_record_from_payload( + cls, + project_id: str, + slide_index: int, + slide_data: Dict[str, Any], + ) -> Dict[str, Any]: + slide_data = dict(slide_data or {}) + return { + "project_id": project_id, + "slide_index": slide_index, + "slide_id": slide_data.get("slide_id", f"slide_{slide_index}"), + "title": slide_data.get("title", f"Slide {slide_index + 1}"), + "content_type": cls._get_slide_content_type(slide_data), + "html_content": slide_data.get("html_content", ""), + "slide_metadata": cls._get_slide_metadata(slide_data), + "is_user_edited": bool(slide_data.get("is_user_edited", False)), + } def _convert_db_project_to_api(self, db_project: DBProject) -> PPTProject: """Convert database project to API model""" @@ -407,6 +490,89 @@ class DatabaseService: logger.error(f"Failed to update TODO board progress for project {project_id}") return success + + async def _sync_outline_to_existing_slides( + self, + project_id: str, + outline: Dict[str, Any], + user_id: Optional[int] = None, + ) -> None: + """Copy outline title/type metadata into existing slide storage.""" + if not isinstance(outline, dict): + return + + outline_slides = outline.get("slides") + if not isinstance(outline_slides, list): + return + + project = await self.project_repo.get_by_id(project_id, user_id=user_id) + if not project: + return + + stored_slides_data = list(project.slides_data or []) + update_project_json = bool(stored_slides_data) + slide_rows = { + slide.slide_index: slide + for slide in await self.slide_repo.get_slides_by_project_id(project_id) + } + + for index, outline_slide in enumerate(outline_slides): + if not isinstance(outline_slide, dict): + continue + + if index < len(stored_slides_data): + stored_slides_data[index] = self._normalize_slide_json_entry( + index, + outline_slide, + existing=stored_slides_data[index], + ) + + slide_row = slide_rows.get(index) + if slide_row: + slide_row.title = outline_slide.get("title") or slide_row.title + slide_row.content_type = self._get_slide_content_type(outline_slide) + metadata = dict(slide_row.slide_metadata or {}) + metadata.update(self._get_slide_metadata(outline_slide)) + slide_row.slide_metadata = metadata + slide_row.updated_at = time.time() + + if update_project_json: + project.slides_data = stored_slides_data + project.updated_at = time.time() + + if update_project_json or slide_rows: + await self.session.commit() + + async def _sync_single_slide_to_project_json( + self, + project_id: str, + slide_index: int, + slide_data: Dict[str, Any], + user_id: Optional[int] = None, + ) -> None: + project = await self.project_repo.get_by_id(project_id, user_id=user_id) + if not project: + return + + slides_data = list(project.slides_data or []) + while len(slides_data) <= slide_index: + placeholder_index = len(slides_data) + slides_data.append( + { + "page_number": placeholder_index + 1, + "title": f"Slide {placeholder_index + 1}", + "html_content": "", + } + ) + + slides_data[slide_index] = self._normalize_slide_json_entry( + slide_index, + slide_data, + existing=slides_data[slide_index], + ) + project.slides_data = slides_data + project.updated_at = time.time() + await self.session.commit() async def save_project_outline(self, project_id: str, outline: Dict[str, Any]) -> bool: """Save project outline""" @@ -431,13 +597,18 @@ class DatabaseService: "updated_at": time.time() } - result = await self.project_repo.update(project_id, update_data) + result = await self.project_repo.update(project_id, update_data, user_id=effective_user_id) if result: logger.info(f"Successfully saved outline for project {project_id}") + await self._sync_outline_to_existing_slides( + project_id, + outline, + user_id=effective_user_id, + ) # 验证保存是否成功 - saved_project = await self.project_repo.get_by_id(project_id) + saved_project = await self.project_repo.get_by_id(project_id, user_id=effective_user_id) if saved_project and saved_project.outline: logger.info(f"Verified outline saved: {len(saved_project.outline.get('slides', []))} slides") return True @@ -477,17 +648,7 @@ class DatabaseService: # 准备幻灯片数据 slides_records = [] 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) - } - slides_records.append(slide_record) + slides_records.append(self._slide_record_from_payload(project_id, i, slide_data)) # 使用批量upsert方式更新幻灯片 try: @@ -545,16 +706,7 @@ class DatabaseService: slide_records = [] for i, slide_data in enumerate(slides_data): - slide_records.append({ - "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) - }) + slide_records.append(self._slide_record_from_payload(project_id, i, slide_data)) if slide_records: await self.slide_repo.create_slides(slide_records) @@ -593,16 +745,7 @@ class DatabaseService: raise ValueError("幻灯片数据不能为空") # Prepare slide record for database - slide_record = { - "project_id": project_id, - "slide_index": slide_index, - "slide_id": slide_data.get("slide_id", f"slide_{slide_index}"), - "title": slide_data.get("title", f"Slide {slide_index + 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) - } + slide_record = self._slide_record_from_payload(project_id, slide_index, slide_data) logger.debug(f"📊 准备保存的幻灯片记录: 标题='{slide_record['title']}', 跳过用户编辑={skip_if_user_edited}") @@ -610,6 +753,12 @@ class DatabaseService: result_slide = await self.slide_repo.upsert_slide(project_id, slide_index, slide_record, skip_if_user_edited=skip_if_user_edited) if result_slide: + await self._sync_single_slide_to_project_json( + project_id, + slide_index, + slide_data, + user_id=effective_user_id, + ) logger.debug(f"✅ 幻灯片保存成功: 项目ID={project_id}, 索引={slide_index}, 数据库ID={result_slide.id}") return True else: @@ -634,6 +783,208 @@ class DatabaseService: logger.error(f"❌ 保存单个幻灯片失败: 重试次数用尽, 项目ID={project_id}, 索引={slide_index}") return False + async def apply_slide_structure_operation( + self, + project_id: str, + operation: Dict[str, Any], + user_id: Optional[int] = None, + ) -> bool: + """Apply an outline structural operation to persisted slide order.""" + if not isinstance(operation, dict): + return True + + op_type = str(operation.get("type") or operation.get("operation") or "").strip().lower() + if op_type not in {"delete", "move", "insert"}: + return True + + effective_user_id = user_id + if effective_user_id == USER_SCOPE_ALL: + effective_user_id = None + if effective_user_id is None: + effective_user_id = current_user_id.get() + + project = await self.project_repo.get_by_id(project_id, user_id=effective_user_id) + if not project: + return False + + if op_type == "insert": + return True + + slide_rows = list(await self.slide_repo.get_slides_by_project_id(project_id)) + slides_json = list(project.slides_data or []) + + try: + from_index = int(operation.get("from_index", operation.get("slide_index", -1))) + except (TypeError, ValueError): + return False + + changed = False + + if op_type == "delete": + if 0 <= from_index < len(slide_rows): + removed = slide_rows.pop(from_index) + await self.session.delete(removed) + changed = True + + if 0 <= from_index < len(slides_json): + slides_json.pop(from_index) + changed = True + + elif op_type == "move": + try: + to_index = int(operation.get("to_index")) + except (TypeError, ValueError): + return False + + if from_index == to_index: + return True + + if 0 <= from_index < len(slide_rows): + moved_row = slide_rows.pop(from_index) + slide_rows.insert(max(0, min(to_index, len(slide_rows))), moved_row) + changed = True + + if 0 <= from_index < len(slides_json): + moved_json = slides_json.pop(from_index) + slides_json.insert(max(0, min(to_index, len(slides_json))), moved_json) + changed = True + + if not changed: + return True + + for index, slide in enumerate(slide_rows): + slide.slide_index = index + slide.updated_at = time.time() + + for index, slide_data in enumerate(slides_json): + if isinstance(slide_data, dict): + slide_data["page_number"] = index + 1 + + project.slides_data = slides_json + project.updated_at = time.time() + await self.session.commit() + return True + + async def duplicate_project( + self, + project_id: str, + user_id: Optional[int] = None, + title_suffix: str = " (Copy)", + ) -> Optional[PPTProject]: + """Duplicate a project for the same owner.""" + effective_user_id = user_id + if effective_user_id == USER_SCOPE_ALL: + effective_user_id = None + if effective_user_id is None: + effective_user_id = current_user_id.get() + + source = await self.project_repo.get_by_id(project_id, user_id=effective_user_id) + if not source: + return None + + new_project_id = str(uuid.uuid4()) + now = time.time() + + try: + duplicate = DBProject( + project_id=new_project_id, + user_id=source.user_id, + title=f"{source.title}{title_suffix}", + scenario=source.scenario, + topic=source.topic, + requirements=source.requirements, + status=source.status, + outline=self._clone_json(source.outline), + slides_html=source.slides_html, + slides_data=self._clone_json(source.slides_data), + confirmed_requirements=self._clone_json(source.confirmed_requirements), + project_metadata=self._clone_json(source.project_metadata), + version=source.version, + share_enabled=False, + share_token=None, + created_at=now, + updated_at=now, + ) + self.session.add(duplicate) + await self.session.flush() + + template_id_map: Dict[int, int] = {} + template_stmt = select(DBPPTTemplate).where(DBPPTTemplate.project_id == project_id) + template_result = await self.session.execute(template_stmt) + for template in template_result.scalars().all(): + copied_template = DBPPTTemplate( + project_id=new_project_id, + template_type=template.template_type, + template_name=template.template_name, + description=template.description, + html_template=template.html_template, + applicable_scenarios=self._clone_json(template.applicable_scenarios), + style_config=self._clone_json(template.style_config), + usage_count=template.usage_count, + created_at=now, + updated_at=now, + ) + self.session.add(copied_template) + await self.session.flush() + template_id_map[template.id] = copied_template.id + + if source.todo_board: + copied_board = DBTodoBoard( + project_id=new_project_id, + current_stage_index=source.todo_board.current_stage_index, + overall_progress=source.todo_board.overall_progress, + created_at=now, + updated_at=now, + ) + self.session.add(copied_board) + await self.session.flush() + + for stage in source.todo_board.stages: + self.session.add( + DBTodoStage( + todo_board_id=copied_board.id, + project_id=new_project_id, + stage_id=stage.stage_id, + stage_index=stage.stage_index, + title=stage.title, + description=stage.description, + status=stage.status, + progress=stage.progress, + result=self._clone_json(stage.result), + created_at=now, + updated_at=now, + ) + ) + + for slide in sorted(source.slides, key=lambda item: item.slide_index): + self.session.add( + DBSlideData( + project_id=new_project_id, + slide_index=slide.slide_index, + slide_id=f"{slide.slide_id}_copy_{uuid.uuid4().hex[:8]}", + title=slide.title, + content_type=slide.content_type, + html_content=slide.html_content, + slide_metadata=self._clone_json(slide.slide_metadata), + template_id=template_id_map.get(slide.template_id), + is_user_edited=slide.is_user_edited, + created_at=now, + updated_at=now, + ) + ) + + await self.session.commit() + duplicated_project = await self.project_repo.get_by_id( + new_project_id, + user_id=source.user_id, + ) + return self._convert_db_project_to_api(duplicated_project) if duplicated_project else None + + except Exception: + await self.session.rollback() + logger.exception("Failed to duplicate project %s", project_id) + return None + async def update_project(self, project_id: str, update_data: Dict[str, Any], user_id: Optional[int] = None) -> bool: """Update project data. If user_id is provided, enforces ownership.""" try: diff --git a/src/landppt/services/community_service.py b/src/landppt/services/community_service.py index 0f425a7..cb52dd5 100644 --- a/src/landppt/services/community_service.py +++ b/src/landppt/services/community_service.py @@ -54,7 +54,7 @@ class CommunityService: "daily_checkin_reward_fixed": {"type": "number", "default": 5}, "daily_checkin_reward_min": {"type": "number", "default": 2}, "daily_checkin_reward_max": {"type": "number", "default": 8}, - "invite_code_required_for_registration": {"type": "boolean", "default": True}, + "invite_code_required_for_registration": {"type": "boolean", "default": False}, "sponsor_page_enabled": {"type": "boolean", "default": False}, "site_notice_enabled": {"type": "boolean", "default": False}, "site_notice_level": {"type": "text", "default": "info"}, diff --git a/src/landppt/services/db_project_manager.py b/src/landppt/services/db_project_manager.py index 05b9de1..8ca65b2 100644 --- a/src/landppt/services/db_project_manager.py +++ b/src/landppt/services/db_project_manager.py @@ -175,17 +175,7 @@ class DatabaseProjectManager: # 准备幻灯片数据 slides_records = [] 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) - } - slides_records.append(slide_record) + slides_records.append(db_service._slide_record_from_payload(project_id, i, slide_data)) # 使用批量upsert success = await db_service.slide_repo.batch_upsert_slides(project_id, slides_records) @@ -291,6 +281,35 @@ class DatabaseProjectManager: finally: await db_service.session.close() + async def apply_slide_structure_operation( + self, + project_id: str, + operation: Dict[str, Any], + user_id: Optional[int] = None, + ) -> bool: + """Apply a slide insert/delete/move operation to persisted slide order.""" + db_service = await self._get_db_service() + try: + return await db_service.apply_slide_structure_operation( + project_id, + operation, + user_id=user_id, + ) + finally: + await db_service.session.close() + + async def duplicate_project( + self, + project_id: str, + user_id: Optional[int] = None, + ) -> Optional[PPTProject]: + """Duplicate a project for the current user.""" + db_service = await self._get_db_service() + try: + return await db_service.duplicate_project(project_id, user_id=user_id) + finally: + await db_service.session.close() + async def get_stage_status(self, project_id: str, stage_id: str, user_id: Optional[int] = None) -> Optional[Dict[str, Any]]: """Get a stage status by project_id and stage_id. If user_id is provided, enforces ownership.""" db_service = await self._get_db_service() diff --git a/src/landppt/web/admin_routes.py b/src/landppt/web/admin_routes.py index 42d74d2..c61a940 100644 --- a/src/landppt/web/admin_routes.py +++ b/src/landppt/web/admin_routes.py @@ -97,7 +97,7 @@ class CommunitySettingsRequest(BaseModel): daily_checkin_reward_fixed: int = 5 daily_checkin_reward_min: int = 2 daily_checkin_reward_max: int = 8 - invite_code_required_for_registration: bool = True + invite_code_required_for_registration: bool = False sponsor_page_enabled: bool = False site_notice_enabled: bool = False site_notice_level: str = "info" diff --git a/src/landppt/web/route_modules/outline_generation_routes.py b/src/landppt/web/route_modules/outline_generation_routes.py index 76d0e17..7e4d28e 100644 --- a/src/landppt/web/route_modules/outline_generation_routes.py +++ b/src/landppt/web/route_modules/outline_generation_routes.py @@ -4,6 +4,7 @@ Outline generation routes extracted from the outline router. from __future__ import annotations +import json import time from fastapi import APIRouter, Depends, HTTPException, Request @@ -177,6 +178,8 @@ async def stream_outline_generation( }, ) + except HTTPException: + raise except Exception as e: raise HTTPException(status_code=500, detail=str(e)) @@ -794,13 +797,34 @@ async def update_project_outline( try: data = await request.json() outline_content = data.get('outline_content', '') + operation = data.get('operation') + try: + json.loads(outline_content) + except (TypeError, json.JSONDecodeError): + raise HTTPException(status_code=400, detail="Invalid outline JSON") - success = await ppt_service.update_project_outline(project_id, outline_content) + user_ppt_service = get_ppt_service_for_user(user.id) + project = await user_ppt_service.project_manager.get_project(project_id, user_id=user.id) + if not project: + raise HTTPException(status_code=404, detail="Project not found") + + if operation: + operation_success = await user_ppt_service.project_manager.apply_slide_structure_operation( + project_id, + operation, + user_id=user.id, + ) + if not operation_success: + raise HTTPException(status_code=400, detail="Failed to apply slide structure operation") + + success = await user_ppt_service.update_project_outline(project_id, outline_content) if success: return {"status": "success", "message": "Outline updated"} else: raise HTTPException(status_code=500, detail="Failed to update outline") + except HTTPException: + raise except Exception as e: raise HTTPException(status_code=500, detail=str(e)) diff --git a/src/landppt/web/route_modules/slide_routes.py b/src/landppt/web/route_modules/slide_routes.py index 7b47b83..8e34312 100644 --- a/src/landppt/web/route_modules/slide_routes.py +++ b/src/landppt/web/route_modules/slide_routes.py @@ -531,6 +531,7 @@ async def save_single_slide_content( html_content = data.get('html_content', '') requested_is_user_edited = data.get('is_user_edited', True) is_user_edited = bool(requested_is_user_edited) + incoming_slide_data = data.get('slide_data') if isinstance(data.get('slide_data'), dict) else {} logger.info(f"📄 接收到HTML内容,长度: {len(html_content)} 字符") @@ -570,6 +571,28 @@ async def save_single_slide_content( "is_user_edited": is_user_edited } + for key, value in incoming_slide_data.items(): + if value is not None: + slide_data[key] = value + + for key in ( + "title", + "slide_type", + "type", + "content_type", + "description", + "subtitle", + "content", + "content_points", + "metadata", + "page_number", + ): + if key in data and data.get(key) is not None: + slide_data[key] = data.get(key) + + slide_data['html_content'] = html_content + slide_data['is_user_edited'] = is_user_edited + logger.debug(f"📝 更新第 {slide_index + 1} 页的内容") logger.debug(f"📊 幻灯片数据: 标题='{slide_data.get('title', '无标题')}', 用户编辑={is_user_edited}, 索引={slide_index}") diff --git a/src/landppt/web/static/css/pages/project/slides_editor/projectSlidesEditor.css b/src/landppt/web/static/css/pages/project/slides_editor/projectSlidesEditor.css index 1df2285..b570737 100644 --- a/src/landppt/web/static/css/pages/project/slides_editor/projectSlidesEditor.css +++ b/src/landppt/web/static/css/pages/project/slides_editor/projectSlidesEditor.css @@ -203,8 +203,8 @@ body::before { .slide-preview { width: 100%; - height: 135px; - /* 调整高度以适应新的缩放比例:720*0.1875=135px */ + aspect-ratio: 16 / 9; + height: auto; border: none; border-radius: 6px 6px 0 0; pointer-events: none; @@ -225,7 +225,7 @@ body::before { width: 1280px; height: 720px; transform-origin: center center; - transform: translate(-50%, -50%) scale(0.1); + transform: translate(-50%, -50%) scale(0.1875); } .slide-title { @@ -1100,7 +1100,7 @@ body::before { /* 响应式设计 */ @media (max-width: 1200px) { .slide-preview { - height: 115px; + height: auto; } /* Hide text labels on medium screens to save space */ @@ -1119,7 +1119,7 @@ body::before { } .slide-preview { - height: 115px; + height: auto; } .slide-title { @@ -1136,8 +1136,7 @@ body::before { } .slide-preview { - height: 105px; - /* 180px容器宽度,180/1280=0.141,720*0.141=101.52px */ + height: auto; } .slide-title { @@ -1164,8 +1163,7 @@ body::before { } .slide-preview { - height: 95px; - /* 160px容器宽度,160/1280=0.125,720*0.125=90px */ + height: auto; } .slide-title { diff --git a/src/landppt/web/static/js/pages/project/slides_editor/projectSlidesEditor.aiChat.js b/src/landppt/web/static/js/pages/project/slides_editor/projectSlidesEditor.aiChat.js index 528be77..73e278e 100644 --- a/src/landppt/web/static/js/pages/project/slides_editor/projectSlidesEditor.aiChat.js +++ b/src/landppt/web/static/js/pages/project/slides_editor/projectSlidesEditor.aiChat.js @@ -398,6 +398,10 @@ async function saveSlideOutline() { // 更新幻灯片标题 if (slidesData[currentSlideIndex]) { slidesData[currentSlideIndex].title = title; + slidesData[currentSlideIndex].slide_type = type; + slidesData[currentSlideIndex].content_type = type; + slidesData[currentSlideIndex].description = description; + slidesData[currentSlideIndex].content_points = points; } try { @@ -418,6 +422,13 @@ async function saveSlideOutline() { const data = await response.json(); if (data.status === 'success') { + if (typeof saveSingleSlideToServer === 'function' && slidesData[currentSlideIndex]?.html_content) { + await saveSingleSlideToServer( + currentSlideIndex, + slidesData[currentSlideIndex].html_content, + { slideData: slidesData[currentSlideIndex], isUserEdited: true } + ); + } showNotification('大纲已保存!', 'success'); } else { throw new Error(data.message || data.error || '保存失败'); @@ -540,13 +551,25 @@ async function updateOutlineForSlideOperation(operation, slideIndex, slideData = } // 保存更新后的大纲到数据库 + const operationPayload = { + type: operation, + slide_index: slideIndex + }; + if (operation === 'move' && slideData && Number.isInteger(slideData.to_index)) { + operationPayload.to_index = slideData.to_index; + } + if (operation === 'insert' && slideData) { + operationPayload.slide_data = slideData; + } + const response = await fetch(`/projects/${window.landpptEditorConfig.projectId}/update-outline`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - outline_content: JSON.stringify(projectOutline, null, 2) + outline_content: JSON.stringify(projectOutline, null, 2), + operation: operationPayload }) }); diff --git a/src/landppt/web/static/js/pages/project/slides_editor/projectSlidesEditor.core.js b/src/landppt/web/static/js/pages/project/slides_editor/projectSlidesEditor.core.js index ffb4d17..81b65e7 100644 --- a/src/landppt/web/static/js/pages/project/slides_editor/projectSlidesEditor.core.js +++ b/src/landppt/web/static/js/pages/project/slides_editor/projectSlidesEditor.core.js @@ -306,6 +306,7 @@ function setSafeIframeContent(iframe, html, options = {}) { const preparedHtml = prepareHtmlForPreview(html); if (!force && iframe.getAttribute('data-current-content') === preparedHtml) { + requestThumbnailPreviewScale(iframe); return; } @@ -315,12 +316,9 @@ function setSafeIframeContent(iframe, html, options = {}) { // 使用requestAnimationFrame优化性能 requestAnimationFrame(() => { try { - // 直接设置srcdoc,减少延迟 - iframe.srcdoc = preparedHtml; - iframe.setAttribute('data-current-content', preparedHtml); + const handleIframeLoad = function () { + requestThumbnailPreviewScale(iframe); - // 简化的加载完成处理 - iframe.onload = function () { // 减少延迟,提高响应速度 setTimeout(() => { try { @@ -345,14 +343,41 @@ function setSafeIframeContent(iframe, html, options = {}) { } catch (e) { // 静默处理错误,避免控制台噪音 } - }, 50); // 减少延迟时间 + }, 50); }; + + iframe.addEventListener('load', handleIframeLoad, { once: true }); + + // 直接设置srcdoc,减少延迟 + iframe.srcdoc = preparedHtml; + iframe.setAttribute('data-current-content', preparedHtml); } catch (e) { // 设置iframe内容失败 } }); } +function requestThumbnailPreviewScale(iframe) { + if (!iframe || typeof iframe.closest !== 'function' || !iframe.closest('.slide-preview')) { + return; + } + + const runScale = () => { + if (typeof applyThumbnailPreviewScale === 'function') { + applyThumbnailPreviewScale(iframe); + } + }; + + if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') { + window.requestAnimationFrame(() => { + window.requestAnimationFrame(runScale); + }); + return; + } + + setTimeout(runScale, 0); +} + function syncIframeCurrentContent(iframe, html) { if (!iframe || typeof html !== 'string') { return; diff --git a/src/landppt/web/static/js/pages/project/slides_editor/projectSlidesEditor.slideCrud.js b/src/landppt/web/static/js/pages/project/slides_editor/projectSlidesEditor.slideCrud.js index 5c39205..28b6732 100644 --- a/src/landppt/web/static/js/pages/project/slides_editor/projectSlidesEditor.slideCrud.js +++ b/src/landppt/web/static/js/pages/project/slides_editor/projectSlidesEditor.slideCrud.js @@ -51,7 +51,10 @@ function handleDrop(event, targetIndex) { } // 移动幻灯片 - moveSlide(draggedSlideIndex, newIndex); + moveSlide(draggedSlideIndex, newIndex).catch((error) => { + console.error('Move slide failed:', error); + showNotification('移动幻灯片失败:' + (error?.message || error), 'error'); + }); } function handleDragEnd(event) { @@ -65,7 +68,7 @@ function handleDragEnd(event) { draggedSlideIndex = -1; } -function moveSlide(fromIndex, toIndex) { +async function moveSlide(fromIndex, toIndex) { if (fromIndex === toIndex || fromIndex < 0 || toIndex < 0 || fromIndex >= slidesData.length || toIndex > slidesData.length) { return; @@ -99,10 +102,7 @@ function moveSlide(fromIndex, toIndex) { }); // 同步更新大纲顺序(避免重新生成等操作按编辑器序号写入错误页) - updateOutlineForSlideOperation('move', fromIndex, { to_index: toIndex }).catch((e) => { - console.error('Outline move failed:', e); - showNotification('同步更新大纲顺序失败:' + (e?.message || e), 'warning'); - }); + await updateOutlineForSlideOperation('move', fromIndex, { to_index: toIndex }); // 更新当前选中的索引 if (currentSlideIndex === fromIndex) { @@ -117,7 +117,7 @@ function moveSlide(fromIndex, toIndex) { refreshSidebar(); // 保存到服务器 - saveToServer(); + await saveToServer(); } // 右键菜单功能 @@ -220,7 +220,7 @@ async function pasteSlide() { // 刷新界面 refreshSidebar(); - saveToServer(); + await saveToServer(); showNotification('幻灯片已粘贴', 'success'); } catch (error) { showNotification('粘贴幻灯片失败:' + error.message, 'error'); @@ -283,7 +283,7 @@ async function insertNewSlide() { // 刷新界面 refreshSidebar(); - saveToServer(); + await saveToServer(); showNotification('新幻灯片已插入', 'success'); } catch (error) { showNotification('插入新幻灯片失败:' + error.message, 'error'); @@ -334,7 +334,7 @@ async function duplicateSlide() { // 刷新界面 refreshSidebar(); - saveToServer(); + await saveToServer(); showNotification('幻灯片已复制', 'success'); } catch (error) { showNotification('复制幻灯片失败:' + error.message, 'error'); @@ -440,18 +440,14 @@ function refreshSidebar() {
`; - // 设置iframe内容并应用缩放 + slidesContainer.appendChild(thumbnailDiv); + + // 设置iframe内容并应用缩放。先挂载到DOM,确保缩放计算能拿到真实容器宽度。 const iframe = thumbnailDiv.querySelector('iframe'); if (iframe) { - // 安全设置iframe内容 setSafeIframeContent(iframe, slide.html_content); - - iframe.onload = function () { - requestAnimationFrame(() => applyThumbnailPreviewScale(this)); - }; + requestThumbnailPreviewScale(iframe); } - - slidesContainer.appendChild(thumbnailDiv); }); // 重新初始化事件监听器 diff --git a/src/landppt/web/static/js/pages/project/slides_editor/projectSlidesEditor.slideGeneration.js b/src/landppt/web/static/js/pages/project/slides_editor/projectSlidesEditor.slideGeneration.js index e3425c0..c36e32d 100644 --- a/src/landppt/web/static/js/pages/project/slides_editor/projectSlidesEditor.slideGeneration.js +++ b/src/landppt/web/static/js/pages/project/slides_editor/projectSlidesEditor.slideGeneration.js @@ -412,7 +412,7 @@ async function saveToServer() { // 标记为用户编辑状态 slide.is_user_edited = true; - const success = await saveSingleSlideToServer(i, slide.html_content); + const success = await saveSingleSlideToServer(i, slide.html_content, { slideData: slide }); if (success) { saveSuccessCount++; } else { @@ -446,7 +446,7 @@ async function saveToServerFallback() { // 标记为用户编辑状态 slide.is_user_edited = true; - const success = await saveSingleSlideToServer(i, slide.html_content); + const success = await saveSingleSlideToServer(i, slide.html_content, { slideData: slide }); if (success) { saveSuccessCount++; } else { @@ -524,11 +524,28 @@ async function saveSingleSlideToServer(slideIndex, htmlContent, options = {}) { + const sourceSlide = options.slideData || slidesData[slideIndex] || {}; + const slidePayload = { + ...sourceSlide, + html_content: htmlContent, + page_number: slideIndex + 1 + }; + const slideType = slidePayload.slide_type || slidePayload.content_type || slidePayload.type || 'content'; + const requestData = { - html_content: htmlContent + html_content: htmlContent, + slide_data: slidePayload, + title: slidePayload.title || `Slide ${slideIndex + 1}`, + slide_type: slideType, + content_type: slideType, + content_points: slidePayload.content_points || [], + metadata: slidePayload.metadata || {}, + page_number: slideIndex + 1 }; if (typeof options.isUserEdited === 'boolean') { requestData.is_user_edited = options.isUserEdited; + } else if (typeof slidePayload.is_user_edited === 'boolean') { + requestData.is_user_edited = slidePayload.is_user_edited; } diff --git a/src/landppt/web/templates/components/project/detail/content_1.html b/src/landppt/web/templates/components/project/detail/content_1.html index 21cfa0b..437b1d9 100644 --- a/src/landppt/web/templates/components/project/detail/content_1.html +++ b/src/landppt/web/templates/components/project/detail/content_1.html @@ -112,6 +112,10 @@ 归档项目 + + @@ -200,145 +204,177 @@ {% if project.outline and project.outline is not none and project.outline.get('slides') %} -
-
-

PPT 大纲

-
- - -
-
-
-

{{ project.outline.get('title', +
+
+

{{ project.outline.get('title', project.topic or '未命名大纲') }}

-

- 总共 {{ project.outline.get('slides', [])|length }} 页幻灯片 | - 场景: {{ project.outline.get('metadata', {}).get('scenario', project.scenario) }} | - 语言: {{ project.outline.get('metadata', {}).get('language', 'zh') }} -

+
+ {{ project.outline.get('slides', + [])|length }} 页幻灯片 + {{ project.outline.get('metadata', + {}).get('scenario', project.scenario) }} + {{ project.outline.get('metadata', + {}).get('language', 'zh') }} +
-
-
-
- {% for slide in project.outline.get('slides', []) %} -
+
+
+ {% for slide in project.outline.get('slides', []) %} +
+
+ + + + + +
- -
- - +
+
+ {{ slide.get('page_number', loop.index) + }} +
+
{{ slide.get('title', '未命名幻灯片') }}
+ {% if slide.get('subtitle') %} +

{{ slide.get('subtitle') }}

+ {% endif %} +
- -
-
- - {{ slide.get('page_number', loop.index) }} - - {{ slide.get('title', - '未命名幻灯片') }} -
- {% if slide.get('subtitle') %} -

- {{ slide.get('subtitle') }}

+

+ {% if slide.get('content_points') and slide.get('content_points')|length > 0 %} + {{ slide.get('content_points')[0][:80] }}{% if slide.get('content_points')[0]|length > + 80 %}...{% endif %} + {% elif slide.get('content') %} + {{ slide.get('content')[:80] }}{% if slide.get('content')|length > 80 %}...{% endif %} + {% else %} + 暂无内容 + {% endif %} +

+
+ 可拖拽 + {% if slide.get('content_points') and slide.get('content_points')|length > 1 %} + +{{ slide.get('content_points')|length - 1 }} 个要点 + {% endif %} + {% if slide.get('slide_type') %} + {{ slide.get('slide_type') }} {% endif %} -
- {% if slide.get('content_points') and slide.get('content_points')|length > 0 %} - {{ slide.get('content_points')[0][:80] }}{% if slide.get('content_points')[0]|length - > 80 %}...{% endif %} - {% if slide.get('content_points')|length > 1 %} -
+{{ slide.get('content_points')|length - - 1 }} 个要点 - {% endif %} - {% elif slide.get('content') %} - {{ slide.get('content')[:80] }}{% if slide.get('content')|length > 80 %}...{% endif - %} - {% else %} - 暂无内容 - {% endif %} -
- {% endfor %} -
+
+ {% endfor %}
- + + + @@ -649,7 +819,7 @@ {% endblock %} @@ -657,6 +827,7 @@ {% block extra_js %}