feat: persist story generation states and cache audio
Some checks failed
Build and Push Docker Images / changes (push) Has been cancelled
Build and Push Docker Images / build-backend (push) Has been cancelled
Build and Push Docker Images / build-frontend (push) Has been cancelled
Build and Push Docker Images / build-admin-frontend (push) Has been cancelled

This commit is contained in:
2026-04-17 17:14:09 +08:00
parent 145be0e67b
commit a97a2fe005
17 changed files with 2045 additions and 849 deletions

1
backend/.gitignore vendored
View File

@@ -25,3 +25,4 @@ htmlcov/
# 其他
*.log
.DS_Store
storage/

View File

@@ -0,0 +1,151 @@
"""add story generation status fields
Revision ID: 0009_add_story_generation_statuses
Revises: 0008_add_pages_to_stories
Create Date: 2026-04-17
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "0009_add_story_generation_statuses"
down_revision = "0008_add_pages_to_stories"
branch_labels = None
depends_on = None
stories = sa.table(
"stories",
sa.column("id", sa.Integer),
sa.column("story_text", sa.Text),
sa.column("pages", sa.JSON),
sa.column("cover_prompt", sa.Text),
sa.column("image_url", sa.String(length=500)),
sa.column("generation_status", sa.String(length=32)),
sa.column("image_status", sa.String(length=32)),
sa.column("audio_status", sa.String(length=32)),
)
def _resolve_image_status(row: dict) -> str:
pages = row.get("pages") or []
expected_assets = 0
ready_assets = 0
if row.get("cover_prompt") or row.get("image_url"):
expected_assets += 1
if row.get("image_url"):
ready_assets += 1
for page in pages:
if not isinstance(page, dict):
continue
if not page.get("image_prompt") and not page.get("image_url"):
continue
expected_assets += 1
if page.get("image_url"):
ready_assets += 1
if expected_assets == 0:
return "not_requested"
if ready_assets == expected_assets:
return "ready"
return "failed"
def _resolve_generation_status(
*,
story_text: str | None,
pages: list[dict] | None,
image_status: str,
audio_status: str,
) -> str:
has_narrative = bool(story_text) or bool(pages)
if not has_narrative:
return "failed"
if "generating" in {image_status, audio_status}:
return "assets_generating"
if "failed" in {image_status, audio_status}:
return "degraded_completed"
if image_status == "not_requested" and audio_status == "not_requested":
return "narrative_ready"
return "completed"
def upgrade() -> None:
op.add_column(
"stories",
sa.Column(
"generation_status",
sa.String(length=32),
nullable=False,
server_default="narrative_ready",
),
)
op.add_column(
"stories",
sa.Column(
"image_status",
sa.String(length=32),
nullable=False,
server_default="not_requested",
),
)
op.add_column(
"stories",
sa.Column(
"audio_status",
sa.String(length=32),
nullable=False,
server_default="not_requested",
),
)
op.add_column("stories", sa.Column("last_error", sa.Text(), nullable=True))
connection = op.get_bind()
rows = connection.execute(
sa.select(
stories.c.id,
stories.c.story_text,
stories.c.pages,
stories.c.cover_prompt,
stories.c.image_url,
)
).mappings()
for row in rows:
image_status = _resolve_image_status(row)
audio_status = "not_requested"
generation_status = _resolve_generation_status(
story_text=row.get("story_text"),
pages=row.get("pages"),
image_status=image_status,
audio_status=audio_status,
)
connection.execute(
stories.update()
.where(stories.c.id == row["id"])
.values(
generation_status=generation_status,
image_status=image_status,
audio_status=audio_status,
)
)
def downgrade() -> None:
op.drop_column("stories", "last_error")
op.drop_column("stories", "audio_status")
op.drop_column("stories", "image_status")
op.drop_column("stories", "generation_status")

View File

@@ -0,0 +1,25 @@
"""add audio cache path to stories
Revision ID: 0010_add_story_audio_cache_path
Revises: 0009_add_story_generation_statuses
Create Date: 2026-04-17
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "0010_add_story_audio_cache_path"
down_revision = "0009_add_story_generation_statuses"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("stories", sa.Column("audio_path", sa.String(length=500), nullable=True))
def downgrade() -> None:
op.drop_column("stories", "audio_path")

View File

