Refactor code structure for improved readability and maintainability

This commit is contained in:
sligter
2026-06-13 18:34:49 +08:00
parent 30c4f0df9b
commit 20fdad7846
35 changed files with 2253 additions and 335 deletions
+4 -4
View File
@@ -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)
+3 -3
View File
@@ -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}
+61
View File
@@ -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,
+3
View File
@@ -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"]
+2 -3
View File
@@ -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
+2 -2
View File
@@ -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,
+3 -3
View File
@@ -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
+385 -34
View File
@@ -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:
+1 -1
View File
@@ -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"},
+30 -11
View File
@@ -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()
+1 -1
View File
@@ -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.141720*0.141=101.52px */
height: auto;
}
.slide-title {
@@ -1164,8 +1163,7 @@ body::before {
}
.slide-preview {
height: 95px;
/* 160px容器宽度,160/1280=0.125720*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;
+21
View File
@@ -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
+13
View File
@@ -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(
+133
View File
@@ -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
Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 209 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Generated
+19 -1
View File
@@ -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"