Refactor code structure for improved readability and maintainability
@@ -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)
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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}")
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
})
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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() {
|
||||
<div class="drag-indicator bottom"></div>
|
||||
`;
|
||||
|
||||
// 设置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);
|
||||
});
|
||||
|
||||
// 重新初始化事件监听器
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -112,6 +112,10 @@
|
||||
归档项目
|
||||
</button>
|
||||
|
||||
<button onclick="duplicateProject()" class="btn btn-primary action-button">
|
||||
复制项目
|
||||
</button>
|
||||
|
||||
<button onclick="deleteProject()" class="btn btn-danger action-button">
|
||||
删除项目
|
||||
</button>
|
||||
@@ -200,145 +204,177 @@
|
||||
|
||||
<!-- PPT Outline Preview -->
|
||||
{% if project.outline and project.outline is not none and project.outline.get('slides') %}
|
||||
<div style="margin-top: 40px;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
|
||||
<h3 class="section-heading" style="margin: 0;">PPT 大纲</h3>
|
||||
<div style="display: flex; gap: 10px;">
|
||||
<button onclick="toggleOutlineView()" class="btn btn-info" style="font-size: 0.9em;">
|
||||
切换视图
|
||||
<section class="outline-panel">
|
||||
<div class="outline-panel__header">
|
||||
<div>
|
||||
<h3 class="section-heading outline-panel__title">
|
||||
<i class="fas fa-sitemap" aria-hidden="true"></i>
|
||||
PPT 大纲
|
||||
</h3>
|
||||
<p class="outline-panel__subtitle">拖拽幻灯片调整顺序,或使用每页右上角的操作图标。</p>
|
||||
</div>
|
||||
<div class="outline-toolbar" aria-label="大纲操作">
|
||||
<button id="outlineViewToggleBtn" type="button" onclick="toggleOutlineView()" class="outline-tool-btn"
|
||||
aria-label="切换为详细视图" data-tooltip="详细视图">
|
||||
<i class="fas fa-list" aria-hidden="true"></i>
|
||||
</button>
|
||||
<button onclick="editOutline()" class="btn btn-primary" style="font-size: 0.9em;">
|
||||
编辑大纲
|
||||
<button type="button" onclick="editOutline()" class="outline-tool-btn" aria-label="编辑大纲"
|
||||
data-tooltip="编辑大纲">
|
||||
<i class="fas fa-edit" aria-hidden="true"></i>
|
||||
</button>
|
||||
<button onclick="exportOutlineJSON()" class="btn btn-success" style="font-size: 0.9em;">
|
||||
导出JSON
|
||||
<button type="button" onclick="exportOutlineJSON()" class="outline-tool-btn" aria-label="导出 JSON"
|
||||
data-tooltip="导出 JSON">
|
||||
<i class="fas fa-download" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="background: white; border-radius: 15px; padding: 30px; box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1);">
|
||||
<div style="text-align: center; margin-bottom: 20px;">
|
||||
<h4 style="color: var(--text-primary); margin-bottom: 10px;">{{ project.outline.get('title',
|
||||
<div class="outline-surface">
|
||||
<div class="outline-summary">
|
||||
<h4 class="outline-summary__title">{{ project.outline.get('title',
|
||||
project.topic or '未命名大纲') }}</h4>
|
||||
<p style="color: var(--text-secondary);">
|
||||
总共 {{ project.outline.get('slides', [])|length }} 页幻灯片 |
|
||||
场景: {{ project.outline.get('metadata', {}).get('scenario', project.scenario) }} |
|
||||
语言: {{ project.outline.get('metadata', {}).get('language', 'zh') }}
|
||||
</p>
|
||||
<div class="outline-summary__meta">
|
||||
<span><i class="fas fa-layer-group" aria-hidden="true"></i>{{ project.outline.get('slides',
|
||||
[])|length }} 页幻灯片</span>
|
||||
<span><i class="fas fa-briefcase" aria-hidden="true"></i>{{ project.outline.get('metadata',
|
||||
{}).get('scenario', project.scenario) }}</span>
|
||||
<span><i class="fas fa-language" aria-hidden="true"></i>{{ project.outline.get('metadata',
|
||||
{}).get('language', 'zh') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 简洁视图 -->
|
||||
<div id="compactView" style="display: block;">
|
||||
<div style="background: var(--surface-muted); border-radius: 10px; padding: 20px;">
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 15px;">
|
||||
{% for slide in project.outline.get('slides', []) %}
|
||||
<div style="padding: 15px; background: white; border-radius: 8px; border-left: 4px solid #1f2933; transition: all 0.3s ease; box-shadow: 0 2px 4px rgba(0,0,0,0.1); position: relative;"
|
||||
onmouseover="this.style.transform='translateY(-2px)'; this.style.boxShadow='0 4px 8px rgba(0,0,0,0.15)'"
|
||||
onmouseout="this.style.transform='translateY(0)'; this.style.boxShadow='0 2px 4px rgba(0,0,0,0.1)'">
|
||||
<div id="compactView" class="outline-view outline-view--compact">
|
||||
<div class="outline-card-grid">
|
||||
{% for slide in project.outline.get('slides', []) %}
|
||||
<article data-outline-slide-index="{{ loop.index0 }}" draggable="true" class="outline-slide-card">
|
||||
<div class="outline-slide-card__actions" aria-label="第 {{ loop.index }} 页操作">
|
||||
<button type="button" onclick="editSingleSlide({{ loop.index0 }})"
|
||||
class="outline-tool-btn outline-tool-btn--small" aria-label="编辑此页"
|
||||
data-tooltip="编辑">
|
||||
<i class="fas fa-edit" aria-hidden="true"></i>
|
||||
</button>
|
||||
<button type="button" onclick="viewSlideDetail({{ loop.index0 }})"
|
||||
class="outline-tool-btn outline-tool-btn--small" aria-label="查看详情"
|
||||
data-tooltip="详情">
|
||||
<i class="fas fa-eye" aria-hidden="true"></i>
|
||||
</button>
|
||||
<button type="button" onclick="moveOutlineSlide({{ loop.index0 }}, {{ loop.index0 - 1 }})"
|
||||
class="outline-tool-btn outline-tool-btn--small" aria-label="上移"
|
||||
data-tooltip="上移" {% if loop.first %}disabled{% endif %}>
|
||||
<i class="fas fa-arrow-up" aria-hidden="true"></i>
|
||||
</button>
|
||||
<button type="button" onclick="moveOutlineSlide({{ loop.index0 }}, {{ loop.index0 + 1 }})"
|
||||
class="outline-tool-btn outline-tool-btn--small" aria-label="下移"
|
||||
data-tooltip="下移" {% if loop.last %}disabled{% endif %}>
|
||||
<i class="fas fa-arrow-down" aria-hidden="true"></i>
|
||||
</button>
|
||||
<button type="button" onclick="deleteOutlineSlide({{ loop.index0 }})"
|
||||
class="outline-tool-btn outline-tool-btn--small outline-tool-btn--danger"
|
||||
aria-label="删除此页" data-tooltip="删除">
|
||||
<i class="fas fa-trash-alt" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div style="position: absolute; top: 10px; right: 10px; display: flex; gap: 5px;">
|
||||
<button onclick="editSingleSlide({{ loop.index0 }})" class="btn btn-primary"
|
||||
style="font-size: 0.7em; padding: 4px 8px; border-radius: 4px;" title="编辑此页">
|
||||
编辑
|
||||
</button>
|
||||
<button onclick="viewSlideDetail({{ loop.index0 }})" class="btn btn-info"
|
||||
style="font-size: 0.7em; padding: 4px 8px; border-radius: 4px;" title="查看详情">
|
||||
详情
|
||||
</button>
|
||||
<div class="outline-slide-card__body" onclick="viewSlideDetail({{ loop.index0 }})">
|
||||
<div class="outline-slide-card__header">
|
||||
<span class="outline-slide-card__number">{{ slide.get('page_number', loop.index)
|
||||
}}</span>
|
||||
<div class="outline-slide-card__heading">
|
||||
<h5>{{ slide.get('title', '未命名幻灯片') }}</h5>
|
||||
{% if slide.get('subtitle') %}
|
||||
<p>{{ slide.get('subtitle') }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div onclick="viewSlideDetail({{ loop.index0 }})"
|
||||
style="cursor: pointer; margin-right: 60px;">
|
||||
<div style="display: flex; align-items: center; margin-bottom: 8px;">
|
||||
<span
|
||||
style="background: #1f2933; color: white; border-radius: 50%; width: 24px; height: 24px; display: flex; align-items: center; justify-content: center; font-size: 0.8em; font-weight: bold; margin-right: 10px;">
|
||||
{{ slide.get('page_number', loop.index) }}
|
||||
</span>
|
||||
<strong style="color: var(--text-primary); font-size: 0.9em;">{{ slide.get('title',
|
||||
'未命名幻灯片') }}</strong>
|
||||
</div>
|
||||
{% if slide.get('subtitle') %}
|
||||
<p
|
||||
style="color: var(--text-secondary); font-size: 0.8em; margin: 5px 0; font-style: italic;">
|
||||
{{ slide.get('subtitle') }}</p>
|
||||
<p class="outline-slide-card__excerpt">
|
||||
{% 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 %}
|
||||
</p>
|
||||
<div class="outline-slide-card__meta">
|
||||
<span><i class="fas fa-grip-vertical" aria-hidden="true"></i>可拖拽</span>
|
||||
{% if slide.get('content_points') and slide.get('content_points')|length > 1 %}
|
||||
<span>+{{ slide.get('content_points')|length - 1 }} 个要点</span>
|
||||
{% endif %}
|
||||
{% if slide.get('slide_type') %}
|
||||
<span>{{ slide.get('slide_type') }}</span>
|
||||
{% endif %}
|
||||
<div style="color: var(--text-secondary); font-size: 0.8em; line-height: 1.4;">
|
||||
{% 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 %}
|
||||
<br><span style="color: var(--text-muted);">+{{ slide.get('content_points')|length -
|
||||
1 }} 个要点</span>
|
||||
{% endif %}
|
||||
{% elif slide.get('content') %}
|
||||
{{ slide.get('content')[:80] }}{% if slide.get('content')|length > 80 %}...{% endif
|
||||
%}
|
||||
{% else %}
|
||||
<span style="color: var(--text-muted);">暂无内容</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 详细视图 -->
|
||||
<div id="detailView" style="display: none; max-height: 500px; overflow-y: auto;">
|
||||
<div id="detailView" class="outline-view outline-view--detail">
|
||||
{% for slide in project.outline.get('slides', []) %}
|
||||
<div
|
||||
style="padding: 20px; margin-bottom: 15px; background: var(--surface-muted); border-radius: 10px; border-left: 4px solid #1f2933; position: relative;">
|
||||
<div style="display: flex; align-items: center; margin-bottom: 15px;">
|
||||
<span
|
||||
style="background: #1f2933; color: white; border-radius: 50%; width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; font-weight: bold; margin-right: 15px;">
|
||||
{{ slide.get('page_number', loop.index) }}
|
||||
</span>
|
||||
<div style="flex: 1;">
|
||||
<strong class="stage-title">{{ slide.get('title', '未命名幻灯片') }}</strong>
|
||||
<article data-outline-slide-index="{{ loop.index0 }}" draggable="true" class="outline-detail-row">
|
||||
<div class="outline-detail-row__top">
|
||||
<span class="outline-slide-card__number outline-slide-card__number--large">{{
|
||||
slide.get('page_number', loop.index) }}</span>
|
||||
<div class="outline-detail-row__heading">
|
||||
<h5>{{ slide.get('title', '未命名幻灯片') }}</h5>
|
||||
{% if slide.get('subtitle') %}
|
||||
<br><em style="color: var(--text-secondary); font-size: 0.9em;">{{ slide.get('subtitle')
|
||||
}}</em>
|
||||
<p>{{ slide.get('subtitle') }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<button onclick="editSingleSlide({{ loop.index0 }})" class="btn btn-primary"
|
||||
style="font-size: 0.8em; padding: 6px 12px;" title="编辑此页">
|
||||
编辑
|
||||
</button>
|
||||
<div class="outline-detail-row__actions" aria-label="第 {{ loop.index }} 页操作">
|
||||
<button type="button" onclick="editSingleSlide({{ loop.index0 }})"
|
||||
class="outline-tool-btn outline-tool-btn--small" aria-label="编辑此页"
|
||||
data-tooltip="编辑">
|
||||
<i class="fas fa-edit" aria-hidden="true"></i>
|
||||
</button>
|
||||
<button type="button" onclick="moveOutlineSlide({{ loop.index0 }}, {{ loop.index0 - 1 }})"
|
||||
class="outline-tool-btn outline-tool-btn--small" aria-label="上移"
|
||||
data-tooltip="上移" {% if loop.first %}disabled{% endif %}>
|
||||
<i class="fas fa-arrow-up" aria-hidden="true"></i>
|
||||
</button>
|
||||
<button type="button" onclick="moveOutlineSlide({{ loop.index0 }}, {{ loop.index0 + 1 }})"
|
||||
class="outline-tool-btn outline-tool-btn--small" aria-label="下移"
|
||||
data-tooltip="下移" {% if loop.last %}disabled{% endif %}>
|
||||
<i class="fas fa-arrow-down" aria-hidden="true"></i>
|
||||
</button>
|
||||
<button type="button" onclick="deleteOutlineSlide({{ loop.index0 }})"
|
||||
class="outline-tool-btn outline-tool-btn--small outline-tool-btn--danger"
|
||||
aria-label="删除此页" data-tooltip="删除">
|
||||
<i class="fas fa-trash-alt" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if slide.get('content_points') %}
|
||||
<div style="margin-top: 10px;">
|
||||
<h5 style="color: var(--text-secondary); margin-bottom: 8px; font-size: 0.9em;">内容要点:</h5>
|
||||
<ul style="margin: 0; padding-left: 20px; color: var(--text-secondary); line-height: 1.6;">
|
||||
<div class="outline-detail-row__content">
|
||||
<h6>内容要点</h6>
|
||||
<ul>
|
||||
{% for point in slide.get('content_points', []) %}
|
||||
<li style="margin-bottom: 5px;">{{ point }}</li>
|
||||
<li>{{ point }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% elif slide.get('content') %}
|
||||
<div style="margin-top: 10px;">
|
||||
<h5 style="color: var(--text-secondary); margin-bottom: 8px; font-size: 0.9em;">内容:</h5>
|
||||
<div
|
||||
style="background: white; padding: 15px; border-radius: 6px; color: var(--text-secondary); line-height: 1.6; white-space: pre-wrap;">
|
||||
{{ slide.get('content') }}</div>
|
||||
<div class="outline-detail-row__content">
|
||||
<h6>内容</h6>
|
||||
<p>{{ slide.get('content') }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if slide.get('slide_type') %}
|
||||
<div style="margin-top: 10px;">
|
||||
<span
|
||||
style="background: var(--surface); color: var(--text-primary); padding: 4px 8px; border-radius: 4px; font-size: 0.8em;">
|
||||
类型: {{ slide.get('slide_type') }}
|
||||
</span>
|
||||
<div class="outline-detail-row__chips">
|
||||
<span>类型: {{ slide.get('slide_type') }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% elif project.outline and project.outline is not none %}
|
||||
<!-- Show outline content if it exists but doesn't have slides structure -->
|
||||
<div style="margin-top: 40px;">
|
||||
|
||||
@@ -279,6 +279,365 @@
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.outline-panel {
|
||||
margin-top: 40px;
|
||||
}
|
||||
|
||||
.outline-panel__header {
|
||||
align-items: flex-start;
|
||||
display: flex;
|
||||
gap: var(--spacing-md);
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.outline-panel__title {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: var(--spacing-sm);
|
||||
margin: 0 0 6px 0;
|
||||
}
|
||||
|
||||
.outline-panel__subtitle {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.outline-toolbar,
|
||||
.outline-slide-card__actions,
|
||||
.outline-detail-row__actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.outline-toolbar {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.outline-tool-btn {
|
||||
align-items: center;
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
height: 36px;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
position: relative;
|
||||
transition: background 0.18s ease, border-color 0.18s ease, color 0.18s ease, transform 0.18s ease;
|
||||
width: 36px;
|
||||
}
|
||||
|
||||
.outline-tool-btn:hover,
|
||||
.outline-tool-btn:focus-visible {
|
||||
background: var(--surface-subtle);
|
||||
border-color: var(--glass-border);
|
||||
color: var(--text-primary);
|
||||
outline: none;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.outline-tool-btn:disabled {
|
||||
color: var(--text-muted);
|
||||
cursor: not-allowed;
|
||||
opacity: 0.35;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.outline-tool-btn--small {
|
||||
height: 30px;
|
||||
width: 30px;
|
||||
}
|
||||
|
||||
.outline-tool-btn--danger:hover,
|
||||
.outline-tool-btn--danger:focus-visible {
|
||||
background: #fef2f2;
|
||||
border-color: #fecaca;
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.outline-tool-btn[data-tooltip]::after,
|
||||
.outline-tool-btn[data-tooltip]::before {
|
||||
left: 50%;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
transform: translate(-50%, 4px);
|
||||
transition: opacity 0.16s ease, transform 0.16s ease;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.outline-tool-btn[data-tooltip]::after {
|
||||
background: var(--surface-contrast);
|
||||
border-radius: 6px;
|
||||
bottom: calc(100% + 8px);
|
||||
color: var(--surface);
|
||||
content: attr(data-tooltip);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
padding: 7px 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.outline-tool-btn[data-tooltip]::before {
|
||||
border: 5px solid transparent;
|
||||
border-top-color: var(--surface-contrast);
|
||||
bottom: calc(100% - 1px);
|
||||
content: '';
|
||||
}
|
||||
|
||||
.outline-tool-btn[data-tooltip]:hover::after,
|
||||
.outline-tool-btn[data-tooltip]:hover::before,
|
||||
.outline-tool-btn[data-tooltip]:focus-visible::after,
|
||||
.outline-tool-btn[data-tooltip]:focus-visible::before {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, 0);
|
||||
}
|
||||
|
||||
.outline-surface {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 8px;
|
||||
box-shadow: var(--shadow-soft);
|
||||
padding: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.outline-summary {
|
||||
align-items: flex-start;
|
||||
border-bottom: 1px solid var(--glass-border);
|
||||
display: flex;
|
||||
gap: var(--spacing-md);
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--spacing-lg);
|
||||
padding-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.outline-summary__title {
|
||||
color: var(--text-primary);
|
||||
font-size: 1.05rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.outline-summary__meta {
|
||||
color: var(--text-secondary);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
font-size: 0.85rem;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.outline-summary__meta span {
|
||||
align-items: center;
|
||||
background: var(--surface-subtle);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 999px;
|
||||
display: inline-flex;
|
||||
gap: 6px;
|
||||
padding: 5px 9px;
|
||||
}
|
||||
|
||||
.outline-card-grid {
|
||||
display: grid;
|
||||
gap: var(--spacing-md);
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
}
|
||||
|
||||
.outline-slide-card,
|
||||
.outline-detail-row {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-left: 3px solid #1f2933;
|
||||
border-radius: 8px;
|
||||
position: relative;
|
||||
transition: border-color 0.18s ease, box-shadow 0.18s ease, transform 0.18s ease;
|
||||
}
|
||||
|
||||
.outline-slide-card {
|
||||
min-height: 172px;
|
||||
padding: var(--spacing-md);
|
||||
}
|
||||
|
||||
.outline-slide-card:hover,
|
||||
.outline-detail-row:hover,
|
||||
.outline-slide-card.is-drag-over,
|
||||
.outline-detail-row.is-drag-over {
|
||||
border-color: #1f2933;
|
||||
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.1);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.outline-slide-card__actions {
|
||||
justify-content: flex-end;
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.outline-slide-card__body {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.outline-slide-card__header,
|
||||
.outline-detail-row__top {
|
||||
align-items: flex-start;
|
||||
display: flex;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.outline-slide-card__number {
|
||||
align-items: center;
|
||||
background: #1f2933;
|
||||
border-radius: 999px;
|
||||
color: var(--surface);
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
height: 28px;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
}
|
||||
|
||||
.outline-slide-card__number--large {
|
||||
height: 34px;
|
||||
width: 34px;
|
||||
}
|
||||
|
||||
.outline-slide-card__heading,
|
||||
.outline-detail-row__heading {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.outline-slide-card__heading h5,
|
||||
.outline-detail-row__heading h5 {
|
||||
color: var(--text-primary);
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.35;
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.outline-slide-card__heading p,
|
||||
.outline-detail-row__heading p {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.82rem;
|
||||
margin: 4px 0 0 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.outline-slide-card__excerpt {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.84rem;
|
||||
line-height: 1.5;
|
||||
margin: var(--spacing-sm) 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.outline-slide-card__meta,
|
||||
.outline-detail-row__chips {
|
||||
color: var(--text-muted);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
font-size: 0.76rem;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.outline-slide-card__meta span,
|
||||
.outline-detail-row__chips span {
|
||||
align-items: center;
|
||||
background: var(--surface-subtle);
|
||||
border-radius: 999px;
|
||||
display: inline-flex;
|
||||
gap: 5px;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.outline-view--detail {
|
||||
display: none;
|
||||
max-height: 560px;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.outline-detail-row {
|
||||
margin-bottom: var(--spacing-md);
|
||||
padding: var(--spacing-md);
|
||||
}
|
||||
|
||||
.outline-detail-row__top {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.outline-detail-row__heading {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.outline-detail-row__actions {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.outline-detail-row__content {
|
||||
background: var(--surface-subtle);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 8px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.6;
|
||||
margin-top: var(--spacing-md);
|
||||
padding: var(--spacing-md);
|
||||
}
|
||||
|
||||
.outline-detail-row__content h6 {
|
||||
color: var(--text-primary);
|
||||
font-size: 0.82rem;
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.outline-detail-row__content ul {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
}
|
||||
|
||||
.outline-detail-row__content li {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.outline-detail-row__content p {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.outline-detail-row__chips {
|
||||
margin-top: var(--spacing-sm);
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.outline-panel__header,
|
||||
.outline-summary,
|
||||
.outline-detail-row__top {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.outline-toolbar,
|
||||
.outline-summary__meta,
|
||||
.outline-detail-row__actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.outline-surface {
|
||||
padding: var(--spacing-md);
|
||||
}
|
||||
|
||||
.outline-card-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
font-size: 0.8rem;
|
||||
padding: 6px 12px;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const projectId = '{{ project.project_id }}';
|
||||
const projectOutline = {{ project.outline | tojson if project.outline else 'null' }};
|
||||
const projectSlides = {{ project.outline.get('slides', []) | tojson if project.outline else '[]' }};
|
||||
let projectOutline = {{ project.outline | tojson if project.outline else 'null' }};
|
||||
let projectSlides = {{ project.outline.get('slides', []) | tojson if project.outline else '[]' }};
|
||||
|
||||
async function archiveProject() {
|
||||
if (confirm('确定要归档这个项目吗?归档后项目将不会显示在活跃项目列表中。')) {
|
||||
@@ -25,6 +25,25 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function duplicateProject() {
|
||||
try {
|
||||
const response = await fetch(`/api/projects/${projectId}/duplicate`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
});
|
||||
const result = await response.json();
|
||||
if (!response.ok || result.status !== 'success') {
|
||||
throw new Error(result.detail || result.message || 'Duplicate failed');
|
||||
}
|
||||
window.location.href = `/projects/${result.project_id}`;
|
||||
} catch (error) {
|
||||
console.error('Error duplicating project:', error);
|
||||
alert('复制项目失败: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteProject() {
|
||||
if (confirm('确定要删除这个项目吗?此操作不可撤销!')) {
|
||||
if (confirm('请再次确认删除操作,所有数据将永久丢失!')) {
|
||||
@@ -66,22 +85,131 @@
|
||||
// 大纲视图切换
|
||||
let isDetailView = false;
|
||||
|
||||
function setOutlineToggleState(showDetailView) {
|
||||
const toggleBtn = document.getElementById('outlineViewToggleBtn');
|
||||
const toggleIcon = toggleBtn ? toggleBtn.querySelector('i') : null;
|
||||
if (!toggleBtn || !toggleIcon) return;
|
||||
|
||||
if (showDetailView) {
|
||||
toggleIcon.className = 'fas fa-th-large';
|
||||
toggleBtn.setAttribute('aria-label', '切换为简洁视图');
|
||||
toggleBtn.setAttribute('data-tooltip', '简洁视图');
|
||||
} else {
|
||||
toggleIcon.className = 'fas fa-list';
|
||||
toggleBtn.setAttribute('aria-label', '切换为详细视图');
|
||||
toggleBtn.setAttribute('data-tooltip', '详细视图');
|
||||
}
|
||||
}
|
||||
|
||||
function toggleOutlineView() {
|
||||
const compactView = document.getElementById('compactView');
|
||||
const detailView = document.getElementById('detailView');
|
||||
const toggleText = document.getElementById('viewToggleText');
|
||||
if (!compactView || !detailView) return;
|
||||
|
||||
if (isDetailView) {
|
||||
compactView.style.display = 'block';
|
||||
detailView.style.display = 'none';
|
||||
toggleText.textContent = '详细视图';
|
||||
isDetailView = false;
|
||||
} else {
|
||||
compactView.style.display = 'none';
|
||||
detailView.style.display = 'block';
|
||||
toggleText.textContent = '简洁视图';
|
||||
isDetailView = true;
|
||||
}
|
||||
setOutlineToggleState(isDetailView);
|
||||
}
|
||||
|
||||
function renumberProjectOutlineSlides() {
|
||||
if (!projectOutline || !Array.isArray(projectOutline.slides)) return;
|
||||
projectOutline.slides.forEach((slide, index) => {
|
||||
if (slide && typeof slide === 'object') {
|
||||
slide.page_number = index + 1;
|
||||
}
|
||||
});
|
||||
projectSlides = projectOutline.slides;
|
||||
}
|
||||
|
||||
async function persistProjectOutline(operation) {
|
||||
renumberProjectOutlineSlides();
|
||||
const response = await fetch(`/projects/${projectId}/update-outline`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
outline_content: JSON.stringify(projectOutline, null, 2),
|
||||
operation
|
||||
})
|
||||
});
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({}));
|
||||
throw new Error(error.detail || 'Outline update failed');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteOutlineSlide(slideIndex) {
|
||||
if (!projectOutline || !Array.isArray(projectOutline.slides)) return;
|
||||
if (projectOutline.slides.length <= 1) {
|
||||
alert('至少保留一页幻灯片');
|
||||
return;
|
||||
}
|
||||
if (!confirm(`确定删除第 ${slideIndex + 1} 页大纲吗?对应幻灯片也会同步删除。`)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
projectOutline.slides.splice(slideIndex, 1);
|
||||
await persistProjectOutline({ type: 'delete', slide_index: slideIndex });
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
alert('删除大纲失败: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function moveOutlineSlide(fromIndex, toIndex) {
|
||||
if (!projectOutline || !Array.isArray(projectOutline.slides)) return;
|
||||
if (fromIndex === toIndex) return;
|
||||
if (fromIndex < 0 || fromIndex >= projectOutline.slides.length) return;
|
||||
if (toIndex < 0 || toIndex >= projectOutline.slides.length) return;
|
||||
|
||||
const moved = projectOutline.slides.splice(fromIndex, 1)[0];
|
||||
projectOutline.slides.splice(toIndex, 0, moved);
|
||||
|
||||
try {
|
||||
await persistProjectOutline({ type: 'move', slide_index: fromIndex, to_index: toIndex });
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
alert('调整大纲顺序失败: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
function initializeOutlineDragAndDrop() {
|
||||
document.querySelectorAll('[data-outline-slide-index]').forEach((element) => {
|
||||
element.addEventListener('dragstart', (event) => {
|
||||
event.dataTransfer.effectAllowed = 'move';
|
||||
event.dataTransfer.setData('text/plain', element.dataset.outlineSlideIndex);
|
||||
});
|
||||
element.addEventListener('dragover', (event) => {
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = 'move';
|
||||
element.classList.add('is-drag-over');
|
||||
});
|
||||
element.addEventListener('dragleave', () => {
|
||||
element.classList.remove('is-drag-over');
|
||||
});
|
||||
element.addEventListener('drop', (event) => {
|
||||
event.preventDefault();
|
||||
element.classList.remove('is-drag-over');
|
||||
const fromIndex = Number.parseInt(event.dataTransfer.getData('text/plain'), 10);
|
||||
const toIndex = Number.parseInt(element.dataset.outlineSlideIndex, 10);
|
||||
if (Number.isInteger(fromIndex) && Number.isInteger(toIndex)) {
|
||||
moveOutlineSlide(fromIndex, toIndex);
|
||||
}
|
||||
});
|
||||
element.addEventListener('dragend', () => {
|
||||
document.querySelectorAll('.is-drag-over').forEach((dropTarget) => {
|
||||
dropTarget.classList.remove('is-drag-over');
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 查看幻灯片详情
|
||||
@@ -1107,3 +1235,9 @@
|
||||
alert('AI优化失败: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
if (typeof initializeOutlineDragAndDrop === 'function') {
|
||||
initializeOutlineDragAndDrop();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -103,41 +103,44 @@
|
||||
}
|
||||
|
||||
.action-button {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 9px 16px;
|
||||
border-radius: 10px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
padding: 0;
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
transition: all 0.2s ease;
|
||||
font-size: 0.95rem;
|
||||
transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease;
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
color: #475569;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.action-button.primary {
|
||||
background: #111;
|
||||
color: #fff;
|
||||
border-color: #111;
|
||||
background: transparent;
|
||||
color: #111827;
|
||||
border-color: transparent;
|
||||
}
|
||||
.action-button.primary:hover {
|
||||
background: #333;
|
||||
border-color: #333;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,0.1);
|
||||
background: #f8fafc;
|
||||
border-color: #e2e8f0;
|
||||
}
|
||||
|
||||
.action-button.secondary {
|
||||
background: #fff;
|
||||
color: var(--text-primary);
|
||||
border-color: var(--border-color, #e5e7eb);
|
||||
background: transparent;
|
||||
color: #475569;
|
||||
border-color: transparent;
|
||||
}
|
||||
.action-button.secondary:hover {
|
||||
background: #f8fafc;
|
||||
border-color: #94a3b8;
|
||||
transform: translateY(-1px);
|
||||
border-color: #e2e8f0;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
/* 最近项目 */
|
||||
@@ -160,7 +163,7 @@
|
||||
border: 1px solid var(--border-color, #e5e7eb);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.04);
|
||||
overflow: hidden;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.project-list-item {
|
||||
@@ -170,6 +173,7 @@
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
transition: background 0.15s ease;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.project-list-item:last-child {
|
||||
@@ -191,6 +195,7 @@
|
||||
.project-list-item.completed .project-status-indicator { background: #16a34a; }
|
||||
.project-list-item.in_progress .project-status-indicator { background: #2563eb; }
|
||||
.project-list-item.draft .project-status-indicator { background: #d97706; }
|
||||
.project-list-item.archived .project-status-indicator { background: #94a3b8; }
|
||||
|
||||
.project-info {
|
||||
flex: 1;
|
||||
@@ -244,6 +249,12 @@
|
||||
border-color: #fde68a;
|
||||
}
|
||||
|
||||
.project-status-badge[data-status="archived"] {
|
||||
background: #f1f5f9;
|
||||
color: #64748b;
|
||||
border-color: #e2e8f0;
|
||||
}
|
||||
|
||||
.project-meta {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
@@ -266,41 +277,49 @@
|
||||
}
|
||||
|
||||
.project-action-btn {
|
||||
padding: 5px 10px;
|
||||
position: relative;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
padding: 0;
|
||||
border-radius: 8px;
|
||||
font-size: 0.75rem;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
transition: all 0.15s ease;
|
||||
border: 1px solid var(--border-color, #e5e7eb);
|
||||
transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease;
|
||||
border: 1px solid transparent;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
background: #fff;
|
||||
color: var(--text-primary);
|
||||
background: transparent;
|
||||
color: #475569;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.project-action-btn.primary {
|
||||
background: #111;
|
||||
border-color: #111;
|
||||
color: #fff;
|
||||
background: transparent;
|
||||
border-color: transparent;
|
||||
color: #111827;
|
||||
}
|
||||
.project-action-btn.primary:hover {
|
||||
background: #f8fafc;
|
||||
border-color: #e2e8f0;
|
||||
}
|
||||
.project-action-btn.primary:hover { background: #333; }
|
||||
|
||||
.project-action-btn.secondary {
|
||||
background: #fff;
|
||||
border-color: var(--border-color, #e5e7eb);
|
||||
color: var(--text-primary);
|
||||
background: transparent;
|
||||
border-color: transparent;
|
||||
color: #475569;
|
||||
}
|
||||
.project-action-btn.secondary:hover {
|
||||
background: #f8fafc;
|
||||
border-color: #94a3b8;
|
||||
border-color: #e2e8f0;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.project-action-btn.destructive {
|
||||
background: #fff;
|
||||
border-color: var(--border-color, #e5e7eb);
|
||||
background: transparent;
|
||||
border-color: transparent;
|
||||
color: #dc2626;
|
||||
}
|
||||
.project-action-btn.destructive:hover {
|
||||
@@ -308,6 +327,62 @@
|
||||
border-color: #fecaca;
|
||||
}
|
||||
|
||||
.action-button:focus-visible,
|
||||
.project-action-btn:focus-visible {
|
||||
outline: none;
|
||||
border-color: #94a3b8;
|
||||
box-shadow: 0 0 0 3px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.action-button[data-tooltip]::after,
|
||||
.action-button[data-tooltip]::before,
|
||||
.project-action-btn[data-tooltip]::after,
|
||||
.project-action-btn[data-tooltip]::before {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.12s ease, transform 0.12s ease;
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.action-button[data-tooltip]::after,
|
||||
.project-action-btn[data-tooltip]::after {
|
||||
content: attr(data-tooltip);
|
||||
bottom: calc(100% + 8px);
|
||||
transform: translate(-50%, 4px);
|
||||
padding: 5px 8px;
|
||||
border-radius: 6px;
|
||||
background: #111827;
|
||||
color: #fff;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.16);
|
||||
}
|
||||
|
||||
.action-button[data-tooltip]::before,
|
||||
.project-action-btn[data-tooltip]::before {
|
||||
content: "";
|
||||
bottom: calc(100% + 3px);
|
||||
transform: translate(-50%, 4px);
|
||||
border: 5px solid transparent;
|
||||
border-top-color: #111827;
|
||||
}
|
||||
|
||||
.action-button[data-tooltip]:hover::after,
|
||||
.action-button[data-tooltip]:hover::before,
|
||||
.action-button[data-tooltip]:focus-visible::after,
|
||||
.action-button[data-tooltip]:focus-visible::before,
|
||||
.project-action-btn[data-tooltip]:hover::after,
|
||||
.project-action-btn[data-tooltip]:hover::before,
|
||||
.project-action-btn[data-tooltip]:focus-visible::after,
|
||||
.project-action-btn[data-tooltip]:focus-visible::before {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, 0);
|
||||
}
|
||||
|
||||
/* 空状态 */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
@@ -363,22 +438,46 @@
|
||||
/* 模态框 */
|
||||
.modal-surface {
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-color, #e5e7eb);
|
||||
box-shadow: 0 20px 25px -5px rgba(0,0,0,0.08), 0 8px 10px -6px rgba(0,0,0,0.04);
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
text-align: center;
|
||||
padding: 32px;
|
||||
text-align: left;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: center;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.modal-input {
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
border: 1px solid var(--border-color, #e5e7eb);
|
||||
border-radius: 8px;
|
||||
padding: 0 12px;
|
||||
font: inherit;
|
||||
color: var(--text-primary);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.modal-input:focus {
|
||||
border-color: #111827;
|
||||
box-shadow: 0 0 0 3px rgba(17, 24, 39, 0.08);
|
||||
}
|
||||
|
||||
.modal-error {
|
||||
min-height: 18px;
|
||||
margin-top: 6px;
|
||||
color: #dc2626;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
@@ -432,7 +531,182 @@
|
||||
}
|
||||
.btn-danger:hover { background: #dc2626; }
|
||||
|
||||
/* 统一项目工作台视觉 */
|
||||
.dashboard-header,
|
||||
.stat-card,
|
||||
.recent-projects-list,
|
||||
.empty-state,
|
||||
.todo-card {
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
|
||||
.dashboard-header {
|
||||
padding: 16px 18px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--border-color, #e5e7eb);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.recent-projects-header,
|
||||
.todo-section h3 {
|
||||
margin: 0 0 10px;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.recent-projects-list {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.project-list-item {
|
||||
display: grid;
|
||||
grid-template-columns: 4px minmax(260px, 1fr) 160px 230px;
|
||||
gap: 16px;
|
||||
min-height: 76px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.project-status-indicator {
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
.project-header {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.project-title-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.project-title-wrap .project-title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.project-title-icon {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 8px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 30px;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.project-meta {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.project-progress-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.project-progress-track {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
background: #f1f5f9;
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.project-progress-fill {
|
||||
height: 100%;
|
||||
background: #111827;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.project-progress-label {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.project-actions {
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.project-action-btn i,
|
||||
.action-button i,
|
||||
.btn i {
|
||||
font-size: 0.92rem;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.todo-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.todo-card {
|
||||
background: #fff;
|
||||
border: 1px solid var(--border-color, #e5e7eb);
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.todo-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.todo-card h4 {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.todo-progress-value {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.todo-card-meta {
|
||||
margin: 12px 0 16px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* 响应式 */
|
||||
@media (max-width: 1180px) {
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.project-list-item {
|
||||
grid-template-columns: 4px minmax(240px, 1fr) 150px;
|
||||
}
|
||||
|
||||
.project-actions {
|
||||
grid-column: 2 / 4;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
@@ -449,11 +723,16 @@
|
||||
|
||||
.project-list-item {
|
||||
padding: 12px 16px;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
grid-template-columns: 4px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.project-progress-cell,
|
||||
.project-actions {
|
||||
grid-column: 2;
|
||||
}
|
||||
|
||||
.project-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
@@ -482,11 +761,11 @@
|
||||
<p>管理您的 PPT 项目,跟踪进度</p>
|
||||
</div>
|
||||
<div class="action-buttons">
|
||||
<a href="/scenarios" class="action-button primary">
|
||||
<i class="fas fa-plus"></i> 创建新项目
|
||||
<a href="/scenarios" class="action-button primary" aria-label="创建新项目" data-tooltip="创建新项目">
|
||||
<i class="fas fa-plus"></i>
|
||||
</a>
|
||||
<a href="/projects" class="action-button secondary">
|
||||
<i class="fas fa-list"></i> 所有项目
|
||||
<a href="/projects" class="action-button secondary" aria-label="所有项目" data-tooltip="所有项目">
|
||||
<i class="fas fa-list"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -533,11 +812,14 @@
|
||||
{% if recent_projects %}
|
||||
<div class="recent-projects-list">
|
||||
{% for project in recent_projects %}
|
||||
<div class="project-list-item {{ project.status }}">
|
||||
<div class="project-list-item {{ project.status }}" onclick="window.location.href='/projects/{{ project.project_id }}'">
|
||||
<div class="project-status-indicator"></div>
|
||||
<div class="project-info">
|
||||
<div class="project-header">
|
||||
<h4 class="project-title">{{ project.title }}</h4>
|
||||
<div class="project-title-wrap">
|
||||
<span class="project-title-icon"><i class="fas fa-file-powerpoint" aria-hidden="true"></i></span>
|
||||
<h4 class="project-title">{{ project.title }}</h4>
|
||||
</div>
|
||||
<span class="project-status-badge" data-status="{{ project.status }}">
|
||||
{% if project.status == 'completed' %}
|
||||
已完成
|
||||
@@ -545,6 +827,8 @@
|
||||
进行中
|
||||
{% elif project.status == 'draft' %}
|
||||
草稿
|
||||
{% elif project.status == 'archived' %}
|
||||
已归档
|
||||
{% else %}
|
||||
异常状态
|
||||
{% endif %}
|
||||
@@ -557,29 +841,57 @@
|
||||
<span>场景: {{ project.scenario }}</span>
|
||||
</span>
|
||||
<span>
|
||||
<i class="fas fa-clock" aria-hidden="true"></i>
|
||||
<span>创建时间: {{ project.created_at | strftime('%Y-%m-%d %H:%M') }}</span>
|
||||
<i class="fas fa-calendar-alt" aria-hidden="true"></i>
|
||||
<span>更新: {{ project.updated_at | strftime('%m-%d %H:%M') }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="project-actions">
|
||||
<a href="/projects/{{ project.project_id }}" class="project-action-btn primary">
|
||||
<i class="fas fa-eye"></i> 查看详情
|
||||
</a>
|
||||
{% if project.status == 'completed' %}
|
||||
<a href="/projects/{{ project.project_id }}/edit" class="project-action-btn secondary">
|
||||
<i class="fas fa-pen"></i> 编辑
|
||||
</a>
|
||||
<a href="/projects/{{ project.project_id }}/fullscreen" class="project-action-btn secondary" target="_blank">
|
||||
<i class="fas fa-expand"></i> 预览
|
||||
</a>
|
||||
{% endif %}
|
||||
<button onclick="confirmDeleteProject('{{ project.project_id }}', '{{ project.title }}')"
|
||||
class="project-action-btn destructive"
|
||||
title="删除项目">
|
||||
<i class="fas fa-trash-alt"></i> 删除
|
||||
</button>
|
||||
</div>
|
||||
<div class="project-progress-cell">
|
||||
{% if project.status == 'in_progress' and project.todo_board %}
|
||||
<div class="project-progress-track">
|
||||
<div class="project-progress-fill" style="width: {{ project.todo_board.overall_progress }}%;"></div>
|
||||
</div>
|
||||
<span class="project-progress-label">{{ "%.0f" | format(project.todo_board.overall_progress) }}%</span>
|
||||
{% elif project.status == 'completed' %}
|
||||
<span class="project-progress-label">已完成</span>
|
||||
{% elif project.status == 'draft' %}
|
||||
<span class="project-progress-label">草稿</span>
|
||||
{% elif project.status == 'archived' %}
|
||||
<span class="project-progress-label">已归档</span>
|
||||
{% else %}
|
||||
<span class="project-progress-label">待处理</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="project-actions" onclick="event.stopPropagation();">
|
||||
<a href="/projects/{{ project.project_id }}" class="project-action-btn primary" aria-label="详情" data-tooltip="详情">
|
||||
<i class="fas fa-eye"></i>
|
||||
</a>
|
||||
{% if project.status == 'completed' %}
|
||||
<a href="/projects/{{ project.project_id }}/edit" class="project-action-btn secondary" aria-label="编辑" data-tooltip="编辑">
|
||||
<i class="fas fa-pen"></i>
|
||||
</a>
|
||||
<a href="/projects/{{ project.project_id }}/fullscreen" class="project-action-btn secondary" aria-label="预览" data-tooltip="预览" target="_blank">
|
||||
<i class="fas fa-expand"></i>
|
||||
</a>
|
||||
{% elif project.status == 'in_progress' %}
|
||||
<a href="/projects/{{ project.project_id }}/todo" class="project-action-btn secondary" aria-label="查看进度" data-tooltip="查看进度">
|
||||
<i class="fas fa-tasks"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
<button onclick='openRenameModal("{{ project.project_id }}", {{ project.title | tojson | safe }})'
|
||||
class="project-action-btn secondary"
|
||||
aria-label="重命名项目"
|
||||
data-tooltip="重命名">
|
||||
<i class="fas fa-i-cursor"></i>
|
||||
</button>
|
||||
<button onclick='confirmDeleteProject("{{ project.project_id }}", {{ project.title | tojson | safe }})'
|
||||
class="project-action-btn destructive"
|
||||
aria-label="删除项目"
|
||||
data-tooltip="删除">
|
||||
<i class="fas fa-trash-alt"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
@@ -588,7 +900,7 @@
|
||||
<div class="empty-state">
|
||||
<h3>暂无项目</h3>
|
||||
<p>开始创建您的第一个 PPT 项目</p>
|
||||
<a href="/scenarios" class="btn btn-primary">创建项目</a>
|
||||
<a href="/scenarios" class="project-action-btn primary" aria-label="创建项目" data-tooltip="创建项目"><i class="fas fa-plus"></i></a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -597,30 +909,27 @@
|
||||
<div class="todo-section">
|
||||
<h3><i class="fas fa-tasks"></i> 活跃的 TODO 看板</h3>
|
||||
|
||||
<div class="grid">
|
||||
<div class="todo-grid">
|
||||
{% for todo_board in active_todo_boards %}
|
||||
<div class="card" style="background: #fff; border: 1px solid var(--border-color, #e5e7eb); border-radius: 12px; padding: 20px;">
|
||||
<h4 style="margin-bottom: 12px; font-size: 0.95rem; font-weight: 600; color: var(--text-primary);">{{ todo_board.title }}</h4>
|
||||
<div class="todo-card">
|
||||
<div class="todo-card-header">
|
||||
<h4>{{ todo_board.title }}</h4>
|
||||
<span class="todo-progress-value">{{ "%.0f" | format(todo_board.overall_progress) }}%</span>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 12px;">
|
||||
<div>
|
||||
<div class="progress-track" data-progress="{{ todo_board.overall_progress }}">
|
||||
<span></span>
|
||||
</div>
|
||||
<p style="text-align: center; margin-top: 6px; color: var(--text-muted); font-size: 0.82rem;">
|
||||
进度: {{ "%.1f" | format(todo_board.overall_progress) }}%
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 16px; color: var(--text-secondary); font-size: 0.875rem;">
|
||||
<strong>当前阶段:</strong>
|
||||
<span>{{ todo_board.stages[todo_board.current_stage_index].name }}</span>
|
||||
<div class="todo-card-meta">
|
||||
当前阶段: {{ todo_board.stages[todo_board.current_stage_index].name }}
|
||||
</div>
|
||||
|
||||
<div style="text-align: center;">
|
||||
<a href="/projects/{{ todo_board.task_id }}/todo" class="btn btn-primary">
|
||||
查看看板
|
||||
</a>
|
||||
</div>
|
||||
<a href="/projects/{{ todo_board.task_id }}/todo" class="project-action-btn primary" aria-label="查看看板" data-tooltip="查看看板">
|
||||
<i class="fas fa-tasks"></i>
|
||||
</a>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
@@ -640,8 +949,22 @@
|
||||
此操作不可撤销,将永久删除项目及其相关数据。
|
||||
</p>
|
||||
<div class="modal-actions">
|
||||
<button onclick="closeDeleteModal()" class="btn btn-secondary">取消</button>
|
||||
<button onclick="executeDeleteProject()" class="btn btn-danger">确认删除</button>
|
||||
<button onclick="closeDeleteModal()" class="project-action-btn secondary" aria-label="取消" data-tooltip="取消"><i class="fas fa-times"></i></button>
|
||||
<button onclick="executeDeleteProject()" class="project-action-btn destructive" aria-label="确认删除" data-tooltip="确认删除"><i class="fas fa-trash-alt"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 重命名项目 Modal -->
|
||||
<div id="renameModal" style="display: none; position: fixed; inset: 0; background: rgba(15, 23, 42, 0.4); backdrop-filter: blur(4px); z-index: 1000; align-items: center; justify-content: center; padding: 24px;">
|
||||
<div class="modal-surface">
|
||||
<h3 style="color: var(--text-primary); margin-bottom: 8px; font-size: 1.05rem; font-weight: 700;">重命名项目</h3>
|
||||
<p style="color: var(--text-secondary); margin-bottom: 14px; font-size: 0.875rem;">请输入新的项目名称,保存后会同步到项目列表和仪表板。</p>
|
||||
<input id="renameProjectTitle" class="modal-input" type="text" maxlength="255" autocomplete="off" aria-label="项目名称">
|
||||
<div id="renameProjectError" class="modal-error"></div>
|
||||
<div class="modal-actions" style="margin-top: 16px;">
|
||||
<button onclick="closeRenameModal()" class="project-action-btn secondary" aria-label="取消" data-tooltip="取消"><i class="fas fa-times"></i></button>
|
||||
<button onclick="executeRenameProject()" class="project-action-btn primary" aria-label="保存" data-tooltip="保存"><i class="fas fa-check"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -649,7 +972,7 @@
|
||||
<div id="loadingOverlay" style="display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(15, 23, 42, 0.4); backdrop-filter: blur(4px); z-index: 1001; align-items: center; justify-content: center; padding: 24px;">
|
||||
<div class="modal-surface" style="max-width: 360px;">
|
||||
<div class="spinner" style="margin-bottom: 16px;"></div>
|
||||
<p style="color: var(--text-primary); font-weight: 600; font-size: 0.875rem;">正在删除项目...</p>
|
||||
<p id="loadingOverlayMessage" style="color: var(--text-primary); font-weight: 600; font-size: 0.875rem; text-align: center;">正在处理...</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -657,6 +980,7 @@
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
let currentDeleteProjectId = null;
|
||||
let currentRenameProjectId = null;
|
||||
|
||||
function confirmDeleteProject(projectId, projectTitle) {
|
||||
currentDeleteProjectId = projectId;
|
||||
@@ -672,11 +996,78 @@ function closeDeleteModal() {
|
||||
currentDeleteProjectId = null;
|
||||
}
|
||||
|
||||
function openRenameModal(projectId, projectTitle) {
|
||||
currentRenameProjectId = projectId;
|
||||
const input = document.getElementById('renameProjectTitle');
|
||||
const error = document.getElementById('renameProjectError');
|
||||
if (input) input.value = projectTitle || '';
|
||||
if (error) error.textContent = '';
|
||||
const modalElement = document.getElementById('renameModal');
|
||||
if (modalElement) modalElement.style.display = 'flex';
|
||||
setTimeout(() => {
|
||||
input?.focus();
|
||||
input?.select();
|
||||
}, 50);
|
||||
}
|
||||
|
||||
function closeRenameModal() {
|
||||
const modalElement = document.getElementById('renameModal');
|
||||
if (modalElement) modalElement.style.display = 'none';
|
||||
currentRenameProjectId = null;
|
||||
}
|
||||
|
||||
function setLoadingOverlay(visible, message = '正在处理...') {
|
||||
const overlay = document.getElementById('loadingOverlay');
|
||||
const messageElement = document.getElementById('loadingOverlayMessage');
|
||||
if (messageElement) messageElement.textContent = message;
|
||||
if (overlay) overlay.style.display = visible ? 'flex' : 'none';
|
||||
}
|
||||
|
||||
async function executeRenameProject() {
|
||||
if (!currentRenameProjectId) return;
|
||||
|
||||
const projectId = currentRenameProjectId;
|
||||
const input = document.getElementById('renameProjectTitle');
|
||||
const error = document.getElementById('renameProjectError');
|
||||
const title = (input?.value || '').trim();
|
||||
|
||||
if (!title) {
|
||||
if (error) error.textContent = '项目名称不能为空';
|
||||
input?.focus();
|
||||
return;
|
||||
}
|
||||
if (title.length > 255) {
|
||||
if (error) error.textContent = '项目名称不能超过 255 个字符';
|
||||
input?.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
closeRenameModal();
|
||||
setLoadingOverlay(true, '正在重命名项目...');
|
||||
try {
|
||||
const response = await fetch(`/api/projects/${projectId}/rename`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title })
|
||||
});
|
||||
const result = await response.json().catch(() => ({}));
|
||||
if (!response.ok || result.status !== 'success') {
|
||||
throw new Error(result.detail || result.message || '重命名失败');
|
||||
}
|
||||
showNotification('项目已重命名', 'success');
|
||||
setTimeout(() => window.location.reload(), 600);
|
||||
} catch (error) {
|
||||
showNotification('重命名失败:' + error.message, 'error');
|
||||
setLoadingOverlay(false);
|
||||
}
|
||||
currentRenameProjectId = null;
|
||||
}
|
||||
|
||||
async function executeDeleteProject() {
|
||||
if (!currentDeleteProjectId) return;
|
||||
|
||||
document.getElementById('deleteModal').style.display = 'none';
|
||||
document.getElementById('loadingOverlay').style.display = 'flex';
|
||||
setLoadingOverlay(true, '正在删除项目...');
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/database/projects/${currentDeleteProjectId}`, {
|
||||
@@ -692,7 +1083,7 @@ async function executeDeleteProject() {
|
||||
}
|
||||
} catch (error) {
|
||||
showNotification('删除失败:' + error.message, 'error');
|
||||
document.getElementById('loadingOverlay').style.display = 'none';
|
||||
setLoadingOverlay(false);
|
||||
}
|
||||
currentDeleteProjectId = null;
|
||||
}
|
||||
@@ -720,8 +1111,17 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
document.getElementById('deleteModal')?.addEventListener('click', function(e) {
|
||||
if (e.target === this) closeDeleteModal();
|
||||
});
|
||||
document.getElementById('renameModal')?.addEventListener('click', function(e) {
|
||||
if (e.target === this) closeRenameModal();
|
||||
});
|
||||
document.getElementById('renameProjectTitle')?.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter') executeRenameProject();
|
||||
});
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') closeDeleteModal();
|
||||
if (e.key === 'Escape') {
|
||||
closeDeleteModal();
|
||||
closeRenameModal();
|
||||
}
|
||||
});
|
||||
|
||||
document.querySelectorAll('.progress-track[data-progress]').forEach(track => {
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.2/theme/dracula.min.css">
|
||||
<!-- CodeMirror Search Dialog CSS -->
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.2/addon/dialog/dialog.min.css">
|
||||
<link rel="stylesheet" href="/static/css/pages/project/slides_editor/projectSlidesEditor.css?v=20260408-split-preview-fix-v1">
|
||||
<link rel="stylesheet" href="/static/css/pages/project/slides_editor/projectSlidesEditor.css?v=20260613-thumbnail-fill-v1">
|
||||
<link rel="stylesheet" href="/static/css/pages/project/slides_editor/projectSlidesEditor.aiSidebar.css?v=20260402-split">
|
||||
<link rel="stylesheet" href="/static/css/pages/project/slides_editor/projectSlidesEditor.nativeChat.css?v=20260402-split">
|
||||
<link rel="stylesheet" href="/static/css/pages/project/slides_editor/projectSlidesEditor.quickEdit.css?v=20260402-split">
|
||||
@@ -734,7 +734,7 @@
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="/static/js/dom-to-pptx.bundle.js?v=20260425-layer-clip-v21"></script>
|
||||
<script src="/static/js/pages/project/slides_editor/projectEditorShareExport.js?v=20260328-phase3"></script>
|
||||
<script src="/static/js/pages/project/slides_editor/projectSlidesEditor.core.js?v=20260408-split-preview-fix-v1"></script>
|
||||
<script src="/static/js/pages/project/slides_editor/projectSlidesEditor.core.js?v=20260613-thumbnail-fill-v1"></script>
|
||||
<script src="/static/js/pages/project/slides_editor/projectSlidesEditor.quickEdit.js?v=20260408-quick-reset-v2"></script>
|
||||
<script src="/static/js/pages/project/slides_editor/projectSlidesEditor.quickAi.js?v=20260402-split"></script>
|
||||
<script src="/static/js/pages/project/slides_editor/projectSlidesEditor.quickEditActions.js?v=20260408-quick-reset-v2"></script>
|
||||
@@ -748,7 +748,7 @@
|
||||
<script src="/static/js/pages/project/slides_editor/projectSlidesEditor.slideGeneration.js?v=20260408-quick-reset-v2"></script>
|
||||
<script src="/static/js/pages/project/slides_editor/projectSlidesEditor.changeTemplate.js?v=20260408-free-template-list-v2"></script>
|
||||
<script src="/static/js/pages/project/slides_editor/projectSlidesEditor.slideshow.js?v=20260402-split"></script>
|
||||
<script src="/static/js/pages/project/slides_editor/projectSlidesEditor.slideCrud.js?v=20260402-split"></script>
|
||||
<script src="/static/js/pages/project/slides_editor/projectSlidesEditor.slideCrud.js?v=20260613-thumbnail-fill-v1"></script>
|
||||
<script src="/static/js/pages/project/slides_editor/projectSlidesEditor.imageSelection.js?v=20260402-split"></script>
|
||||
|
||||
<!-- CodeMirror JavaScript -->
|
||||
|
||||
@@ -139,43 +139,95 @@
|
||||
.btn-sm { padding: 5px 10px; font-size: 0.75rem; border-radius: 8px; min-height: 30px; }
|
||||
|
||||
.action-btn {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 5px 12px;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
padding: 0;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-color, #e5e7eb);
|
||||
background: #fff;
|
||||
color: var(--text-primary);
|
||||
font-size: 0.75rem;
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: #475569;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
transition: all 0.15s ease;
|
||||
transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background: #f8fafc;
|
||||
border-color: #e2e8f0;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.action-btn:focus-visible {
|
||||
outline: none;
|
||||
border-color: #94a3b8;
|
||||
box-shadow: 0 0 0 3px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.action-btn--primary {
|
||||
background: #111;
|
||||
border-color: #111;
|
||||
color: #fff;
|
||||
background: transparent;
|
||||
border-color: transparent;
|
||||
color: #111827;
|
||||
}
|
||||
.action-btn--primary:hover { background: #333; }
|
||||
.action-btn--primary:hover { background: #f8fafc; }
|
||||
|
||||
.action-btn--danger {
|
||||
color: #dc2626;
|
||||
border-color: var(--border-color, #e5e7eb);
|
||||
border-color: transparent;
|
||||
}
|
||||
.action-btn--danger:hover {
|
||||
background: #fef2f2;
|
||||
border-color: #fecaca;
|
||||
}
|
||||
|
||||
.action-btn[data-tooltip]::after,
|
||||
.action-btn[data-tooltip]::before {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.12s ease, transform 0.12s ease;
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.action-btn[data-tooltip]::after {
|
||||
content: attr(data-tooltip);
|
||||
bottom: calc(100% + 8px);
|
||||
transform: translate(-50%, 4px);
|
||||
padding: 5px 8px;
|
||||
border-radius: 6px;
|
||||
background: #111827;
|
||||
color: #fff;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.16);
|
||||
}
|
||||
|
||||
.action-btn[data-tooltip]::before {
|
||||
content: "";
|
||||
bottom: calc(100% + 3px);
|
||||
transform: translate(-50%, 4px);
|
||||
border: 5px solid transparent;
|
||||
border-top-color: #111827;
|
||||
}
|
||||
|
||||
.action-btn[data-tooltip]:hover::after,
|
||||
.action-btn[data-tooltip]:hover::before,
|
||||
.action-btn[data-tooltip]:focus-visible::after,
|
||||
.action-btn[data-tooltip]:focus-visible::before {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, 0);
|
||||
}
|
||||
|
||||
/* 表格 */
|
||||
.projects-table-wrapper {
|
||||
margin-bottom: 24px;
|
||||
@@ -185,7 +237,7 @@
|
||||
background: #fff;
|
||||
border: 1px solid var(--border-color, #e5e7eb);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
overflow: visible;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.04);
|
||||
}
|
||||
|
||||
@@ -208,6 +260,7 @@
|
||||
.projects-table-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.project-list-item {
|
||||
@@ -219,6 +272,7 @@
|
||||
transition: background 0.15s ease;
|
||||
cursor: pointer;
|
||||
align-items: center;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.project-list-item:hover { background: #f8fafc; }
|
||||
@@ -404,12 +458,43 @@
|
||||
.modal-surface {
|
||||
background: #fff;
|
||||
border: 1px solid var(--border-color, #e5e7eb);
|
||||
border-radius: 16px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 20px 25px -5px rgba(0,0,0,0.08);
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
text-align: center;
|
||||
padding: 32px;
|
||||
text-align: left;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.modal-input {
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
border: 1px solid var(--border-color, #e5e7eb);
|
||||
border-radius: 8px;
|
||||
padding: 0 12px;
|
||||
font: inherit;
|
||||
color: var(--text-primary);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.modal-input:focus {
|
||||
border-color: #111827;
|
||||
box-shadow: 0 0 0 3px rgba(17, 24, 39, 0.08);
|
||||
}
|
||||
|
||||
.modal-error {
|
||||
min-height: 18px;
|
||||
margin-top: 6px;
|
||||
color: #dc2626;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
@@ -427,17 +512,78 @@
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* 统一项目工作台视觉 */
|
||||
.projects-header,
|
||||
.projects-toolbar,
|
||||
.projects-table,
|
||||
.empty-state,
|
||||
.pagination-bar {
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
|
||||
.projects-header {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto 16px;
|
||||
padding: 16px 18px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--border-color, #e5e7eb);
|
||||
}
|
||||
|
||||
.projects-table-header,
|
||||
.project-list-item {
|
||||
grid-template-columns: 116px minmax(240px, 1fr) 120px 170px 130px 198px;
|
||||
}
|
||||
|
||||
.project-list-item {
|
||||
min-height: 72px;
|
||||
}
|
||||
|
||||
.project-title-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.project-title-icon {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 8px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 30px;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.project-title-copy {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.action-btn i,
|
||||
.btn i {
|
||||
font-size: 0.92rem;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.table-action-group {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* 响应式 */
|
||||
@media (max-width: 1024px) {
|
||||
.projects-table-header,
|
||||
.project-list-item {
|
||||
grid-template-columns: 100px 1fr 120px 150px;
|
||||
grid-template-columns: 100px minmax(220px, 1fr) 130px 180px;
|
||||
}
|
||||
|
||||
.projects-table-header > div:nth-child(3),
|
||||
.projects-table-header > div:nth-child(5),
|
||||
.projects-table-header > div:nth-child(6),
|
||||
.project-list-item > div:nth-child(5),
|
||||
.project-list-item > div:nth-child(6) {
|
||||
.project-list-item > div:nth-child(3),
|
||||
.project-list-item > div:nth-child(5) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -462,6 +608,11 @@
|
||||
|
||||
.table-action-group { justify-content: flex-start; }
|
||||
|
||||
.project-list-item > div:nth-child(3),
|
||||
.project-list-item > div:nth-child(5) {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.projects-header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
@@ -478,8 +629,8 @@
|
||||
<p>管理您的所有 PPT 项目</p>
|
||||
</div>
|
||||
<div class="projects-header-actions">
|
||||
<a href="/scenarios" class="action-btn action-btn--primary"><i class="fas fa-plus"></i> 创建新项目</a>
|
||||
<a href="/dashboard" class="action-btn"><i class="fas fa-tachometer-alt"></i> 仪表板</a>
|
||||
<a href="/scenarios" class="action-btn action-btn--primary" aria-label="创建新项目" data-tooltip="创建新项目"><i class="fas fa-plus"></i></a>
|
||||
<a href="/dashboard" class="action-btn" aria-label="仪表板" data-tooltip="仪表板"><i class="fas fa-tachometer-alt"></i></a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -539,9 +690,12 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div style="display: flex; flex-direction: column; justify-content: center; min-width: 0;">
|
||||
<h4 class="project-title">{{ project.title }}</h4>
|
||||
<p class="project-topic" style="margin: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">{{ project.topic }}</p>
|
||||
<div class="project-title-cell">
|
||||
<span class="project-title-icon"><i class="fas fa-file-powerpoint" aria-hidden="true"></i></span>
|
||||
<div class="project-title-copy">
|
||||
<h4 class="project-title">{{ project.title }}</h4>
|
||||
<p class="project-topic" style="margin: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">{{ project.topic }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="project-meta" style="display: flex; align-items: center;">
|
||||
@@ -573,12 +727,14 @@
|
||||
|
||||
<div class="table-action-group" onclick="event.stopPropagation();">
|
||||
{% if project.status == 'completed' %}
|
||||
<a href="/projects/{{ project.project_id }}/edit" class="action-btn" title="编辑">编辑</a>
|
||||
<a href="/projects/{{ project.project_id }}/fullscreen" class="action-btn" title="预览" target="_blank">预览</a>
|
||||
<a href="/projects/{{ project.project_id }}/edit" class="action-btn" aria-label="编辑" data-tooltip="编辑"><i class="fas fa-pen"></i></a>
|
||||
<a href="/projects/{{ project.project_id }}/fullscreen" class="action-btn" aria-label="预览" data-tooltip="预览" target="_blank"><i class="fas fa-expand"></i></a>
|
||||
{% elif project.status == 'in_progress' %}
|
||||
<a href="/projects/{{ project.project_id }}/todo" class="action-btn" title="查看进度">进度</a>
|
||||
<a href="/projects/{{ project.project_id }}/todo" class="action-btn" aria-label="查看进度" data-tooltip="查看进度"><i class="fas fa-tasks"></i></a>
|
||||
{% endif %}
|
||||
<button onclick="event.stopPropagation(); confirmDeleteProject('{{ project.project_id }}', '{{ project.title }}')" class="action-btn action-btn--danger" title="删除">删除</button>
|
||||
<button onclick='event.stopPropagation(); openRenameModal("{{ project.project_id }}", {{ project.title | tojson | safe }})' class="action-btn" aria-label="重命名" data-tooltip="重命名"><i class="fas fa-i-cursor"></i></button>
|
||||
<button onclick="event.stopPropagation(); duplicateProject('{{ project.project_id }}')" class="action-btn" aria-label="复制" data-tooltip="复制"><i class="fas fa-copy"></i></button>
|
||||
<button onclick='event.stopPropagation(); confirmDeleteProject("{{ project.project_id }}", {{ project.title | tojson | safe }})' class="action-btn action-btn--danger" aria-label="删除" data-tooltip="删除"><i class="fas fa-trash-alt"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
@@ -590,13 +746,13 @@
|
||||
{% if total > page_size %}
|
||||
<div class="pagination-bar">
|
||||
{% if page > 1 %}
|
||||
<a href="?page={{ page - 1 }}{% if status_filter %}&status={{ status_filter }}{% endif %}" class="btn btn-secondary btn-sm">← 上一页</a>
|
||||
<a href="?page={{ page - 1 }}{% if status_filter %}&status={{ status_filter }}{% endif %}" class="action-btn" aria-label="上一页" data-tooltip="上一页"><i class="fas fa-chevron-left"></i></a>
|
||||
{% endif %}
|
||||
|
||||
<span class="page-info">第 {{ page }} 页,共 {{ (total + page_size - 1) // page_size }} 页</span>
|
||||
|
||||
{% if page * page_size < total %}
|
||||
<a href="?page={{ page + 1 }}{% if status_filter %}&status={{ status_filter }}{% endif %}" class="btn btn-secondary btn-sm">下一页 →</a>
|
||||
<a href="?page={{ page + 1 }}{% if status_filter %}&status={{ status_filter }}{% endif %}" class="action-btn" aria-label="下一页" data-tooltip="下一页"><i class="fas fa-chevron-right"></i></a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -620,9 +776,9 @@
|
||||
{% endif %}
|
||||
</p>
|
||||
<div class="table-action-group" style="justify-content: center;">
|
||||
<a href="/scenarios" class="action-btn action-btn--primary">创建项目</a>
|
||||
<a href="/scenarios" class="action-btn action-btn--primary" aria-label="创建项目" data-tooltip="创建项目"><i class="fas fa-plus"></i></a>
|
||||
{% if status_filter %}
|
||||
<a href="/projects" class="action-btn">查看全部</a>
|
||||
<a href="/projects" class="action-btn" aria-label="查看全部" data-tooltip="查看全部"><i class="fas fa-list"></i></a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
@@ -640,8 +796,22 @@
|
||||
此操作不可撤销,将永久删除项目及其所有相关数据。
|
||||
</p>
|
||||
<div style="display: flex; gap: 8px; justify-content: center;">
|
||||
<button onclick="closeDeleteModal()" class="btn btn-secondary">取消</button>
|
||||
<button onclick="executeDeleteProject()" class="action-btn action-btn--danger" style="padding: 9px 20px;">确认删除</button>
|
||||
<button onclick="closeDeleteModal()" class="action-btn" aria-label="取消" data-tooltip="取消"><i class="fas fa-times"></i></button>
|
||||
<button onclick="executeDeleteProject()" class="action-btn action-btn--danger" aria-label="确认删除" data-tooltip="确认删除"><i class="fas fa-trash-alt"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 重命名项目 -->
|
||||
<div id="renameModal" style="display: none; position: fixed; inset: 0; background: rgba(15,23,42,0.4); backdrop-filter: blur(4px); z-index: 1000; align-items: center; justify-content: center; padding: 24px;">
|
||||
<div class="modal-surface">
|
||||
<h3 style="color: var(--text-primary); margin-bottom: 8px; font-size: 1.05rem; font-weight: 700;">重命名项目</h3>
|
||||
<p style="color: var(--text-secondary); margin-bottom: 14px; font-size: 0.875rem;">请输入新的项目名称,保存后会同步到项目列表和仪表板。</p>
|
||||
<input id="renameProjectTitle" class="modal-input" type="text" maxlength="255" autocomplete="off" aria-label="项目名称">
|
||||
<div id="renameProjectError" class="modal-error"></div>
|
||||
<div class="modal-actions" style="margin-top: 16px;">
|
||||
<button onclick="closeRenameModal()" class="action-btn" aria-label="取消" data-tooltip="取消"><i class="fas fa-times"></i></button>
|
||||
<button onclick="executeRenameProject()" class="action-btn action-btn--primary" aria-label="保存" data-tooltip="保存"><i class="fas fa-check"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -649,7 +819,7 @@
|
||||
<div id="loadingOverlay" style="display: none; position: fixed; inset: 0; background: rgba(15,23,42,0.4); backdrop-filter: blur(4px); z-index: 1001; align-items: center; justify-content: center; padding: 24px;">
|
||||
<div class="modal-surface" style="max-width: 360px;">
|
||||
<div class="spinner" style="margin-bottom: 16px;"></div>
|
||||
<p style="color: var(--text-primary); font-weight: 600; font-size: 0.875rem;">正在删除项目...</p>
|
||||
<p id="loadingOverlayMessage" style="color: var(--text-primary); font-weight: 600; font-size: 0.875rem; text-align: center;">正在处理...</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -657,6 +827,7 @@
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
let currentDeleteProjectId = null;
|
||||
let currentRenameProjectId = null;
|
||||
|
||||
function filterProjects() {
|
||||
const selectElement = document.getElementById('statusFilter');
|
||||
@@ -706,10 +877,95 @@ function closeDeleteModal() {
|
||||
currentDeleteProjectId = null;
|
||||
}
|
||||
|
||||
function openRenameModal(projectId, projectTitle) {
|
||||
currentRenameProjectId = projectId;
|
||||
const input = document.getElementById('renameProjectTitle');
|
||||
const error = document.getElementById('renameProjectError');
|
||||
if (input) input.value = projectTitle || '';
|
||||
if (error) error.textContent = '';
|
||||
const modal = document.getElementById('renameModal');
|
||||
if (modal) modal.style.display = 'flex';
|
||||
setTimeout(() => {
|
||||
input?.focus();
|
||||
input?.select();
|
||||
}, 50);
|
||||
}
|
||||
|
||||
function closeRenameModal() {
|
||||
const modal = document.getElementById('renameModal');
|
||||
if (modal) modal.style.display = 'none';
|
||||
currentRenameProjectId = null;
|
||||
}
|
||||
|
||||
function setLoadingOverlay(visible, message = '正在处理...') {
|
||||
const overlay = document.getElementById('loadingOverlay');
|
||||
const messageEl = document.getElementById('loadingOverlayMessage');
|
||||
if (messageEl) messageEl.textContent = message;
|
||||
if (overlay) overlay.style.display = visible ? 'flex' : 'none';
|
||||
}
|
||||
|
||||
async function executeRenameProject() {
|
||||
if (!currentRenameProjectId) return;
|
||||
|
||||
const projectId = currentRenameProjectId;
|
||||
const input = document.getElementById('renameProjectTitle');
|
||||
const error = document.getElementById('renameProjectError');
|
||||
const title = (input?.value || '').trim();
|
||||
|
||||
if (!title) {
|
||||
if (error) error.textContent = '项目名称不能为空';
|
||||
input?.focus();
|
||||
return;
|
||||
}
|
||||
if (title.length > 255) {
|
||||
if (error) error.textContent = '项目名称不能超过 255 个字符';
|
||||
input?.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
closeRenameModal();
|
||||
setLoadingOverlay(true, '正在重命名项目...');
|
||||
try {
|
||||
const response = await fetch(`/api/projects/${projectId}/rename`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title })
|
||||
});
|
||||
const result = await response.json().catch(() => ({}));
|
||||
if (!response.ok || result.status !== 'success') {
|
||||
throw new Error(result.detail || result.message || '重命名失败');
|
||||
}
|
||||
showNotification('项目已重命名', 'success');
|
||||
setTimeout(() => window.location.reload(), 600);
|
||||
} catch (error) {
|
||||
showNotification('重命名失败:' + error.message, 'error');
|
||||
setLoadingOverlay(false);
|
||||
}
|
||||
currentRenameProjectId = null;
|
||||
}
|
||||
|
||||
async function duplicateProject(projectId) {
|
||||
try {
|
||||
showNotification('正在复制项目...', 'info');
|
||||
const response = await fetch(`/api/projects/${projectId}/duplicate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
const result = await response.json();
|
||||
if (!response.ok || result.status !== 'success') {
|
||||
throw new Error(result.detail || result.message || '复制失败');
|
||||
}
|
||||
showNotification('项目复制成功', 'success');
|
||||
setTimeout(() => { window.location.href = `/projects/${result.project_id}`; }, 600);
|
||||
} catch (error) {
|
||||
showNotification('复制失败:' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function executeDeleteProject() {
|
||||
if (!currentDeleteProjectId) return;
|
||||
document.getElementById('deleteModal').style.display = 'none';
|
||||
document.getElementById('loadingOverlay').style.display = 'flex';
|
||||
setLoadingOverlay(true, '正在删除项目...');
|
||||
try {
|
||||
const response = await fetch(`/api/database/projects/${currentDeleteProjectId}`, {
|
||||
method: 'DELETE', headers: { 'Content-Type': 'application/json' }
|
||||
@@ -721,7 +977,7 @@ async function executeDeleteProject() {
|
||||
} else { throw new Error(result.detail || '删除失败'); }
|
||||
} catch (error) {
|
||||
showNotification('删除失败:' + error.message, 'error');
|
||||
document.getElementById('loadingOverlay').style.display = 'none';
|
||||
setLoadingOverlay(false);
|
||||
}
|
||||
currentDeleteProjectId = null;
|
||||
}
|
||||
@@ -749,8 +1005,17 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
document.getElementById('deleteModal')?.addEventListener('click', function(e) {
|
||||
if (e.target === this) closeDeleteModal();
|
||||
});
|
||||
document.getElementById('renameModal')?.addEventListener('click', function(e) {
|
||||
if (e.target === this) closeRenameModal();
|
||||
});
|
||||
document.getElementById('renameProjectTitle')?.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter') executeRenameProject();
|
||||
});
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') closeDeleteModal();
|
||||
if (e.key === 'Escape') {
|
||||
closeDeleteModal();
|
||||
closeRenameModal();
|
||||
}
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
switch(e.key) {
|
||||
case 'f': e.preventDefault(); document.getElementById('statusFilter')?.focus(); break;
|
||||
|
||||
@@ -78,6 +78,27 @@ def test_init_default_admin_bootstraps_when_explicitly_configured(monkeypatch):
|
||||
db.close()
|
||||
|
||||
|
||||
def test_init_default_admin_uses_default_local_credentials(monkeypatch):
|
||||
from landppt.auth.auth_service import init_default_admin
|
||||
from landppt.core.config import app_config
|
||||
from landppt.database.models import User
|
||||
|
||||
db = _create_db()
|
||||
try:
|
||||
monkeypatch.setattr(app_config, "bootstrap_admin_enabled", True)
|
||||
monkeypatch.setattr(app_config, "bootstrap_admin_username", "admin")
|
||||
monkeypatch.setattr(app_config, "bootstrap_admin_password", "admin123")
|
||||
|
||||
init_default_admin(db)
|
||||
|
||||
created = db.query(User).one()
|
||||
assert created.username == "admin"
|
||||
assert created.is_admin is True
|
||||
assert created.check_password("admin123") is True
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_init_default_admin_skips_when_users_exist(monkeypatch):
|
||||
from landppt.auth.auth_service import init_default_admin
|
||||
from landppt.core.config import app_config
|
||||
|
||||
@@ -372,6 +372,18 @@ def test_resolve_registration_invite_allows_blank_when_switch_disabled():
|
||||
db.close()
|
||||
|
||||
|
||||
def test_resolve_registration_invite_allows_blank_by_default():
|
||||
from landppt.services.community_service import community_service
|
||||
|
||||
db = _create_db()
|
||||
try:
|
||||
invite = community_service.resolve_registration_invite(db, "", "mail")
|
||||
assert invite is None
|
||||
assert community_service.is_invite_code_required_for_registration(db) is False
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_github_oauth_new_user_requires_and_consumes_invite_code():
|
||||
from landppt.auth.github_oauth_service import get_or_create_user_by_github
|
||||
from landppt.core.config import app_config
|
||||
@@ -380,6 +392,7 @@ def test_github_oauth_new_user_requires_and_consumes_invite_code():
|
||||
db = _create_db()
|
||||
try:
|
||||
_create_user(db, "admin", "admin@example.com")
|
||||
_set_community_setting(db, "invite_code_required_for_registration", "true")
|
||||
invite = _create_invite(db, code="GITHUB01", channel="github", credits_amount=15, max_uses=1)
|
||||
|
||||
user, created, error = get_or_create_user_by_github(
|
||||
|
||||
@@ -36,6 +36,59 @@ def _load_class_method(relative_path: str, class_name: str, method_name: str):
|
||||
return namespace[method_name]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rename_project_scopes_to_current_user(monkeypatch):
|
||||
from landppt.api import landppt_api
|
||||
from landppt.api.models import ProjectRenameRequest
|
||||
|
||||
calls = {}
|
||||
|
||||
class FakeProjectManager:
|
||||
async def get_project(self, project_id, user_id=None):
|
||||
calls["lookup"] = (project_id, user_id)
|
||||
return SimpleNamespace(project_id=project_id)
|
||||
|
||||
async def update_project_data(self, project_id, update_data, user_id=None):
|
||||
calls["update"] = (project_id, update_data, user_id)
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(
|
||||
landppt_api,
|
||||
"get_ppt_service_for_user",
|
||||
lambda user_id: SimpleNamespace(project_manager=FakeProjectManager()),
|
||||
)
|
||||
|
||||
response = await landppt_api.rename_project(
|
||||
"proj-1",
|
||||
ProjectRenameRequest(title=" New project title "),
|
||||
user=SimpleNamespace(id=11),
|
||||
)
|
||||
|
||||
assert response["status"] == "success"
|
||||
assert response["title"] == "New project title"
|
||||
assert calls == {
|
||||
"lookup": ("proj-1", 11),
|
||||
"update": ("proj-1", {"title": "New project title"}, 11),
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rename_project_rejects_blank_title():
|
||||
from fastapi import HTTPException
|
||||
from landppt.api import landppt_api
|
||||
from landppt.api.models import ProjectRenameRequest
|
||||
|
||||
with pytest.raises(HTTPException) as excinfo:
|
||||
await landppt_api.rename_project(
|
||||
"proj-1",
|
||||
ProjectRenameRequest(title=" "),
|
||||
user=SimpleNamespace(id=11),
|
||||
)
|
||||
|
||||
assert excinfo.value.status_code == 422
|
||||
assert excinfo.value.detail == "Project title is required"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_enhanced_ppt_service_keeps_project_workflow_proxy():
|
||||
execute_project_workflow = _load_class_method(
|
||||
@@ -142,3 +195,83 @@ def test_todo_board_preserves_saved_outline_before_auto_starting_generation():
|
||||
assert "Saved outline exists, skipping auto-start outline generation." in script
|
||||
assert "Saved outline exists, hydrating instead of starting outline generation." in script
|
||||
assert "Saved outline exists, skipping workflow auto-start." in script
|
||||
|
||||
|
||||
def test_slide_record_from_payload_preserves_outline_metadata():
|
||||
record = DatabaseService._slide_record_from_payload(
|
||||
"project-1",
|
||||
2,
|
||||
{
|
||||
"title": "Updated title",
|
||||
"slide_type": "section",
|
||||
"description": "Updated description",
|
||||
"content_points": ["point-a", "point-b"],
|
||||
"html_content": "<section>Updated</section>",
|
||||
"is_user_edited": True,
|
||||
},
|
||||
)
|
||||
|
||||
assert record["title"] == "Updated title"
|
||||
assert record["content_type"] == "section"
|
||||
assert record["slide_metadata"]["description"] == "Updated description"
|
||||
assert record["slide_metadata"]["content_points"] == ["point-a", "point-b"]
|
||||
assert record["html_content"] == "<section>Updated</section>"
|
||||
assert record["is_user_edited"] is True
|
||||
|
||||
|
||||
def test_editor_slide_save_sends_full_slide_payload():
|
||||
script = _read(
|
||||
"src/landppt/web/static/js/pages/project/slides_editor/projectSlidesEditor.slideGeneration.js"
|
||||
)
|
||||
|
||||
assert "slide_data: slidePayload" in script
|
||||
assert "content_points: slidePayload.content_points || []" in script
|
||||
assert "metadata: slidePayload.metadata || {}" in script
|
||||
|
||||
|
||||
def test_outline_operations_send_structure_operation_payload():
|
||||
script = _read("src/landppt/web/static/js/pages/project/slides_editor/projectSlidesEditor.aiChat.js")
|
||||
|
||||
assert "const operationPayload = {" in script
|
||||
assert "operation: operationPayload" in script
|
||||
assert "await saveSingleSlideToServer(" in script
|
||||
|
||||
|
||||
def test_project_detail_outline_supports_drag_delete_and_duplicate():
|
||||
content = _read("src/landppt/web/templates/components/project/detail/content_1.html")
|
||||
script = _read("src/landppt/web/templates/components/project/detail/extra_js_1.html")
|
||||
|
||||
assert 'draggable="true"' in content
|
||||
assert "deleteOutlineSlide(" in content
|
||||
assert "duplicateProject()" in content
|
||||
assert "function initializeOutlineDragAndDrop()" in script
|
||||
assert "async function persistProjectOutline(operation)" in script
|
||||
|
||||
|
||||
def test_project_detail_outline_uses_icon_only_toolbar():
|
||||
content = _read("src/landppt/web/templates/components/project/detail/content_1.html")
|
||||
css = _read("src/landppt/web/templates/components/project/detail/extra_css_1.html")
|
||||
script = _read("src/landppt/web/templates/components/project/detail/extra_js_1.html")
|
||||
|
||||
assert "outline-panel__header" in content
|
||||
assert "outline-tool-btn" in content
|
||||
assert 'data-tooltip="编辑大纲"' in content
|
||||
assert 'id="outlineViewToggleBtn"' in content
|
||||
assert "outline-slide-card__actions" in content
|
||||
assert ".outline-tool-btn[data-tooltip]::after" in css
|
||||
assert "setOutlineToggleState(isDetailView)" in script
|
||||
assert "toggleIcon.className = 'fas fa-th-large'" in script
|
||||
|
||||
|
||||
def test_editor_sidebar_thumbnail_refresh_recalculates_scale_without_overriding_load_handler():
|
||||
core = _read("src/landppt/web/static/js/pages/project/slides_editor/projectSlidesEditor.core.js")
|
||||
slide_crud = _read("src/landppt/web/static/js/pages/project/slides_editor/projectSlidesEditor.slideCrud.js")
|
||||
css = _read("src/landppt/web/static/css/pages/project/slides_editor/projectSlidesEditor.css")
|
||||
|
||||
assert "function requestThumbnailPreviewScale(iframe)" in core
|
||||
assert "iframe.addEventListener('load', handleIframeLoad, { once: true });" in core
|
||||
assert "requestThumbnailPreviewScale(iframe);" in slide_crud
|
||||
assert "iframe.onload = function" not in slide_crud
|
||||
assert "aspect-ratio: 16 / 9;" in css
|
||||
assert "height: 95px" not in css
|
||||
assert "scale(0.1875)" in css
|
||||
|
||||
|
After Width: | Height: | Size: 170 KiB |
|
After Width: | Height: | Size: 137 KiB |
|
After Width: | Height: | Size: 116 KiB |
|
After Width: | Height: | Size: 209 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 135 KiB |
|
After Width: | Height: | Size: 122 KiB |
@@ -1697,7 +1697,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "landppt"
|
||||
version = "0.1.8"
|
||||
version = "0.3.1"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiofiles" },
|
||||
@@ -1735,6 +1735,7 @@ dependencies = [
|
||||
{ name = "psycopg2-binary" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "pydantic-settings" },
|
||||
{ name = "pymupdf" },
|
||||
{ name = "pypdf2" },
|
||||
{ name = "python-docx" },
|
||||
{ name = "python-dotenv" },
|
||||
@@ -1811,6 +1812,7 @@ requires-dist = [
|
||||
{ name = "psycopg2-binary", specifier = ">=2.9.9" },
|
||||
{ name = "pydantic", specifier = ">=2.5.0" },
|
||||
{ name = "pydantic-settings", specifier = ">=2.0.0" },
|
||||
{ name = "pymupdf", specifier = ">=1.23.0" },
|
||||
{ name = "pypdf2", specifier = ">=3.0.1" },
|
||||
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0.0" },
|
||||
{ name = "pytest", marker = "extra == 'test'", specifier = ">=7.0.0" },
|
||||
@@ -3560,6 +3562,22 @@ crypto = [
|
||||
{ name = "cryptography" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pymupdf"
|
||||
version = "1.27.2.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/22/32/708bedc9dde7b328d45abbc076091769d44f2f24ad151ad92d56a6ec142b/pymupdf-1.27.2.3.tar.gz", hash = "sha256:7a92faa25129e8bbec5e50eeb9214f187665428c31b05c4ef6e36c58c0b1c6d2", size = 85759618, upload-time = "2026-04-24T14:13:14.42Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/09/ddbdfa7ee91fbabd6f63d7d744884cbdfe3e7ff9b8604749fb38bddf5c5d/pymupdf-1.27.2.3-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:fc1bc3cae6e9e150b0dbb0a9221bdfd411d65f0db2fe359eaa22467d7cc2a05f", size = 24002636, upload-time = "2026-04-24T14:09:17.459Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/89/3f8edd6c4f50ca370e2a2f2a3011face36f3760728ffe76dffec91c0fca0/pymupdf-1.27.2.3-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:660d93cb6da5bbddf11d3982ae27745dd3a9902d9f24cdb69adab83962294b5a", size = 23278238, upload-time = "2026-04-24T14:09:32.882Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/26/b7e5a70eb83bd189f8b5df87ec442746b992f2f632662839b288170d357d/pymupdf-1.27.2.3-cp310-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:1dd460a3ae4597a755f00a3bd9771f5ebf1531dc111f6a36bf05dd00a6b84425", size = 24333923, upload-time = "2026-04-24T14:09:47.341Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/a0/aa1ee2240f29481a04a827c313333b4ecd8a14d6ac3e15d3f41a30574781/pymupdf-1.27.2.3-cp310-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:857842b4888827bd6155a1131341b2822a7ebe9a8c15a975fd7d490d7a64a30c", size = 24963198, upload-time = "2026-04-24T14:10:07.408Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/49/4f742451f980840829fc00ba158bebb25d389c846d8f4f8c65936ee55de8/pymupdf-1.27.2.3-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:580983849c64a08d08344ca3d1580e87c01f046a8392421797bc850efd72a5b6", size = 25184609, upload-time = "2026-04-24T14:10:22.911Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/3f/3853d6608f394faf6eec2bd4e8ea9f6a00beea329b071abdb29f4164cc3d/pymupdf-1.27.2.3-cp310-abi3-win32.whl", hash = "sha256:a5c1088a87189891a4946ab314a14b7934ac4c5b6077f7e74ebee956f8906d0e", size = 18019286, upload-time = "2026-04-24T14:10:34.239Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/47/5fb10fe73f96b31253a41647c362ea9e0380920bddf16028414a051247fc/pymupdf-1.27.2.3-cp310-abi3-win_amd64.whl", hash = "sha256:d20f68ef15195e073071dbc4ae7455257c7889af7584e39df490c0a92728526e", size = 19249102, upload-time = "2026-04-24T14:10:46.72Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/a4/b9e91aac82293f9c954654c85581ee8212b5b05efadc534b581141241e6f/pymupdf-1.27.2.3-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:77691604c5d1d0233827139bbcdea61fd57879c84712b8e49b1f45520f7ab9c2", size = 25000393, upload-time = "2026-04-24T14:11:01.669Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyparsing"
|
||||
version = "3.3.1"
|
||||
|
||||