@@ -1,11 +1,10 @@
"""Story related APIs."""
import asyncio
import json
import uuid
from typing import AsyncGenerator
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from fastapi import APIRouter, Depends, Response
from sse_starlette.sse import EventSourceResponse
from sqlalchemy.ext.asyncio import AsyncSession
@@ -15,13 +14,15 @@ from app.core.rate_limiter import check_rate_limit
from app.db.database import get_db
from app.db.models import User
from app.schemas.story_schemas import (
GenerateRequest,
StoryResponse,
AchievementItem,
FullStoryResponse,
GenerateRequest,
StoryDetailResponse,
StoryImageResponse,
StoryListItem,
StoryResponse,
StorybookRequest,
StorybookResponse,
StoryListItem,
AchievementItem,
)
from app.services import story_service
from app.services.memory_service import build_enhanced_memory_context
@@ -29,6 +30,7 @@ from app.services.provider_router import (
generate_story_content,
generate_image,
)
from app.services.story_status import StoryAssetStatus, sync_story_status
logger = get_logger(__name__)
router = APIRouter()
@@ -62,7 +64,6 @@ async def generate_story_full(
@router.post("/stories/generate/stream")
async def generate_story_stream(
request: GenerateRequest,
req: Request,
user: User = Depends(require_user),
db: AsyncSession = Depends(get_db),
):
@@ -110,22 +111,71 @@ async def generate_story_stream(
"mode": story.mode,
"child_profile_id": story.child_profile_id,
"universe_id": story.universe_id,
"generation_status": story.generation_status,
"image_status": story.image_status,
"audio_status": story.audio_status,
"last_error": story.last_error,
}),
}
# Step 2: Generate Image
if story.cover_prompt:
sync_story_status(story, image_status=StoryAssetStatus.GENERATING)
await db.commit()
try:
# Direct call to provider router's generate_image, sharing db session
image_url = await generate_image(story.cover_prompt, db=db)
story.image_url = image_url
sync_story_status(
story,
image_status=StoryAssetStatus.READY,
)
await db.commit()
yield {"event": "image_ready", "data": json.dumps({"image_url": image_url})}
yield {
"event": "image_ready",
"data": json.dumps(
{
"image_url": image_url,
"generation_status": story.generation_status,
"image_status": story.image_status,
"audio_status": story.audio_status,
"last_error": story.last_error,
}
),
}
except Exception as e:
sync_story_status(
story,
image_status=StoryAssetStatus.FAILED,
last_error=str(e),
)
await db.commit()
logger.warning("sse_image_generation_failed", story_id=story.id, error=str(e))
yield {"event": "image_failed", "data": json.dumps({"error": str(e)})}
yield {
"event": "image_failed",
"data": json.dumps(
{
"error": str(e),
"generation_status": story.generation_status,
"image_status": story.image_status,
"audio_status": story.audio_status,
"last_error": story.last_error,
}
),
}
yield {"event": "complete", "data": json.dumps({"story_id": story.id})}
yield {
"event": "complete",
"data": json.dumps(
{
"story_id": story.id,
"generation_status": story.generation_status,
"image_status": story.image_status,
"audio_status": story.audio_status,
"last_error": story.last_error,
}
),
}
return EventSourceResponse(event_generator())
@@ -154,7 +204,7 @@ async def list_stories(
return await story_service.list_stories(user.id, limit, offset, db)
@router.get("/stories/{story_id}", response_model=StoryResponse)
@router.get("/stories/{story_id}", response_model=StoryDetailResponse)
async def get_story(
story_id: int,
user: User = Depends(require_user),
@@ -175,7 +225,7 @@ async def delete_story(
return {"message": "Deleted"}
@router.post("/image/generate/{story_id}")
@router.post("/image/generate/{story_id}", response_model=StoryImageResponse)
async def generate_story_image(
story_id: int,
user: User = Depends(require_user),
@@ -183,7 +233,14 @@ async def generate_story_image(
):
"""Generate cover image for story."""
url = await story_service.generate_story_cover(story_id, user.id, db)
return {"image_url": url}
story = await story_service.get_story_detail(story_id, user.id, db)
return {
"image_url": url,
"generation_status": story.generation_status,
"image_status": story.image_status,
"audio_status": story.audio_status,
"last_error": story.last_error,
}
@router.get("/audio/{story_id}")

View File

@@ -53,6 +53,10 @@ class Settings(BaseSettings):
text_providers: list[str] = Field(default_factory=lambda: ["gemini"])
image_providers: list[str] = Field(default_factory=lambda: ["cqtai"])
tts_providers: list[str] = Field(default_factory=lambda: ["minimax", "elevenlabs", "edge_tts"])
story_audio_cache_dir: str = Field(
"storage/audio",
description="Directory for cached story audio files",
)
# Celery (Redis)
celery_broker_url: str = Field("redis://localhost:6379/0")

View File

@@ -27,10 +27,10 @@ class User(Base):
__tablename__ = "users"
id: Mapped[str] = mapped_column(String(255), primary_key=True) # OAuth provider user ID
id: Mapped[str] = mapped_column(String(255), primary_key=True)
name: Mapped[str] = mapped_column(String(255), nullable=False)
avatar_url: Mapped[str | None] = mapped_column(String(500))
provider: Mapped[str] = mapped_column(String(50), nullable=False) # github / google
provider: Mapped[str] = mapped_column(String(50), nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
@@ -59,11 +59,22 @@ class Story(Base):
String(36), ForeignKey("story_universes.id", ondelete="SET NULL"), nullable=True
)
title: Mapped[str] = mapped_column(String(255), nullable=False)
story_text: Mapped[str] = mapped_column(Text, nullable=True) # 允许为空(绘本模式下)
pages: Mapped[list[dict] | None] = mapped_column(JSON, default=list) # 绘本分页数据
story_text: Mapped[str | None] = mapped_column(Text, nullable=True)
pages: Mapped[list[dict] | None] = mapped_column(JSON, default=list)
cover_prompt: Mapped[str | None] = mapped_column(Text)
image_url: Mapped[str | None] = mapped_column(String(500))
mode: Mapped[str] = mapped_column(String(20), nullable=False, default="generated")
generation_status: Mapped[str] = mapped_column(
String(32), nullable=False, default="narrative_ready"
)
image_status: Mapped[str] = mapped_column(
String(32), nullable=False, default="not_requested"
)
audio_status: Mapped[str] = mapped_column(
String(32), nullable=False, default="not_requested"
)
audio_path: Mapped[str | None] = mapped_column(String(500))
last_error: Mapped[str | None] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
@@ -123,6 +134,7 @@ class ChildProfile(Base):
class StoryUniverse(Base):
"""Story universe entity."""
__tablename__ = "story_universes"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_uuid)
@@ -142,7 +154,9 @@ class StoryUniverse(Base):
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
child_profile: Mapped["ChildProfile"] = relationship("ChildProfile", back_populates="story_universes")
child_profile: Mapped["ChildProfile"] = relationship(
"ChildProfile", back_populates="story_universes"
)
class ReadingEvent(Base):
@@ -163,6 +177,7 @@ class ReadingEvent(Base):
DateTime(timezone=True), server_default=func.now(), index=True
)
class PushConfig(Base):
"""Push configuration entity."""

View File

@@ -1,4 +1,4 @@
"""故事相关 Pydantic 模型。"""
"""Story-related Pydantic schemas."""
from datetime import datetime
from typing import Literal
@@ -11,7 +11,13 @@ MAX_EDU_THEME_LENGTH = 200
MAX_TTS_LENGTH = 4000
# ==================== 故事模型 ====================
class StoryStatusMixin(BaseModel):
"""Shared generation status fields returned by story APIs."""
generation_status: str
image_status: str
audio_status: str
last_error: str | None = None
class GenerateRequest(BaseModel):
@@ -24,8 +30,8 @@ class GenerateRequest(BaseModel):
universe_id: str | None = None
class StoryResponse(BaseModel):
"""Story response."""
class StoryResponse(StoryStatusMixin):
"""Story generation response."""
id: int
title: str
@@ -37,7 +43,7 @@ class StoryResponse(BaseModel):
universe_id: str | None = None
class StoryListItem(BaseModel):
class StoryListItem(StoryStatusMixin):
"""Story list item."""
id: int
@@ -47,8 +53,8 @@ class StoryListItem(BaseModel):
mode: str
class FullStoryResponse(BaseModel):
"""完整故事响应(含图片和音频状态)。"""
class FullStoryResponse(StoryStatusMixin):
"""Full story response with asset status."""
id: int
title: str
@@ -62,22 +68,19 @@ class FullStoryResponse(BaseModel):
universe_id: str | None = None
# ==================== 绘本模型 ====================
class StorybookRequest(BaseModel):
"""Storybook 生成请求。"""
"""Storybook generation request."""
keywords: str = Field(..., min_length=1, max_length=200)
page_count: int = Field(default=6, ge=4, le=12)
education_theme: str | None = Field(default=None, max_length=MAX_EDU_THEME_LENGTH)
generate_images: bool = Field(default=False, description="是否同时生成插图")
generate_images: bool = Field(default=False, description="Whether to generate images too.")
child_profile_id: str | None = None
universe_id: str | None = None
class StorybookPageResponse(BaseModel):
"""故事书单页响应。"""
"""One storybook page."""
page_number: int
text: str
@@ -85,8 +88,8 @@ class StorybookPageResponse(BaseModel):
image_url: str | None = None
class StorybookResponse(BaseModel):
"""故事书响应。"""
class StorybookResponse(StoryStatusMixin):
"""Storybook generation response."""
id: int | None = None
title: str
@@ -97,10 +100,29 @@ class StorybookResponse(BaseModel):
cover_url: str | None = None
# ==================== 成就模型 ====================
class StoryDetailResponse(StoryStatusMixin):
"""Story detail response for both stories and storybooks."""
id: int
title: str
story_text: str | None = None
pages: list[StorybookPageResponse] | None = None
cover_prompt: str | None
image_url: str | None
mode: str
child_profile_id: str | None = None
universe_id: str | None = None
class StoryImageResponse(StoryStatusMixin):
"""Cover image generation response."""
image_url: str | None
class AchievementItem(BaseModel):
"""Achievement item returned for a story."""
type: str
description: str
obtained_at: str | None = None

View File

@@ -0,0 +1,38 @@
"""Story audio cache storage helpers."""
from __future__ import annotations
from pathlib import Path
from app.core.config import settings
def build_story_audio_path(story_id: int) -> str:
"""Build the cache path for a story audio file."""
return str(Path(settings.story_audio_cache_dir) / f"story-{story_id}.mp3")
def audio_cache_exists(audio_path: str | None) -> bool:
"""Whether the cached audio file exists on disk."""
return bool(audio_path) and Path(audio_path).is_file()
def read_audio_cache(audio_path: str) -> bytes:
"""Read cached story audio bytes."""
return Path(audio_path).read_bytes()
def write_story_audio_cache(story_id: int, audio_data: bytes) -> str:
"""Persist story audio and return the saved file path."""
final_path = Path(build_story_audio_path(story_id))
final_path.parent.mkdir(parents=True, exist_ok=True)
temp_path = final_path.with_suffix(".tmp")
temp_path.write_bytes(audio_data)
temp_path.replace(final_path)
return str(final_path)

View File

@@ -1,12 +1,9 @@
"""Story business logic service."""
import asyncio
import json
import uuid
from typing import Literal
from fastapi import HTTPException
from sqlalchemy import select, desc
from sqlalchemy import desc, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import joinedload
@@ -20,17 +17,78 @@ from app.schemas.story_schemas import (
StorybookPageResponse,
AchievementItem,
)
from app.services.audio_storage import (
audio_cache_exists,
read_audio_cache,
write_story_audio_cache,
)
from app.services.memory_service import build_enhanced_memory_context
from app.services.provider_router import (
generate_story_content,
generate_image,
generate_storybook,
)
from app.services.story_status import (
StoryAssetStatus,
sync_story_status,
)
from app.tasks.achievements import extract_story_achievements
logger = get_logger(__name__)
def _build_storybook_error_message(
*,
cover_failed: bool,
failed_pages: list[int],
) -> str | None:
"""Summarize storybook image generation errors for the latest attempt."""
parts: list[str] = []
if cover_failed:
parts.append("封面生成失败")
if failed_pages:
pages = "".join(str(page) for page in sorted(failed_pages))
parts.append(f"{pages} 页插图生成失败")
return "".join(parts) if parts else None
def _resolve_storybook_image_status(
*,
generate_images: bool,
cover_prompt: str | None,
cover_url: str | None,
pages_data: list[dict],
) -> StoryAssetStatus:
"""Resolve the persisted image status for a storybook."""
if not generate_images:
return StoryAssetStatus.NOT_REQUESTED
expected_assets = 0
ready_assets = 0
if cover_prompt or cover_url:
expected_assets += 1
if cover_url:
ready_assets += 1
for page in pages_data:
if not page.get("image_prompt") and not page.get("image_url"):
continue
expected_assets += 1
if page.get("image_url"):
ready_assets += 1
if expected_assets == 0:
return StoryAssetStatus.NOT_REQUESTED
if ready_assets == expected_assets:
return StoryAssetStatus.READY
return StoryAssetStatus.FAILED
async def validate_profile_and_universe(
profile_id: str | None,
universe_id: str | None,
@@ -108,6 +166,12 @@ async def generate_and_save_story(
cover_prompt=result.cover_prompt_suggestion,
mode=result.mode,
)
sync_story_status(
story,
image_status=StoryAssetStatus.NOT_REQUESTED,
audio_status=StoryAssetStatus.NOT_REQUESTED,
last_error=None,
)
db.add(story)
await db.commit()
await db.refresh(story)
@@ -135,12 +199,24 @@ async def generate_full_story_service(
errors: dict[str, str | None] = {}
if story.cover_prompt:
sync_story_status(story, image_status=StoryAssetStatus.GENERATING)
await db.commit()
try:
image_url = await generate_image(story.cover_prompt, db=db)
story.image_url = image_url
sync_story_status(
story,
image_status=StoryAssetStatus.READY,
)
await db.commit()
except Exception as exc:
errors["image"] = str(exc)
sync_story_status(
story,
image_status=StoryAssetStatus.FAILED,
last_error=str(exc),
)
await db.commit()
logger.warning("image_generation_failed", story_id=story.id, error=str(exc))
return FullStoryResponse(
@@ -154,6 +230,10 @@ async def generate_full_story_service(
errors=errors,
child_profile_id=story.child_profile_id,
universe_id=story.universe_id,
generation_status=story.generation_status,
image_status=story.image_status,
audio_status=story.audio_status,
last_error=story.last_error,
)
@@ -195,37 +275,40 @@ async def generate_storybook_service(
# 4. Parallel Image Generation
final_cover_url = storybook.cover_url
cover_failed = False
failed_pages: list[int] = []
if request.generate_images:
logger.info("storybook_parallel_generation_start", page_count=len(storybook.pages))
tasks = []
# Cover Task
async def _gen_cover():
nonlocal cover_failed
if storybook.cover_prompt and not storybook.cover_url:
try:
return await generate_image(storybook.cover_prompt, db=db)
except Exception as e:
logger.warning("cover_gen_failed", error=str(e))
except Exception as exc:
cover_failed = True
logger.warning("cover_gen_failed", error=str(exc))
return storybook.cover_url
tasks.append(_gen_cover())
# Page Tasks
async def _gen_page(page):
if page.image_prompt and not page.image_url:
try:
url = await generate_image(page.image_prompt, db=db)
page.image_url = url
except Exception as e:
logger.warning("page_gen_failed", page=page.page_number, error=str(e))
page.image_url = await generate_image(page.image_prompt, db=db)
except Exception as exc:
failed_pages.append(page.page_number)
logger.warning("page_gen_failed", page=page.page_number, error=str(exc))
for page in storybook.pages:
tasks.append(_gen_page(page))
# Execute
results = await asyncio.gather(*tasks, return_exceptions=True)
# Update cover result
cover_res = results[0]
if isinstance(cover_res, str):
final_cover_url = cover_res
@@ -254,6 +337,20 @@ async def generate_storybook_service(
cover_prompt=storybook.cover_prompt,
image_url=final_cover_url,
)
sync_story_status(
story,
image_status=_resolve_storybook_image_status(
generate_images=request.generate_images,
cover_prompt=storybook.cover_prompt,
cover_url=final_cover_url,
pages_data=pages_data,
),
audio_status=StoryAssetStatus.NOT_REQUESTED,
last_error=_build_storybook_error_message(
cover_failed=cover_failed,
failed_pages=failed_pages,
),
)
db.add(story)
await db.commit()
await db.refresh(story)
@@ -280,6 +377,10 @@ async def generate_storybook_service(
pages=response_pages,
cover_prompt=storybook.cover_prompt,
cover_url=final_cover_url,
generation_status=story.generation_status,
image_status=story.image_status,
audio_status=story.audio_status,
last_error=story.last_error,
)
@@ -345,6 +446,12 @@ async def create_story_from_result(
cover_prompt=result.cover_prompt_suggestion,
mode=result.mode,
)
sync_story_status(
story,
image_status=StoryAssetStatus.NOT_REQUESTED,
audio_status=StoryAssetStatus.NOT_REQUESTED,
last_error=None,
)
db.add(story)
await db.commit()
await db.refresh(story)
@@ -366,12 +473,25 @@ async def generate_story_cover(
if not story.cover_prompt:
raise HTTPException(status_code=400, detail="Story has no cover prompt")
sync_story_status(story, image_status=StoryAssetStatus.GENERATING)
await db.commit()
try:
image_url = await generate_image(story.cover_prompt, db=db)
story.image_url = image_url
sync_story_status(
story,
image_status=StoryAssetStatus.READY,
)
await db.commit()
return image_url
except Exception as e:
sync_story_status(
story,
image_status=StoryAssetStatus.FAILED,
last_error=str(e),
)
await db.commit()
logger.error("cover_generation_failed", story_id=story_id, error=str(e))
raise HTTPException(status_code=500, detail=f"Image generation failed: {e}")
@@ -387,14 +507,45 @@ async def generate_story_audio(
if not story.story_text:
raise HTTPException(status_code=400, detail="Story has no text")
# TODO: Check if audio is already cached/saved?
# For now, generate on the fly via provider
if story.audio_path and audio_cache_exists(story.audio_path):
if story.audio_status != StoryAssetStatus.READY.value:
sync_story_status(story, audio_status=StoryAssetStatus.READY)
await db.commit()
return read_audio_cache(story.audio_path)
if story.audio_path and not audio_cache_exists(story.audio_path):
logger.warning(
"story_audio_cache_missing",
story_id=story_id,
audio_path=story.audio_path,
)
story.audio_path = None
if story.audio_status == StoryAssetStatus.READY.value:
sync_story_status(story, audio_status=StoryAssetStatus.NOT_REQUESTED)
await db.commit()
from app.services.provider_router import text_to_speech
sync_story_status(story, audio_status=StoryAssetStatus.GENERATING)
await db.commit()
try:
audio_data = await text_to_speech(story.story_text, db=db)
story.audio_path = write_story_audio_cache(story.id, audio_data)
sync_story_status(
story,
audio_status=StoryAssetStatus.READY,
)
await db.commit()
return audio_data
except Exception as e:
story.audio_path = None
sync_story_status(
story,
audio_status=StoryAssetStatus.FAILED,
last_error=str(e),
)
await db.commit()
logger.error("audio_generation_failed", story_id=story_id, error=str(e))
raise HTTPException(status_code=500, detail=f"Audio generation failed: {e}")

View File

@@ -0,0 +1,112 @@
"""Story generation status helpers."""
from __future__ import annotations
from enum import Enum
from typing import Protocol
class StoryGenerationStatus(str, Enum):
"""Overall story generation lifecycle."""
NARRATIVE_READY = "narrative_ready"
ASSETS_GENERATING = "assets_generating"
COMPLETED = "completed"
DEGRADED_COMPLETED = "degraded_completed"
FAILED = "failed"
class StoryAssetStatus(str, Enum):
"""Asset generation state for image and audio."""
NOT_REQUESTED = "not_requested"
GENERATING = "generating"
READY = "ready"
FAILED = "failed"
class StoryLike(Protocol):
"""Protocol for story-like objects used by status helpers."""
story_text: str | None
pages: list[dict] | None
generation_status: str
image_status: str
audio_status: str
last_error: str | None
_ERROR_UNSET = object()
def _normalize_asset_status(value: str | None) -> StoryAssetStatus:
if not value:
return StoryAssetStatus.NOT_REQUESTED
try:
return StoryAssetStatus(value)
except ValueError:
return StoryAssetStatus.NOT_REQUESTED
def has_narrative_content(story: StoryLike) -> bool:
"""Whether the story already has readable content."""
return bool(story.story_text) or bool(story.pages)
def resolve_story_generation_status(story: StoryLike) -> StoryGenerationStatus:
"""Derive the overall status from narrative and asset states."""
if not has_narrative_content(story):
return StoryGenerationStatus.FAILED
image_status = _normalize_asset_status(story.image_status)
audio_status = _normalize_asset_status(story.audio_status)
if StoryAssetStatus.GENERATING in (image_status, audio_status):
return StoryGenerationStatus.ASSETS_GENERATING
if StoryAssetStatus.FAILED in (image_status, audio_status):
return StoryGenerationStatus.DEGRADED_COMPLETED
if (
image_status == StoryAssetStatus.NOT_REQUESTED
and audio_status == StoryAssetStatus.NOT_REQUESTED
):
return StoryGenerationStatus.NARRATIVE_READY
return StoryGenerationStatus.COMPLETED
def has_failed_assets(story: StoryLike) -> bool:
"""Whether any persisted asset is still in a failed state."""
image_status = _normalize_asset_status(story.image_status)
audio_status = _normalize_asset_status(story.audio_status)
return StoryAssetStatus.FAILED in (image_status, audio_status)
def sync_story_status(
story: StoryLike,
*,
image_status: StoryAssetStatus | None = None,
audio_status: StoryAssetStatus | None = None,
last_error: str | None | object = _ERROR_UNSET,
) -> None:
"""Update asset statuses and refresh overall generation status."""
if image_status is not None:
story.image_status = image_status.value
if audio_status is not None:
story.audio_status = audio_status.value
if last_error is not _ERROR_UNSET:
story.last_error = last_error
generation_status = resolve_story_generation_status(story)
story.generation_status = generation_status.value
if last_error is _ERROR_UNSET and not has_failed_assets(story):
story.last_error = None

View File

@@ -1,4 +1,4 @@
"""测试配置和 fixtures"""
"""Pytest fixtures for backend tests."""
import os
from collections.abc import AsyncGenerator
@@ -11,6 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_asyn
os.environ.setdefault("SECRET_KEY", "test-secret-key-for-testing")
os.environ.setdefault("DATABASE_URL", "sqlite+aiosqlite:///:memory:")
from app.core.config import settings
from app.core.security import create_access_token
from app.db.database import get_db
from app.db.models import Base, Story, User
@@ -19,7 +20,8 @@ from app.main import app
@pytest.fixture
async def async_engine():
"""创建内存数据库引擎。"""
"""Create an in-memory database engine."""
engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
@@ -29,7 +31,8 @@ async def async_engine():
@pytest.fixture
async def db_session(async_engine) -> AsyncGenerator[AsyncSession, None]:
"""创建数据库会话。"""
"""Create a database session."""
session_factory = async_sessionmaker(
async_engine, class_=AsyncSession, expire_on_commit=False
)
@@ -39,7 +42,8 @@ async def db_session(async_engine) -> AsyncGenerator[AsyncSession, None]:
@pytest.fixture
async def test_user(db_session: AsyncSession) -> User:
"""创建测试用户。"""
"""Create a test user."""
user = User(
id="github:12345",
name="Test User",
@@ -54,13 +58,74 @@ async def test_user(db_session: AsyncSession) -> User:
@pytest.fixture
async def test_story(db_session: AsyncSession, test_user: User) -> Story:
"""创建测试故事。"""
"""Create a plain generated story."""
story = Story(
user_id=test_user.id,
title="测试故事",
story_text="从前有一只小兔子...",
story_text="从前有一只小兔子",
cover_prompt="A cute rabbit in a forest",
mode="generated",
generation_status="narrative_ready",
image_status="not_requested",
audio_status="not_requested",
)
db_session.add(story)
await db_session.commit()
await db_session.refresh(story)
return story
@pytest.fixture
async def storybook_story(db_session: AsyncSession, test_user: User) -> Story:
"""Create a storybook-mode story."""
story = Story(
user_id=test_user.id,
title="森林绘本冒险",
story_text=None,
pages=[
{
"page_number": 1,
"text": "小兔子走进了会发光的森林。",
"image_prompt": "A glowing forest with a curious rabbit",
"image_url": "https://example.com/page-1.png",
},
{
"page_number": 2,
"text": "它遇见了一位会唱歌的萤火虫朋友。",
"image_prompt": "A rabbit meeting a singing firefly",
"image_url": None,
},
],
cover_prompt="A magical forest storybook cover",
image_url="https://example.com/storybook-cover.png",
mode="storybook",
generation_status="degraded_completed",
image_status="failed",
audio_status="not_requested",
last_error="第 2 页插图生成失败",
)
db_session.add(story)
await db_session.commit()
await db_session.refresh(story)
return story
@pytest.fixture
async def degraded_story_with_text(db_session: AsyncSession, test_user: User) -> Story:
"""Create a readable story whose image generation already failed."""
story = Story(
user_id=test_user.id,
title="部分完成的测试故事",
story_text="从前有一只小兔子继续冒险。",
cover_prompt="A rabbit under the moon",
mode="generated",
generation_status="degraded_completed",
image_status="failed",
audio_status="not_requested",
last_error="封面生成失败",
)
db_session.add(story)
await db_session.commit()
@@ -70,13 +135,14 @@ async def test_story(db_session: AsyncSession, test_user: User) -> Story:
@pytest.fixture
def auth_token(test_user: User) -> str:
"""生成测试用户的 JWT token"""
"""Create a JWT token for the test user."""
return create_access_token({"sub": test_user.id})
@pytest.fixture
def client(db_session: AsyncSession) -> TestClient:
"""创建测试客户端。"""
"""Create a test client."""
async def override_get_db():
yield db_session
@@ -89,35 +155,45 @@ def client(db_session: AsyncSession) -> TestClient:
@pytest.fixture
def auth_client(client: TestClient, auth_token: str) -> TestClient:
"""带认证的测试客户端。"""
"""Create an authenticated test client."""
client.cookies.set("access_token", auth_token)
return client
@pytest.fixture(autouse=True)
def bypass_rate_limit():
"""默认绕过限流,让非限流测试正常运行。"""
"""Bypass rate limiting in most tests."""
with patch("app.core.rate_limiter.get_redis", new_callable=AsyncMock) as mock_redis:
# 创建一个模拟的 Redis 客户端,所有操作返回安全默认值
redis_instance = AsyncMock()
redis_instance.incr.return_value = 1 # 始终返回 1 (不触发限流)
redis_instance.incr.return_value = 1
redis_instance.expire.return_value = True
redis_instance.get.return_value = None # 无锁定记录
redis_instance.get.return_value = None
redis_instance.ttl.return_value = 0
redis_instance.delete.return_value = 1
mock_redis.return_value = redis_instance
yield redis_instance
@pytest.fixture(autouse=True)
def isolated_story_audio_cache(tmp_path, monkeypatch):
"""Use an isolated directory for cached story audio files."""
monkeypatch.setattr(settings, "story_audio_cache_dir", str(tmp_path / "audio"))
yield
@pytest.fixture
def mock_text_provider():
"""Mock 文本生成适配器 API 调用。"""
"""Mock text generation."""
from app.services.adapters.text.models import StoryOutput
mock_result = StoryOutput(
mode="generated",
title="小兔子的冒险",
story_text="从前有一只小兔子...",
story_text="从前有一只小兔子",
cover_prompt_suggestion="A cute rabbit",
)
@@ -128,7 +204,8 @@ def mock_text_provider():
@pytest.fixture
def mock_image_provider():
"""Mock 图像生成。"""
"""Mock image generation."""
with patch("app.services.story_service.generate_image", new_callable=AsyncMock) as mock:
mock.return_value = "https://example.com/image.png"
yield mock
@@ -136,7 +213,8 @@ def mock_image_provider():
@pytest.fixture
def mock_tts_provider():
"""Mock TTS。"""
"""Mock text-to-speech generation."""
with patch("app.services.provider_router.text_to_speech", new_callable=AsyncMock) as mock:
mock.return_value = b"fake-audio-bytes"
yield mock
@@ -144,7 +222,8 @@ def mock_tts_provider():
@pytest.fixture
def mock_all_providers(mock_text_provider, mock_image_provider, mock_tts_provider):
"""Mock 所有 AI 供应商。"""
"""Group all mocked providers."""
return {
"text_primary": mock_text_provider,
"image_primary": mock_image_provider,

View File

@@ -1,26 +1,41 @@
"""故事 API 测试。"""
"""Tests for story-related API endpoints."""
from pathlib import Path
from unittest.mock import AsyncMock, patch
import pytest
from fastapi.testclient import TestClient
from app.core.config import settings
from app.services.adapters.storybook.primary import Storybook, StorybookPage
# ── 注意 ──────────────────────────────────────────────────────────────────────
# 以下路由尚未实现 (stories.py 中没有对应端点),相关测试标记为 skip:
# GET /api/stories (列表)
# GET /api/stories/{id} (详情)
# DELETE /api/stories/{id} (删除)
# POST /api/image/generate/{id} (封面图片生成)
# GET /api/audio/{id} (音频)
# 实现后请取消 skip 标记。
def build_storybook_output() -> Storybook:
"""Create a reusable mocked storybook payload."""
return Storybook(
title="森林里的发光冒险",
main_character="小兔子露露",
art_style="温暖水彩",
cover_prompt="A glowing forest storybook cover",
pages=[
StorybookPage(
page_number=1,
text="露露第一次走进会发光的森林。",
image_prompt="Lulu entering a glowing forest",
),
StorybookPage(
page_number=2,
text="她遇到了一只会唱歌的萤火虫。",
image_prompt="Lulu meeting a singing firefly",
),
],
)
class TestStoryGenerate:
"""故事生成测试。"""
"""Tests for basic story generation."""
def test_generate_without_auth(self, client: TestClient):
"""未登录时生成故事。"""
response = client.post(
"/api/stories/generate",
json={"type": "keywords", "data": "小兔子, 森林"},
@@ -28,7 +43,6 @@ class TestStoryGenerate:
assert response.status_code == 401
def test_generate_with_empty_data(self, auth_client: TestClient):
"""空数据生成故事。"""
response = auth_client.post(
"/api/stories/generate",
json={"type": "keywords", "data": ""},
@@ -36,7 +50,6 @@ class TestStoryGenerate:
assert response.status_code == 422
def test_generate_with_invalid_type(self, auth_client: TestClient):
"""无效类型生成故事。"""
response = auth_client.post(
"/api/stories/generate",
json={"type": "invalid", "data": "test"},
@@ -44,7 +57,6 @@ class TestStoryGenerate:
assert response.status_code == 422
def test_generate_story_success(self, auth_client: TestClient, mock_text_provider):
"""成功生成故事。"""
response = auth_client.post(
"/api/stories/generate",
json={"type": "keywords", "data": "小兔子, 森林, 勇气"},
@@ -55,82 +67,96 @@ class TestStoryGenerate:
assert "title" in data
assert "story_text" in data
assert data["mode"] == "generated"
assert data["generation_status"] == "narrative_ready"
assert data["image_status"] == "not_requested"
assert data["audio_status"] == "not_requested"
assert data["last_error"] is None
class TestStoryList:
"""故事列表测试。"""
"""Tests for story listing."""
def test_list_without_auth(self, client: TestClient):
"""未登录时获取列表。"""
response = client.get("/api/stories")
assert response.status_code == 401
def test_list_empty(self, auth_client: TestClient):
"""空列表。"""
response = auth_client.get("/api/stories")
assert response.status_code == 200
assert response.json() == []
def test_list_with_stories(self, auth_client: TestClient, test_story):
"""有故事时获取列表。"""
response = auth_client.get("/api/stories")
assert response.status_code == 200
data = response.json()
assert len(data) == 1
assert data[0]["id"] == test_story.id
assert data[0]["title"] == test_story.title
assert data[0]["generation_status"] == "narrative_ready"
assert data[0]["image_status"] == "not_requested"
assert data[0]["audio_status"] == "not_requested"
def test_list_pagination(self, auth_client: TestClient, test_story):
"""分页测试。"""
response = auth_client.get("/api/stories?limit=1&offset=0")
assert response.status_code == 200
data = response.json()
assert len(data) == 1
assert len(response.json()) == 1
response = auth_client.get("/api/stories?limit=1&offset=1")
assert response.status_code == 200
data = response.json()
assert len(data) == 0
assert len(response.json()) == 0
class TestStoryDetail:
"""故事详情测试。"""
"""Tests for story detail retrieval."""
def test_get_story_without_auth(self, client: TestClient, test_story):
"""未登录时获取详情。"""
response = client.get(f"/api/stories/{test_story.id}")
assert response.status_code == 401
def test_get_story_not_found(self, auth_client: TestClient):
"""故事不存在。"""
response = auth_client.get("/api/stories/99999")
assert response.status_code == 404
def test_get_story_success(self, auth_client: TestClient, test_story):
"""成功获取详情。"""
response = auth_client.get(f"/api/stories/{test_story.id}")
assert response.status_code == 200
data = response.json()
assert data["id"] == test_story.id
assert data["title"] == test_story.title
assert data["story_text"] == test_story.story_text
assert data["generation_status"] == "narrative_ready"
assert data["image_status"] == "not_requested"
assert data["audio_status"] == "not_requested"
assert data["last_error"] is None
def test_get_storybook_success(self, auth_client: TestClient, storybook_story):
response = auth_client.get(f"/api/stories/{storybook_story.id}")
assert response.status_code == 200
data = response.json()
assert data["id"] == storybook_story.id
assert data["mode"] == "storybook"
assert data["story_text"] is None
assert len(data["pages"]) == 2
assert data["pages"][0]["page_number"] == 1
assert data["image_url"] == "https://example.com/storybook-cover.png"
assert data["generation_status"] == "degraded_completed"
assert data["image_status"] == "failed"
assert data["audio_status"] == "not_requested"
assert "第 2 页" in data["last_error"]
class TestStoryDelete:
"""故事删除测试。"""
"""Tests for story deletion."""
def test_delete_without_auth(self, client: TestClient, test_story):
"""未登录时删除。"""
response = client.delete(f"/api/stories/{test_story.id}")
assert response.status_code == 401
def test_delete_not_found(self, auth_client: TestClient):
"""删除不存在的故事。"""
response = auth_client.delete("/api/stories/99999")
assert response.status_code == 404
def test_delete_success(self, auth_client: TestClient, test_story):
"""成功删除故事。"""
response = auth_client.delete(f"/api/stories/{test_story.id}")
assert response.status_code == 200
assert response.json()["message"] == "Deleted"
@@ -140,11 +166,14 @@ class TestStoryDelete:
class TestRateLimit:
"""Rate limit 测试。"""
"""Tests for story generation rate limiting."""
def test_rate_limit_allows_normal_requests(self, auth_client: TestClient, mock_text_provider, bypass_rate_limit):
"""正常请求不触发限流。"""
# bypass_rate_limit 默认 incr 返回 1不触发限流
def test_rate_limit_allows_normal_requests(
self,
auth_client: TestClient,
mock_text_provider,
bypass_rate_limit,
):
for _ in range(3):
response = auth_client.post(
"/api/stories/generate",
@@ -152,9 +181,11 @@ class TestRateLimit:
)
assert response.status_code == 200
def test_rate_limit_blocks_excess_requests(self, auth_client: TestClient, bypass_rate_limit):
"""超限请求被阻止。"""
# 让 incr 返回超限值 (> RATE_LIMIT_REQUESTS)
def test_rate_limit_blocks_excess_requests(
self,
auth_client: TestClient,
bypass_rate_limit,
):
bypass_rate_limit.incr.return_value = 11
response = auth_client.post(
@@ -166,52 +197,118 @@ class TestRateLimit:
class TestImageGenerate:
"""封面图片生成测试。"""
"""Tests for cover generation endpoint."""
def test_generate_image_without_auth(self, client: TestClient, test_story):
"""未登录时生成图片。"""
response = client.post(f"/api/image/generate/{test_story.id}")
assert response.status_code == 401
def test_generate_image_not_found(self, auth_client: TestClient):
"""故事不存在。"""
response = auth_client.post("/api/image/generate/99999")
assert response.status_code == 404
class TestAudio:
"""语音朗读测试。"""
"""Tests for story audio endpoint."""
def test_get_audio_without_auth(self, client: TestClient, test_story):
"""未登录时获取音频。"""
response = client.get(f"/api/audio/{test_story.id}")
assert response.status_code == 401
def test_get_audio_not_found(self, auth_client: TestClient):
"""故事不存在。"""
response = auth_client.get("/api/audio/99999")
assert response.status_code == 404
def test_get_audio_success(self, auth_client: TestClient, test_story, mock_tts_provider):
"""成功获取音频。"""
def test_get_audio_success(
self,
auth_client: TestClient,
test_story,
mock_tts_provider,
):
response = auth_client.get(f"/api/audio/{test_story.id}")
assert response.status_code == 200
assert response.headers["content-type"] == "audio/mpeg"
assert response.content == b"fake-audio-bytes"
cached_audio_path = Path(settings.story_audio_cache_dir) / f"story-{test_story.id}.mp3"
assert cached_audio_path.is_file()
second_response = auth_client.get(f"/api/audio/{test_story.id}")
assert second_response.status_code == 200
assert second_response.content == b"fake-audio-bytes"
mock_tts_provider.assert_awaited_once()
detail_response = auth_client.get(f"/api/stories/{test_story.id}")
detail = detail_response.json()
assert detail["audio_status"] == "ready"
assert detail["generation_status"] == "completed"
assert detail["last_error"] is None
def test_get_audio_regenerates_when_cache_file_is_missing(
self,
auth_client: TestClient,
test_story,
mock_tts_provider,
):
first_response = auth_client.get(f"/api/audio/{test_story.id}")
assert first_response.status_code == 200
cached_audio_path = Path(settings.story_audio_cache_dir) / f"story-{test_story.id}.mp3"
cached_audio_path.unlink()
mock_tts_provider.reset_mock()
second_response = auth_client.get(f"/api/audio/{test_story.id}")
assert second_response.status_code == 200
assert second_response.content == b"fake-audio-bytes"
assert cached_audio_path.is_file()
mock_tts_provider.assert_awaited_once()
def test_get_audio_failure_updates_status(self, auth_client: TestClient, test_story):
with patch("app.services.provider_router.text_to_speech", new_callable=AsyncMock) as mock_tts:
mock_tts.side_effect = Exception("TTS provider timeout")
response = auth_client.get(f"/api/audio/{test_story.id}")
assert response.status_code == 500
detail_response = auth_client.get(f"/api/stories/{test_story.id}")
detail = detail_response.json()
assert detail["audio_status"] == "failed"
assert detail["generation_status"] == "degraded_completed"
assert "TTS provider timeout" in detail["last_error"]
def test_get_audio_success_preserves_existing_image_error(
self,
auth_client: TestClient,
degraded_story_with_text,
mock_tts_provider,
):
response = auth_client.get(f"/api/audio/{degraded_story_with_text.id}")
assert response.status_code == 200
assert response.content == b"fake-audio-bytes"
mock_tts_provider.assert_awaited_once()
detail_response = auth_client.get(f"/api/stories/{degraded_story_with_text.id}")
detail = detail_response.json()
assert detail["audio_status"] == "ready"
assert detail["generation_status"] == "degraded_completed"
assert detail["last_error"] == "封面生成失败"
class TestGenerateFull:
"""完整故事生成测试(/api/stories/generate/full"""
"""Tests for complete story generation."""
def test_generate_full_without_auth(self, client: TestClient):
"""未登录时生成完整故事。"""
response = client.post(
"/api/stories/generate/full",
json={"type": "keywords", "data": "小兔子, 森林"},
)
assert response.status_code == 401
def test_generate_full_success(self, auth_client: TestClient, mock_text_provider, mock_image_provider):
"""成功生成完整故事(含图片)。"""
def test_generate_full_success(
self,
auth_client: TestClient,
mock_text_provider,
mock_image_provider,
):
response = auth_client.post(
"/api/stories/generate/full",
json={"type": "keywords", "data": "小兔子, 森林, 勇气"},
@@ -223,11 +320,14 @@ class TestGenerateFull:
assert "story_text" in data
assert data["mode"] == "generated"
assert data["image_url"] == "https://example.com/image.png"
assert data["audio_ready"] is False # 音频按需生成
assert data["audio_ready"] is False
assert data["errors"] == {}
assert data["generation_status"] == "completed"
assert data["image_status"] == "ready"
assert data["audio_status"] == "not_requested"
assert data["last_error"] is None
def test_generate_full_image_failure(self, auth_client: TestClient, mock_text_provider):
"""图片生成失败时返回部分成功。"""
with patch("app.services.story_service.generate_image", new_callable=AsyncMock) as mock_img:
mock_img.side_effect = Exception("Image API error")
response = auth_client.post(
@@ -239,9 +339,17 @@ class TestGenerateFull:
assert data["image_url"] is None
assert "image" in data["errors"]
assert "Image API error" in data["errors"]["image"]
assert data["generation_status"] == "degraded_completed"
assert data["image_status"] == "failed"
assert data["audio_status"] == "not_requested"
assert "Image API error" in data["last_error"]
def test_generate_full_with_education_theme(self, auth_client: TestClient, mock_text_provider, mock_image_provider):
"""带教育主题生成故事。"""
def test_generate_full_with_education_theme(
self,
auth_client: TestClient,
mock_text_provider,
mock_image_provider,
):
response = auth_client.post(
"/api/stories/generate/full",
json={
@@ -257,11 +365,80 @@ class TestGenerateFull:
class TestImageGenerateSuccess:
"""封面图片生成成功测试。"""
"""Tests for successful cover generation."""
def test_generate_image_success(self, auth_client: TestClient, test_story, mock_image_provider):
"""成功生成图片。"""
def test_generate_image_success(
self,
auth_client: TestClient,
test_story,
mock_image_provider,
):
response = auth_client.post(f"/api/image/generate/{test_story.id}")
assert response.status_code == 200
data = response.json()
assert data["image_url"] == "https://example.com/image.png"
assert data["generation_status"] == "completed"
assert data["image_status"] == "ready"
assert data["audio_status"] == "not_requested"
assert data["last_error"] is None
class TestStorybookGenerate:
"""Tests for storybook generation status handling."""
def test_generate_storybook_success(self, auth_client: TestClient):
with patch("app.services.story_service.generate_storybook", new_callable=AsyncMock) as mock_storybook:
with patch("app.services.story_service.generate_image", new_callable=AsyncMock) as mock_image:
mock_storybook.return_value = build_storybook_output()
mock_image.side_effect = [
"https://example.com/storybook-cover.png",
"https://example.com/storybook-page-1.png",
"https://example.com/storybook-page-2.png",
]
response = auth_client.post(
"/api/storybook/generate",
json={
"keywords": "森林, 发光, 友情",
"page_count": 6,
"generate_images": True,
},
)
assert response.status_code == 200
data = response.json()
assert data["id"] is not None
assert data["generation_status"] == "completed"
assert data["image_status"] == "ready"
assert data["audio_status"] == "not_requested"
assert data["last_error"] is None
assert len(data["pages"]) == 2
assert data["cover_url"] == "https://example.com/storybook-cover.png"
def test_generate_storybook_partial_image_failure(self, auth_client: TestClient):
async def image_side_effect(prompt: str, **kwargs):
if "singing firefly" in prompt:
raise Exception("Image API error")
slug = prompt.split()[0].lower()
return f"https://example.com/{slug}.png"
with patch("app.services.story_service.generate_storybook", new_callable=AsyncMock) as mock_storybook:
with patch("app.services.story_service.generate_image", new_callable=AsyncMock) as mock_image:
mock_storybook.return_value = build_storybook_output()
mock_image.side_effect = image_side_effect
response = auth_client.post(
"/api/storybook/generate",
json={
"keywords": "森林, 发光, 友情",
"page_count": 6,
"generate_images": True,
},
)
assert response.status_code == 200
data = response.json()
assert data["generation_status"] == "degraded_completed"
assert data["image_status"] == "failed"
assert data["audio_status"] == "not_requested"
assert "第 2 页插图生成失败" in data["last_error"]

View File

@@ -17,6 +17,10 @@ export interface Storybook {
pages: StorybookPage[]
cover_prompt: string
cover_url?: string
generation_status?: string
image_status?: string
audio_status?: string
last_error?: string | null
}
export const useStorybookStore = defineStore('storybook', () => {

View File

@@ -0,0 +1,79 @@
export type StoryGenerationStatus =
| 'narrative_ready'
| 'assets_generating'
| 'completed'
| 'degraded_completed'
| 'failed'
export type StoryAssetStatus =
| 'not_requested'
| 'generating'
| 'ready'
| 'failed'
interface StatusMeta {
label: string
description: string
badgeClass: string
}
const generationStatusMetaMap: Record<StoryGenerationStatus, StatusMeta> = {
narrative_ready: {
label: '文本已完成',
description: '故事内容已经生成,可以继续补充封面或音频。',
badgeClass: 'bg-sky-50 text-sky-700 border border-sky-100',
},
assets_generating: {
label: '资源生成中',
description: '封面或音频正在生成中,请稍候查看结果。',
badgeClass: 'bg-amber-50 text-amber-700 border border-amber-100',
},
completed: {
label: '内容可用',
description: '当前内容已经达到可阅读状态。',
badgeClass: 'bg-emerald-50 text-emerald-700 border border-emerald-100',
},
degraded_completed: {
label: '部分降级完成',
description: '核心内容可用,但有部分资源生成失败。',
badgeClass: 'bg-orange-50 text-orange-700 border border-orange-100',
},
failed: {
label: '生成失败',
description: '当前内容还未成功生成,请稍后重试。',
badgeClass: 'bg-rose-50 text-rose-700 border border-rose-100',
},
}
const assetStatusMetaMap: Record<StoryAssetStatus, StatusMeta> = {
not_requested: {
label: '未请求',
description: '还没有发起该资源生成。',
badgeClass: 'bg-slate-100 text-slate-600 border border-slate-200',
},
generating: {
label: '生成中',
description: '资源正在生成,请稍候。',
badgeClass: 'bg-amber-50 text-amber-700 border border-amber-100',
},
ready: {
label: '已就绪',
description: '该资源可使用。',
badgeClass: 'bg-emerald-50 text-emerald-700 border border-emerald-100',
},
failed: {
label: '失败',
description: '最近一次生成失败,可以稍后重试。',
badgeClass: 'bg-rose-50 text-rose-700 border border-rose-100',
},
}
export function getGenerationStatusMeta(status?: string): StatusMeta {
return generationStatusMetaMap[(status ?? 'narrative_ready') as StoryGenerationStatus]
?? generationStatusMetaMap.narrative_ready
}
export function getAssetStatusMeta(status?: string): StatusMeta {
return assetStatusMetaMap[(status ?? 'not_requested') as StoryAssetStatus]
?? assetStatusMetaMap.not_requested
}

View File

@@ -1,19 +1,20 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { api } from '../api/client'
import CreateStoryModal from '../components/CreateStoryModal.vue'
import BaseButton from '../components/ui/BaseButton.vue'
import BaseCard from '../components/ui/BaseCard.vue'
import LoadingSpinner from '../components/ui/LoadingSpinner.vue'
import EmptyState from '../components/ui/EmptyState.vue'
import CreateStoryModal from '../components/CreateStoryModal.vue'
import LoadingSpinner from '../components/ui/LoadingSpinner.vue'
import { getAssetStatusMeta, getGenerationStatusMeta } from '../utils/storyStatus'
import {
BookOpenIcon,
ChevronRightIcon,
ExclamationCircleIcon,
PhotoIcon,
SparklesIcon,
PlusIcon,
SparklesIcon,
} from '@heroicons/vue/24/outline'
interface StoryItem {
@@ -21,6 +22,11 @@ interface StoryItem {
title: string
image_url: string | null
created_at: string
mode: string
generation_status: string
image_status: string
audio_status: string
last_error: string | null
}
const router = useRouter()
@@ -29,6 +35,16 @@ const loading = ref(true)
const error = ref('')
const showCreateModal = ref(false)
const completedCount = computed(() =>
stories.value.filter((story) => story.generation_status === 'completed').length,
)
const attentionCount = computed(() =>
stories.value.filter((story) =>
['degraded_completed', 'failed'].includes(story.generation_status),
).length,
)
async function fetchStories() {
try {
stories.value = await api.get<StoryItem[]>('/api/stories')
@@ -60,8 +76,13 @@ function goToCreate() {
showCreateModal.value = true
}
function getStoryLink(story: StoryItem) {
return story.mode === 'storybook' ? `/storybook/view/${story.id}` : `/story/${story.id}`
}
onMounted(() => {
fetchStories()
void fetchStories()
if (router.currentRoute.value.query.openCreate) {
showCreateModal.value = true
router.replace({ query: { ...router.currentRoute.value.query, openCreate: undefined } })
@@ -71,11 +92,10 @@ onMounted(() => {
<template>
<div class="max-w-6xl mx-auto px-4">
<!-- 页面标题 -->
<div class="flex items-center justify-between mb-8">
<div>
<h1 class="text-3xl font-bold gradient-text mb-2">我的故事</h1>
<p class="text-gray-500">收藏的所有童话故事</p>
<p class="text-gray-500">回看每个作品的生成质量资源状态和可优化点</p>
</div>
<BaseButton @click="goToCreate">
<SparklesIcon class="h-5 w-5 mr-2" />
@@ -83,12 +103,10 @@ onMounted(() => {
</BaseButton>
</div>
<!-- 加载状态 -->
<div v-if="loading" class="py-20">
<LoadingSpinner text="加载中..." />
<LoadingSpinner text="正在加载内容..." />
</div>
<!-- 错误状态 -->
<div v-else-if="error" class="py-10">
<EmptyState
:icon="ExclamationCircleIcon"
@@ -97,54 +115,53 @@ onMounted(() => {
/>
</div>
<!-- 空状态 -->
<div v-else-if="stories.length === 0" class="py-10">
<EmptyState
:icon="BookOpenIcon"
title="开始你的创作之旅"
description="还没有创作任何故事,现在就开始为孩子创作第一个专属童话故事吧!"
title="从第一个作品开始"
description="现在还没有故事或绘本,先做一个能完整跑通的版本,后面再持续优化。"
>
<template #action>
<BaseButton @click="goToCreate">
<PlusIcon class="h-5 w-5 mr-2" />
创作第一个故事
创作第一个作品
</BaseButton>
</template>
</EmptyState>
</div>
<!-- 故事列表 -->
<template v-else>
<!-- 统计卡片 -->
<BaseCard class="mb-8" padding="lg">
<div class="flex items-center justify-around divide-x divide-gray-100">
<div class="text-center px-4">
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div class="text-center px-4 py-2">
<div class="text-3xl font-bold text-gray-800">{{ stories.length }}</div>
<div class="text-gray-500 text-sm mt-1">故事总数</div>
<div class="text-gray-500 text-sm mt-1">内容总数</div>
</div>
<div class="text-center px-4">
<div class="text-center px-4 py-2">
<div class="text-3xl font-bold text-gray-800">
{{ stories.filter(s => s.image_url).length }}
{{ stories.filter((story) => story.mode === 'storybook').length }}
</div>
<div class="text-gray-500 text-sm mt-1">已配图</div>
<div class="text-gray-500 text-sm mt-1">绘本数量</div>
</div>
<div class="text-center px-4">
<BookOpenIcon class="h-8 w-8 text-purple-500 mx-auto" />
<div class="text-gray-500 text-sm mt-1">继续阅读</div>
<div class="text-center px-4 py-2">
<div class="text-3xl font-bold text-gray-800">{{ completedCount }}</div>
<div class="text-gray-500 text-sm mt-1">完整可用</div>
</div>
<div class="text-center px-4 py-2">
<div class="text-3xl font-bold text-gray-800">{{ attentionCount }}</div>
<div class="text-gray-500 text-sm mt-1">待补资源</div>
</div>
</div>
</BaseCard>
<!-- 故事网格 -->
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
<router-link
v-for="story in stories"
:key="story.id"
:to="`/story/${story.id}`"
:to="getStoryLink(story)"
class="block group"
>
<BaseCard hover padding="none" class="h-full overflow-hidden flex flex-col">
<!-- 封面图 -->
<div class="relative aspect-[4/3] overflow-hidden bg-gray-100">
<img
v-if="story.image_url"
@@ -159,23 +176,64 @@ onMounted(() => {
<PhotoIcon class="h-12 w-12" />
</div>
<!-- 悬停阅读提示 -->
<div class="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity duration-300 flex items-center justify-center">
<div class="absolute top-4 left-4 flex flex-wrap gap-2">
<span
class="px-2.5 py-1 rounded-full text-xs font-medium backdrop-blur-sm"
:class="story.mode === 'storybook' ? 'bg-amber-100/90 text-amber-800' : 'bg-violet-100/90 text-violet-800'"
>
{{ story.mode === 'storybook' ? '绘本' : '故事' }}
</span>
<span
class="px-2.5 py-1 rounded-full text-xs font-medium backdrop-blur-sm"
:class="getGenerationStatusMeta(story.generation_status).badgeClass"
>
{{ getGenerationStatusMeta(story.generation_status).label }}
</span>
</div>
<div class="absolute inset-0 bg-black/35 opacity-0 group-hover:opacity-100 transition-opacity duration-300 flex items-center justify-center">
<span class="inline-flex items-center gap-1 px-4 py-2 bg-white/90 text-gray-900 rounded-full font-medium shadow-lg backdrop-blur-sm transform translate-y-4 group-hover:translate-y-0 transition-transform duration-300">
阅读故事 <ChevronRightIcon class="h-4 w-4" />
{{ story.mode === 'storybook' ? '阅读绘本' : '阅读故事' }}
<ChevronRightIcon class="h-4 w-4" />
</span>
</div>
</div>
<!-- 信息区 -->
<div class="p-5 flex-1 flex flex-col">
<h3 class="font-bold text-xl text-gray-800 mb-2 line-clamp-2 group-hover:text-purple-600 transition-colors">
<h3 class="font-bold text-xl text-gray-800 mb-3 line-clamp-2 group-hover:text-purple-600 transition-colors">
{{ story.title }}
</h3>
<p class="text-sm text-gray-500 mb-4 leading-6">
{{ getGenerationStatusMeta(story.generation_status).description }}
</p>
<div class="flex flex-wrap gap-2 mb-4">
<span
class="px-2 py-1 rounded-lg text-xs font-medium"
:class="getAssetStatusMeta(story.image_status).badgeClass"
>
封面{{ getAssetStatusMeta(story.image_status).label }}
</span>
<span
class="px-2 py-1 rounded-lg text-xs font-medium"
:class="getAssetStatusMeta(story.audio_status).badgeClass"
>
音频{{ getAssetStatusMeta(story.audio_status).label }}
</span>
</div>
<div
v-if="story.last_error"
class="mb-4 px-3 py-2 rounded-xl bg-amber-50 text-amber-700 text-sm line-clamp-2"
>
{{ story.last_error }}
</div>
<div class="mt-auto flex items-center justify-between text-sm text-gray-500">
<span>{{ formatDate(story.created_at) }}</span>
<span v-if="story.image_url" class="flex items-center gap-1 text-green-600 bg-green-50 px-2 py-0.5 rounded text-xs font-medium">
已配图
<span>
{{ story.image_url ? '已有封面' : '待补封面' }}
</span>
</div>
</div>

View File

@@ -1,10 +1,11 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
<script setup lang="ts">
import { computed, onUnmounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { api } from '../api/client'
import BaseButton from '../components/ui/BaseButton.vue'
import ConfirmModal from '../components/ui/ConfirmModal.vue'
import LoadingSpinner from '../components/ui/LoadingSpinner.vue'
import { getAssetStatusMeta, getGenerationStatusMeta } from '../utils/storyStatus'
import {
ArrowLeftIcon,
ExclamationTriangleIcon,
@@ -12,21 +13,38 @@ import {
SpeakerWaveIcon,
SparklesIcon,
TrashIcon,
XMarkIcon,
} from '@heroicons/vue/24/outline'
const route = useRoute()
const router = useRouter()
interface Story {
id: number
title: string
story_text: string
story_text: string | null
cover_prompt: string | null
image_url: string | null
mode: string
generation_status: string
image_status: string
audio_status: string
last_error: string | null
pages?: Array<{
page_number: number
text: string
image_prompt: string
image_url?: string | null
}> | null
}
interface ImageGenerationResponse {
image_url: string | null
generation_status: string
image_status: string
audio_status: string
last_error: string | null
}
const route = useRoute()
const router = useRouter()
const story = ref<Story | null>(null)
const loading = ref(true)
const imageLoading = ref(false)
@@ -38,11 +56,29 @@ const audioProgress = ref(0)
const audioDuration = ref(0)
const error = ref('')
const showDeleteConfirm = ref(false)
const imageGenerationFailed = ref(false)
const storyParagraphs = computed(() => story.value?.story_text?.split('\n\n') ?? [])
const generationMeta = computed(() => getGenerationStatusMeta(story.value?.generation_status))
const imageMeta = computed(() => getAssetStatusMeta(story.value?.image_status))
const audioMeta = computed(() => getAssetStatusMeta(story.value?.audio_status))
async function refreshStorySnapshot() {
const data = await api.get<Story>(`/api/stories/${route.params.id}`)
if (data.mode === 'storybook') {
await router.replace(`/storybook/view/${data.id}`)
return
}
story.value = data
}
async function fetchStory() {
loading.value = true
error.value = ''
try {
story.value = await api.get<Story>(`/api/stories/${route.params.id}`)
await refreshStorySnapshot()
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
} finally {
@@ -52,12 +88,20 @@ async function fetchStory() {
async function generateImage() {
if (!story.value) return
imageLoading.value = true
error.value = ''
try {
const result = await api.post<{ image_url: string }>(`/api/image/generate/${story.value.id}`)
const result = await api.post<ImageGenerationResponse>(`/api/image/generate/${story.value.id}`)
story.value.image_url = result.image_url
story.value.generation_status = result.generation_status
story.value.image_status = result.image_status
story.value.audio_status = result.audio_status
story.value.last_error = result.last_error
} catch (e) {
error.value = e instanceof Error ? e.message : '图片生成失败'
await refreshStorySnapshot().catch(() => undefined)
} finally {
imageLoading.value = false
}
@@ -65,16 +109,25 @@ async function generateImage() {
async function loadAudio() {
if (!story.value || audioUrl.value) return
audioLoading.value = true
error.value = ''
try {
const response = await fetch(`/api/audio/${story.value.id}`, {
credentials: 'include',
})
if (!response.ok) throw new Error('音频加载失败')
if (!response.ok) {
const payload = await response.json().catch(() => ({ detail: '音频加载失败' }))
throw new Error(payload.detail || '音频加载失败')
}
const blob = await response.blob()
audioUrl.value = URL.createObjectURL(blob)
await refreshStorySnapshot().catch(() => undefined)
} catch (e) {
error.value = e instanceof Error ? e.message : '音频加载失败'
await refreshStorySnapshot().catch(() => undefined)
} finally {
audioLoading.value = false
}
@@ -82,24 +135,29 @@ async function loadAudio() {
function togglePlay() {
if (!audioRef.value) return
if (isPlaying.value) {
audioRef.value.pause()
} else {
audioRef.value.play()
void audioRef.value.play()
}
isPlaying.value = !isPlaying.value
}
function updateProgress() {
if (!audioRef.value) return
audioProgress.value = audioRef.value.currentTime
audioDuration.value = audioRef.value.duration || 0
}
function seekAudio(e: MouseEvent) {
function seekAudio(event: MouseEvent) {
if (!audioRef.value || !audioDuration.value) return
const rect = (e.target as HTMLElement).getBoundingClientRect()
const percent = (e.clientX - rect.left) / rect.width
const target = event.currentTarget as HTMLElement
const rect = target.getBoundingClientRect()
const percent = (event.clientX - rect.left) / rect.width
audioRef.value.currentTime = percent * audioDuration.value
}
@@ -111,9 +169,10 @@ function formatTime(seconds: number) {
async function deleteStory() {
if (!story.value) return
try {
await api.delete(`/api/stories/${story.value.id}`)
router.push('/my-stories')
await router.push('/my-stories')
} catch (e) {
error.value = e instanceof Error ? e.message : '删除失败'
}
@@ -124,12 +183,14 @@ async function confirmDelete() {
await deleteStory()
}
onMounted(() => {
fetchStory()
if (route.query.imageError === '1') {
imageGenerationFailed.value = true
}
})
watch(
() => route.params.id,
() => {
story.value = null
void fetchStory()
},
{ immediate: true },
)
onUnmounted(() => {
if (audioUrl.value) {
@@ -139,7 +200,7 @@ onUnmounted(() => {
</script>
<template>
<div class="max-w-4xl mx-auto px-4">
<div class="max-w-5xl mx-auto px-4">
<div v-if="loading" class="py-20">
<LoadingSpinner size="lg" text="正在加载故事..." />
</div>
@@ -156,29 +217,16 @@ onUnmounted(() => {
返回
</BaseButton>
<Transition
enter-active-class="transition-all duration-300"
enter-from-class="opacity-0 -translate-y-2"
enter-to-class="opacity-100 translate-y-0"
>
<div
v-if="imageGenerationFailed && !story?.image_url"
class="p-4 bg-amber-50 border border-amber-200 text-amber-700 rounded-xl flex items-center justify-between"
v-if="story.last_error"
class="p-4 bg-amber-50 border border-amber-200 text-amber-700 rounded-2xl flex items-start gap-3"
>
<div class="flex items-center space-x-2">
<ExclamationTriangleIcon class="h-5 w-5" />
<span>封面生成失败您可以稍后重试</span>
<ExclamationTriangleIcon class="h-5 w-5 mt-0.5 flex-shrink-0" />
<div>
<div class="font-semibold mb-1">最近一次资源生成异常</div>
<p class="text-sm leading-6">{{ story.last_error }}</p>
</div>
<BaseButton
variant="ghost"
size="sm"
class="text-amber-500 hover:text-amber-700"
@click="imageGenerationFailed = false"
>
<XMarkIcon class="h-5 w-5" />
</BaseButton>
</div>
</Transition>
<div class="glass rounded-3xl shadow-2xl overflow-hidden">
<div class="relative aspect-[21/9] bg-gradient-to-br from-purple-100 via-pink-100 to-blue-100 overflow-hidden">
@@ -188,28 +236,60 @@ onUnmounted(() => {
:alt="story.title"
class="w-full h-full object-cover"
/>
<div v-else class="absolute inset-0 flex flex-col items-center justify-center">
<div v-else class="absolute inset-0 flex flex-col items-center justify-center px-6 text-center">
<PhotoIcon class="h-16 w-16 text-purple-400 mb-4" />
<p class="text-gray-600 mb-4 max-w-md">
{{ imageMeta.description }}
</p>
<BaseButton
v-if="story.cover_prompt"
variant="secondary"
:loading="imageLoading"
@click="generateImage"
>
<template v-if="imageLoading">AI 正在绘制...</template>
<template v-else>生成精美封面</template>
<template v-if="imageLoading">正在生成封面...</template>
<template v-else>{{ story.image_status === 'failed' ? '重试生成封面' : '生成封面' }}</template>
</BaseButton>
</div>
<div class="absolute bottom-0 left-0 right-0 h-32 bg-gradient-to-t from-white/80 to-transparent"></div>
<div class="absolute bottom-0 left-0 right-0 h-32 bg-gradient-to-t from-white/80 to-transparent" />
</div>
<div class="p-8 md:p-12 -mt-16 relative">
<h1 class="text-3xl md:text-4xl font-bold gradient-text mb-8 leading-tight">
<div class="flex flex-wrap items-start justify-between gap-4 mb-6">
<h1 class="text-3xl md:text-4xl font-bold gradient-text leading-tight">
{{ story.title }}
</h1>
<span
class="px-3 py-1.5 rounded-full text-sm font-medium"
:class="generationMeta.badgeClass"
>
{{ generationMeta.label }}
</span>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-10">
<div class="rounded-2xl border border-gray-100 bg-white/80 p-5">
<div class="text-sm text-gray-500 mb-2">整体状态</div>
<div class="font-semibold text-gray-800 mb-2">{{ generationMeta.label }}</div>
<p class="text-sm text-gray-500 leading-6">{{ generationMeta.description }}</p>
</div>
<div class="rounded-2xl border border-gray-100 bg-white/80 p-5">
<div class="text-sm text-gray-500 mb-2">封面资源</div>
<div class="font-semibold text-gray-800 mb-2">{{ imageMeta.label }}</div>
<p class="text-sm text-gray-500 leading-6">{{ imageMeta.description }}</p>
</div>
<div class="rounded-2xl border border-gray-100 bg-white/80 p-5">
<div class="text-sm text-gray-500 mb-2">音频资源</div>
<div class="font-semibold text-gray-800 mb-2">{{ audioMeta.label }}</div>
<p class="text-sm text-gray-500 leading-6">
{{ audioMeta.description }} 音频首次生成后会缓存复用状态记录的是当前可播放结果
</p>
</div>
</div>
<div class="prose prose-lg max-w-none mb-10">
<p
v-for="(paragraph, index) in story.story_text.split('\n\n')"
v-for="(paragraph, index) in storyParagraphs"
:key="index"
class="text-gray-700 leading-loose mb-6 first-letter:text-4xl first-letter:font-bold first-letter:text-purple-600 first-letter:float-left first-letter:mr-2"
>
@@ -224,10 +304,10 @@ onUnmounted(() => {
@click="loadAudio"
class="mx-auto"
>
<template v-if="audioLoading">加载中...</template>
<template v-if="audioLoading">正在准备音频...</template>
<template v-else>
<SpeakerWaveIcon class="h-5 w-5" />
听故事
听故事
</template>
</BaseButton>
</div>
@@ -247,10 +327,10 @@ onUnmounted(() => {
@click="togglePlay"
>
<svg v-if="!isPlaying" class="w-6 h-6 ml-1" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
<path d="M8 5v14l11-7z" />
</svg>
<svg v-else class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/>
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" />
</svg>
</BaseButton>
@@ -262,7 +342,7 @@ onUnmounted(() => {
<div
class="h-full bg-gradient-to-r from-purple-500 to-pink-500 rounded-full transition-all duration-100"
:style="{ width: `${(audioProgress / audioDuration) * 100 || 0}%` }"
></div>
/>
</div>
<div class="flex justify-between text-sm text-gray-500 mt-1">
<span>{{ formatTime(audioProgress) }}</span>
@@ -301,7 +381,7 @@ onUnmounted(() => {
<ConfirmModal
:show="showDeleteConfirm"
title="确定删除这个故事吗?"
message="删除后将无法恢复"
message="删除后将无法恢复"
confirm-text="确定删除"
cancel-text="取消"
variant="danger"

View File

@@ -1,34 +1,86 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { computed, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { api } from '../api/client'
import { useStorybookStore } from '../stores/storybook'
import BaseButton from '../components/ui/BaseButton.vue'
import LoadingSpinner from '../components/ui/LoadingSpinner.vue'
import { getAssetStatusMeta, getGenerationStatusMeta } from '../utils/storyStatus'
import {
ArrowLeftIcon,
ArrowRightIcon,
HomeIcon,
BookOpenIcon,
ExclamationTriangleIcon,
HomeIcon,
PhotoIcon,
SparklesIcon,
PhotoIcon
} from '@heroicons/vue/24/outline'
interface StoryDetailResponse {
id: number
title: string
story_text: string | null
pages: Array<{
page_number: number
text: string
image_prompt: string
image_url?: string | null
}> | null
cover_prompt: string | null
image_url: string | null
mode: string
generation_status: string
image_status: string
audio_status: string
last_error: string | null
}
const route = useRoute()
const router = useRouter()
const store = useStorybookStore()
const storybook = computed(() => store.currentStorybook)
const loading = ref(true)
const error = ref('')
const currentPageIndex = ref(-1)
const currentPageIndex = ref(-1) // -1 for cover
// 计算属性
const totalPages = computed(() => storybook.value?.pages.length || 0)
const isCover = computed(() => currentPageIndex.value === -1)
const isLastPage = computed(() => currentPageIndex.value === totalPages.value - 1)
const generationMeta = computed(() => getGenerationStatusMeta(storybook.value?.generation_status))
const imageMeta = computed(() => getAssetStatusMeta(storybook.value?.image_status))
const audioMeta = computed(() => getAssetStatusMeta(storybook.value?.audio_status))
const currentPage = computed(() => {
if (!storybook.value || isCover.value) return null
return storybook.value.pages[currentPageIndex.value]
})
const pageImageMessage = computed(() => {
if (storybook.value?.image_status === 'failed') {
return '部分插图生成失败,但不影响继续阅读。'
}
if (storybook.value?.image_status === 'generating') {
return '插图正在生成中,请稍后刷新查看。'
}
if (storybook.value?.image_status === 'not_requested') {
return '当前绘本还没有发起插图生成。'
}
return currentPage.value?.image_prompt
? `${currentPage.value.image_prompt}`
: '当前页插图暂未就绪。'
})
const currentStoryId = computed(() => {
const rawId = route.params.id
const normalized = Array.isArray(rawId) ? rawId[0] : rawId
if (!normalized) return null
const parsed = Number(normalized)
return Number.isFinite(parsed) ? parsed : null
})
// 导航
function goHome() {
store.clearStorybook()
router.push('/')
@@ -46,16 +98,89 @@ function prevPage() {
}
}
onMounted(() => {
if (!storybook.value) {
router.push('/')
async function loadStorybook() {
loading.value = true
error.value = ''
currentPageIndex.value = -1
const storyId = currentStoryId.value
const cachedStorybook = store.currentStorybook
if (storyId === null) {
if (cachedStorybook) {
loading.value = false
return
}
})
await router.replace('/')
loading.value = false
return
}
if (cachedStorybook?.id === storyId) {
loading.value = false
return
}
try {
const detail = await api.get<StoryDetailResponse>(`/api/stories/${storyId}`)
if (detail.mode !== 'storybook') {
await router.replace(`/story/${detail.id}`)
return
}
store.setStorybook({
id: detail.id,
title: detail.title,
main_character: cachedStorybook?.id === detail.id ? cachedStorybook.main_character : '故事主角',
art_style: cachedStorybook?.id === detail.id ? cachedStorybook.art_style : 'AI 绘本风格',
pages: (detail.pages ?? []).map((page) => ({
...page,
image_url: page.image_url ?? undefined,
})),
cover_prompt: detail.cover_prompt ?? '',
cover_url: detail.image_url ?? undefined,
generation_status: detail.generation_status,
image_status: detail.image_status,
audio_status: detail.audio_status,
last_error: detail.last_error,
})
} catch (e) {
error.value = e instanceof Error ? e.message : '绘本加载失败'
} finally {
loading.value = false
}
}
watch(
() => route.params.id,
() => {
void loadStorybook()
},
{ immediate: true },
)
</script>
<template>
<div class="storybook-viewer" v-if="storybook">
<!-- 导航栏 -->
<div v-if="loading" class="h-screen bg-[#0D0F1A] flex items-center justify-center px-4">
<LoadingSpinner size="lg" text="正在载入绘本..." />
</div>
<div v-else-if="error" class="h-screen bg-[#0D0F1A] flex items-center justify-center px-4">
<div class="max-w-md text-center text-white">
<ExclamationTriangleIcon class="w-14 h-14 mx-auto mb-4 text-amber-400" />
<p class="text-lg mb-6">{{ error }}</p>
<div class="flex items-center justify-center gap-3">
<BaseButton @click="loadStorybook">重新加载</BaseButton>
<BaseButton variant="ghost" class="text-white border-white/20 hover:bg-white/10" @click="goHome">
返回首页
</BaseButton>
</div>
</div>
</div>
<div v-else-if="storybook" class="storybook-viewer">
<nav class="fixed top-0 left-0 right-0 z-50 p-4 flex justify-between items-center bg-gradient-to-b from-black/50 to-transparent">
<button @click="goHome" class="p-2 rounded-full bg-white/10 backdrop-blur hover:bg-white/20 text-white transition-all">
<HomeIcon class="w-6 h-6" />
@@ -63,39 +188,31 @@ onMounted(() => {
<div class="text-white font-serif text-lg text-shadow">
{{ storybook.title }}
</div>
<div class="w-10"></div> <!-- 占位 -->
<div class="w-10"></div>
</nav>
<!-- 主展示区 -->
<div class="h-screen w-full flex items-center justify-center p-4 md:p-8 relative overflow-hidden">
<!-- 动态背景 -->
<div class="absolute inset-0 bg-[#0D0F1A] z-0">
<div class="absolute inset-0 bg-gradient-to-br from-[#1a1a2e] to-[#0D0F1A]"></div>
<div class="stars"></div>
</div>
<!-- 书页容器 -->
<div class="book-container relative z-10 w-full max-w-5xl aspect-[16/10] bg-[#fffbf0] rounded-2xl shadow-2xl overflow-hidden flex transition-all duration-500">
<!-- 封面模式 -->
<div v-if="isCover" class="w-full h-full flex flex-col md:flex-row animate-fade-in">
<!-- 封面图 -->
<div class="w-full md:w-1/2 h-1/2 md:h-full relative overflow-hidden bg-gray-900 group">
<template v-if="storybook.cover_url">
<img :src="storybook.cover_url" class="w-full h-full object-cover transition-transform duration-1000 group-hover:scale-105" />
</template>
<div v-else class="w-full h-full flex flex-col items-center justify-center p-8 text-center bg-gradient-to-br from-indigo-900 to-purple-900 text-white">
<SparklesIcon class="w-20 h-20 mb-4 opacity-50" />
<p class="text-white/60 text-sm">封面正在构思中...</p>
<p class="text-white/70 text-sm max-w-xs leading-6">{{ imageMeta.description }}</p>
</div>
<!-- 封面遮罩 -->
<div class="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent md:bg-gradient-to-r"></div>
<div class="absolute bottom-6 left-6 text-white md:hidden">
<span class="inline-block px-3 py-1 bg-yellow-500/90 rounded-full text-xs font-bold mb-2 text-black">绘本故事</span>
</div>
</div>
<!-- 封面信息 -->
<div class="w-full md:w-1/2 h-1/2 md:h-full p-8 md:p-16 flex flex-col justify-center bg-[#fffbf0] text-amber-900">
<div class="hidden md:block mb-8">
<span class="inline-block px-4 py-1 border border-amber-900/30 rounded-full text-sm tracking-widest uppercase">Original Storybook</span>
@@ -104,24 +221,46 @@ onMounted(() => {
<h1 class="text-4xl md:text-6xl font-serif font-bold mb-6 leading-tight">{{ storybook.title }}</h1>
<div class="space-y-4 mb-10 text-amber-900/70">
<p class="flex items-center"><span class="w-20 font-bold opacity-50">主角</span> {{ storybook.main_character }}</p>
<p class="flex items-center"><span class="w-20 font-bold opacity-50">画风</span> {{ storybook.art_style }}</p>
<p class="flex items-center"><span class="w-20 font-bold opacity-50">主角</span> {{ storybook.main_character || '故事主角' }}</p>
<p class="flex items-center"><span class="w-20 font-bold opacity-50">画风</span> {{ storybook.art_style || 'AI 绘本风格' }}</p>
</div>
<div class="flex flex-wrap gap-2 mb-4">
<span class="px-3 py-1 rounded-full text-xs font-semibold" :class="generationMeta.badgeClass">
{{ generationMeta.label }}
</span>
<span class="px-3 py-1 rounded-full text-xs font-semibold" :class="imageMeta.badgeClass">
插图{{ imageMeta.label }}
</span>
<span class="px-3 py-1 rounded-full text-xs font-semibold" :class="audioMeta.badgeClass">
音频{{ audioMeta.label }}
</span>
</div>
<p class="text-sm leading-6 text-amber-900/70 mb-6">
{{ generationMeta.description }}
</p>
<div
v-if="storybook.last_error"
class="mb-8 rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800"
>
<div class="font-semibold mb-1">最近一次资源异常</div>
<p class="leading-6">{{ storybook.last_error }}</p>
</div>
<BaseButton size="lg" @click="nextPage" class="self-start shadow-xl hover:shadow-2xl hover:-translate-y-1 transition-all">
开始阅读 <BookOpenIcon class="w-5 h-5 ml-2" />
开始阅读
<BookOpenIcon class="w-5 h-5 ml-2" />
</BaseButton>
</div>
</div>
<!-- 内页模式 -->
<div v-else class="w-full h-full flex flex-col md:flex-row animate-fade-in relative">
<!-- 页码 -->
<div class="absolute bottom-4 right-6 text-amber-900/30 font-serif text-xl z-20">
{{ currentPageIndex + 1 }} / {{ totalPages }}
</div>
<!-- 插图区域 () -->
<div class="w-full md:w-1/2 h-1/2 md:h-full relative bg-gray-100 border-r border-amber-900/5">
<template v-if="currentPage?.image_url">
<img :src="currentPage.image_url" class="w-full h-full object-cover" />
@@ -131,12 +270,19 @@ onMounted(() => {
<div class="inline-block p-6 rounded-full bg-amber-50 mb-4">
<PhotoIcon class="w-10 h-10 text-amber-300" />
</div>
<p class="text-amber-900/40 text-sm max-w-xs mx-auto italic">"{{ currentPage?.image_prompt }}"</p>
<p class="text-amber-900/50 text-sm max-w-xs mx-auto italic leading-6">
{{ pageImageMessage }}
</p>
<p
v-if="storybook.last_error && storybook.image_status === 'failed'"
class="mt-3 text-xs font-medium text-amber-700"
>
可以先继续阅读稍后再回来看插图结果
</p>
</div>
</div>
</div>
<!-- 文字区域 () -->
<div class="w-full md:w-1/2 h-1/2 md:h-full p-8 md:p-16 flex items-center justify-center bg-[#fffbf0]">
<div class="prose prose-xl prose-amber font-serif text-amber-900 leading-relaxed text-center md:text-left">
<p>{{ currentPage?.text }}</p>
@@ -145,7 +291,6 @@ onMounted(() => {
</div>
</div>
<!-- 翻页控制 (悬浮) -->
<button
v-if="!isCover"
@click="prevPage"
@@ -162,7 +307,6 @@ onMounted(() => {
<ArrowRightIcon class="w-6 h-6 md:w-8 md:h-8" />
</button>
<!-- 最后一页的完成按钮 -->
<BaseButton
v-if="isLastPage"
@click="goHome"
@@ -170,14 +314,13 @@ onMounted(() => {
>
读完了再来一本
</BaseButton>
</div>
</div>
</template>
<style scoped>
.text-shadow {
text-shadow: 0 2px 4px rgba(0,0,0,0.5);
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.5);
}
.animate-fade-in {