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
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:
1
backend/.gitignore
vendored
1
backend/.gitignore
vendored
@@ -25,3 +25,4 @@ htmlcov/
|
|||||||
# 其他
|
# 其他
|
||||||
*.log
|
*.log
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
storage/
|
||||||
|
|||||||
151
backend/alembic/versions/0009_add_story_generation_statuses.py
Normal file
151
backend/alembic/versions/0009_add_story_generation_statuses.py
Normal 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")
|
||||||
25
backend/alembic/versions/0010_add_story_audio_cache_path.py
Normal file
25
backend/alembic/versions/0010_add_story_audio_cache_path.py
Normal 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")
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
"""Story related APIs."""
|
"""Story related APIs."""
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import json
|
import json
|
||||||
import uuid
|
import uuid
|
||||||
from typing import AsyncGenerator
|
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 sse_starlette.sse import EventSourceResponse
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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.database import get_db
|
||||||
from app.db.models import User
|
from app.db.models import User
|
||||||
from app.schemas.story_schemas import (
|
from app.schemas.story_schemas import (
|
||||||
GenerateRequest,
|
AchievementItem,
|
||||||
StoryResponse,
|
|
||||||
FullStoryResponse,
|
FullStoryResponse,
|
||||||
|
GenerateRequest,
|
||||||
|
StoryDetailResponse,
|
||||||
|
StoryImageResponse,
|
||||||
|
StoryListItem,
|
||||||
|
StoryResponse,
|
||||||
StorybookRequest,
|
StorybookRequest,
|
||||||
StorybookResponse,
|
StorybookResponse,
|
||||||
StoryListItem,
|
|
||||||
AchievementItem,
|
|
||||||
)
|
)
|
||||||
from app.services import story_service
|
from app.services import story_service
|
||||||
from app.services.memory_service import build_enhanced_memory_context
|
from app.services.memory_service import build_enhanced_memory_context
|
||||||
@@ -29,6 +30,7 @@ from app.services.provider_router import (
|
|||||||
generate_story_content,
|
generate_story_content,
|
||||||
generate_image,
|
generate_image,
|
||||||
)
|
)
|
||||||
|
from app.services.story_status import StoryAssetStatus, sync_story_status
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -62,7 +64,6 @@ async def generate_story_full(
|
|||||||
@router.post("/stories/generate/stream")
|
@router.post("/stories/generate/stream")
|
||||||
async def generate_story_stream(
|
async def generate_story_stream(
|
||||||
request: GenerateRequest,
|
request: GenerateRequest,
|
||||||
req: Request,
|
|
||||||
user: User = Depends(require_user),
|
user: User = Depends(require_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
@@ -110,22 +111,71 @@ async def generate_story_stream(
|
|||||||
"mode": story.mode,
|
"mode": story.mode,
|
||||||
"child_profile_id": story.child_profile_id,
|
"child_profile_id": story.child_profile_id,
|
||||||
"universe_id": story.universe_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
|
# Step 2: Generate Image
|
||||||
if story.cover_prompt:
|
if story.cover_prompt:
|
||||||
|
sync_story_status(story, image_status=StoryAssetStatus.GENERATING)
|
||||||
|
await db.commit()
|
||||||
try:
|
try:
|
||||||
# Direct call to provider router's generate_image, sharing db session
|
# Direct call to provider router's generate_image, sharing db session
|
||||||
image_url = await generate_image(story.cover_prompt, db=db)
|
image_url = await generate_image(story.cover_prompt, db=db)
|
||||||
story.image_url = image_url
|
story.image_url = image_url
|
||||||
|
sync_story_status(
|
||||||
|
story,
|
||||||
|
image_status=StoryAssetStatus.READY,
|
||||||
|
)
|
||||||
await db.commit()
|
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:
|
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))
|
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())
|
return EventSourceResponse(event_generator())
|
||||||
|
|
||||||
@@ -154,7 +204,7 @@ async def list_stories(
|
|||||||
return await story_service.list_stories(user.id, limit, offset, db)
|
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(
|
async def get_story(
|
||||||
story_id: int,
|
story_id: int,
|
||||||
user: User = Depends(require_user),
|
user: User = Depends(require_user),
|
||||||
@@ -175,7 +225,7 @@ async def delete_story(
|
|||||||
return {"message": "Deleted"}
|
return {"message": "Deleted"}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/image/generate/{story_id}")
|
@router.post("/image/generate/{story_id}", response_model=StoryImageResponse)
|
||||||
async def generate_story_image(
|
async def generate_story_image(
|
||||||
story_id: int,
|
story_id: int,
|
||||||
user: User = Depends(require_user),
|
user: User = Depends(require_user),
|
||||||
@@ -183,7 +233,14 @@ async def generate_story_image(
|
|||||||
):
|
):
|
||||||
"""Generate cover image for story."""
|
"""Generate cover image for story."""
|
||||||
url = await story_service.generate_story_cover(story_id, user.id, db)
|
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}")
|
@router.get("/audio/{story_id}")
|
||||||
|
|||||||
@@ -53,6 +53,10 @@ class Settings(BaseSettings):
|
|||||||
text_providers: list[str] = Field(default_factory=lambda: ["gemini"])
|
text_providers: list[str] = Field(default_factory=lambda: ["gemini"])
|
||||||
image_providers: list[str] = Field(default_factory=lambda: ["cqtai"])
|
image_providers: list[str] = Field(default_factory=lambda: ["cqtai"])
|
||||||
tts_providers: list[str] = Field(default_factory=lambda: ["minimax", "elevenlabs", "edge_tts"])
|
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 (Redis)
|
||||||
celery_broker_url: str = Field("redis://localhost:6379/0")
|
celery_broker_url: str = Field("redis://localhost:6379/0")
|
||||||
|
|||||||
@@ -27,10 +27,10 @@ class User(Base):
|
|||||||
|
|
||||||
__tablename__ = "users"
|
__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)
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
avatar_url: Mapped[str | None] = mapped_column(String(500))
|
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(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True), server_default=func.now()
|
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
|
String(36), ForeignKey("story_universes.id", ondelete="SET NULL"), nullable=True
|
||||||
)
|
)
|
||||||
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
story_text: Mapped[str] = mapped_column(Text, nullable=True) # 允许为空(绘本模式下)
|
story_text: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
pages: Mapped[list[dict] | None] = mapped_column(JSON, default=list) # 绘本分页数据
|
pages: Mapped[list[dict] | None] = mapped_column(JSON, default=list)
|
||||||
cover_prompt: Mapped[str | None] = mapped_column(Text)
|
cover_prompt: Mapped[str | None] = mapped_column(Text)
|
||||||
image_url: Mapped[str | None] = mapped_column(String(500))
|
image_url: Mapped[str | None] = mapped_column(String(500))
|
||||||
mode: Mapped[str] = mapped_column(String(20), nullable=False, default="generated")
|
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(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True), server_default=func.now()
|
DateTime(timezone=True), server_default=func.now()
|
||||||
)
|
)
|
||||||
@@ -123,6 +134,7 @@ class ChildProfile(Base):
|
|||||||
|
|
||||||
class StoryUniverse(Base):
|
class StoryUniverse(Base):
|
||||||
"""Story universe entity."""
|
"""Story universe entity."""
|
||||||
|
|
||||||
__tablename__ = "story_universes"
|
__tablename__ = "story_universes"
|
||||||
|
|
||||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_uuid)
|
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()
|
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):
|
class ReadingEvent(Base):
|
||||||
@@ -163,6 +177,7 @@ class ReadingEvent(Base):
|
|||||||
DateTime(timezone=True), server_default=func.now(), index=True
|
DateTime(timezone=True), server_default=func.now(), index=True
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class PushConfig(Base):
|
class PushConfig(Base):
|
||||||
"""Push configuration entity."""
|
"""Push configuration entity."""
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""故事相关 Pydantic 模型。"""
|
"""Story-related Pydantic schemas."""
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
@@ -11,7 +11,13 @@ MAX_EDU_THEME_LENGTH = 200
|
|||||||
MAX_TTS_LENGTH = 4000
|
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):
|
class GenerateRequest(BaseModel):
|
||||||
@@ -24,8 +30,8 @@ class GenerateRequest(BaseModel):
|
|||||||
universe_id: str | None = None
|
universe_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class StoryResponse(BaseModel):
|
class StoryResponse(StoryStatusMixin):
|
||||||
"""Story response."""
|
"""Story generation response."""
|
||||||
|
|
||||||
id: int
|
id: int
|
||||||
title: str
|
title: str
|
||||||
@@ -37,7 +43,7 @@ class StoryResponse(BaseModel):
|
|||||||
universe_id: str | None = None
|
universe_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class StoryListItem(BaseModel):
|
class StoryListItem(StoryStatusMixin):
|
||||||
"""Story list item."""
|
"""Story list item."""
|
||||||
|
|
||||||
id: int
|
id: int
|
||||||
@@ -47,8 +53,8 @@ class StoryListItem(BaseModel):
|
|||||||
mode: str
|
mode: str
|
||||||
|
|
||||||
|
|
||||||
class FullStoryResponse(BaseModel):
|
class FullStoryResponse(StoryStatusMixin):
|
||||||
"""完整故事响应(含图片和音频状态)。"""
|
"""Full story response with asset status."""
|
||||||
|
|
||||||
id: int
|
id: int
|
||||||
title: str
|
title: str
|
||||||
@@ -62,22 +68,19 @@ class FullStoryResponse(BaseModel):
|
|||||||
universe_id: str | None = None
|
universe_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
# ==================== 绘本模型 ====================
|
|
||||||
|
|
||||||
|
|
||||||
class StorybookRequest(BaseModel):
|
class StorybookRequest(BaseModel):
|
||||||
"""Storybook 生成请求。"""
|
"""Storybook generation request."""
|
||||||
|
|
||||||
keywords: str = Field(..., min_length=1, max_length=200)
|
keywords: str = Field(..., min_length=1, max_length=200)
|
||||||
page_count: int = Field(default=6, ge=4, le=12)
|
page_count: int = Field(default=6, ge=4, le=12)
|
||||||
education_theme: str | None = Field(default=None, max_length=MAX_EDU_THEME_LENGTH)
|
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
|
child_profile_id: str | None = None
|
||||||
universe_id: str | None = None
|
universe_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class StorybookPageResponse(BaseModel):
|
class StorybookPageResponse(BaseModel):
|
||||||
"""故事书单页响应。"""
|
"""One storybook page."""
|
||||||
|
|
||||||
page_number: int
|
page_number: int
|
||||||
text: str
|
text: str
|
||||||
@@ -85,8 +88,8 @@ class StorybookPageResponse(BaseModel):
|
|||||||
image_url: str | None = None
|
image_url: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class StorybookResponse(BaseModel):
|
class StorybookResponse(StoryStatusMixin):
|
||||||
"""故事书响应。"""
|
"""Storybook generation response."""
|
||||||
|
|
||||||
id: int | None = None
|
id: int | None = None
|
||||||
title: str
|
title: str
|
||||||
@@ -97,10 +100,29 @@ class StorybookResponse(BaseModel):
|
|||||||
cover_url: str | None = None
|
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):
|
class AchievementItem(BaseModel):
|
||||||
|
"""Achievement item returned for a story."""
|
||||||
|
|
||||||
type: str
|
type: str
|
||||||
description: str
|
description: str
|
||||||
obtained_at: str | None = None
|
obtained_at: str | None = None
|
||||||
|
|||||||
38
backend/app/services/audio_storage.py
Normal file
38
backend/app/services/audio_storage.py
Normal 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)
|
||||||
@@ -1,12 +1,9 @@
|
|||||||
"""Story business logic service."""
|
"""Story business logic service."""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
|
||||||
import uuid
|
|
||||||
from typing import Literal
|
|
||||||
|
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from sqlalchemy import select, desc
|
from sqlalchemy import desc, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import joinedload
|
from sqlalchemy.orm import joinedload
|
||||||
|
|
||||||
@@ -20,17 +17,78 @@ from app.schemas.story_schemas import (
|
|||||||
StorybookPageResponse,
|
StorybookPageResponse,
|
||||||
AchievementItem,
|
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.memory_service import build_enhanced_memory_context
|
||||||
from app.services.provider_router import (
|
from app.services.provider_router import (
|
||||||
generate_story_content,
|
generate_story_content,
|
||||||
generate_image,
|
generate_image,
|
||||||
generate_storybook,
|
generate_storybook,
|
||||||
)
|
)
|
||||||
|
from app.services.story_status import (
|
||||||
|
StoryAssetStatus,
|
||||||
|
sync_story_status,
|
||||||
|
)
|
||||||
from app.tasks.achievements import extract_story_achievements
|
from app.tasks.achievements import extract_story_achievements
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
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(
|
async def validate_profile_and_universe(
|
||||||
profile_id: str | None,
|
profile_id: str | None,
|
||||||
universe_id: str | None,
|
universe_id: str | None,
|
||||||
@@ -108,6 +166,12 @@ async def generate_and_save_story(
|
|||||||
cover_prompt=result.cover_prompt_suggestion,
|
cover_prompt=result.cover_prompt_suggestion,
|
||||||
mode=result.mode,
|
mode=result.mode,
|
||||||
)
|
)
|
||||||
|
sync_story_status(
|
||||||
|
story,
|
||||||
|
image_status=StoryAssetStatus.NOT_REQUESTED,
|
||||||
|
audio_status=StoryAssetStatus.NOT_REQUESTED,
|
||||||
|
last_error=None,
|
||||||
|
)
|
||||||
db.add(story)
|
db.add(story)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(story)
|
await db.refresh(story)
|
||||||
@@ -135,12 +199,24 @@ async def generate_full_story_service(
|
|||||||
errors: dict[str, str | None] = {}
|
errors: dict[str, str | None] = {}
|
||||||
|
|
||||||
if story.cover_prompt:
|
if story.cover_prompt:
|
||||||
|
sync_story_status(story, image_status=StoryAssetStatus.GENERATING)
|
||||||
|
await db.commit()
|
||||||
try:
|
try:
|
||||||
image_url = await generate_image(story.cover_prompt, db=db)
|
image_url = await generate_image(story.cover_prompt, db=db)
|
||||||
story.image_url = image_url
|
story.image_url = image_url
|
||||||
|
sync_story_status(
|
||||||
|
story,
|
||||||
|
image_status=StoryAssetStatus.READY,
|
||||||
|
)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
errors["image"] = str(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))
|
logger.warning("image_generation_failed", story_id=story.id, error=str(exc))
|
||||||
|
|
||||||
return FullStoryResponse(
|
return FullStoryResponse(
|
||||||
@@ -154,6 +230,10 @@ async def generate_full_story_service(
|
|||||||
errors=errors,
|
errors=errors,
|
||||||
child_profile_id=story.child_profile_id,
|
child_profile_id=story.child_profile_id,
|
||||||
universe_id=story.universe_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
|
# 4. Parallel Image Generation
|
||||||
final_cover_url = storybook.cover_url
|
final_cover_url = storybook.cover_url
|
||||||
|
cover_failed = False
|
||||||
|
failed_pages: list[int] = []
|
||||||
|
|
||||||
if request.generate_images:
|
if request.generate_images:
|
||||||
logger.info("storybook_parallel_generation_start", page_count=len(storybook.pages))
|
logger.info("storybook_parallel_generation_start", page_count=len(storybook.pages))
|
||||||
|
|
||||||
tasks = []
|
tasks = []
|
||||||
|
|
||||||
# Cover Task
|
|
||||||
async def _gen_cover():
|
async def _gen_cover():
|
||||||
|
nonlocal cover_failed
|
||||||
|
|
||||||
if storybook.cover_prompt and not storybook.cover_url:
|
if storybook.cover_prompt and not storybook.cover_url:
|
||||||
try:
|
try:
|
||||||
return await generate_image(storybook.cover_prompt, db=db)
|
return await generate_image(storybook.cover_prompt, db=db)
|
||||||
except Exception as e:
|
except Exception as exc:
|
||||||
logger.warning("cover_gen_failed", error=str(e))
|
cover_failed = True
|
||||||
|
logger.warning("cover_gen_failed", error=str(exc))
|
||||||
return storybook.cover_url
|
return storybook.cover_url
|
||||||
|
|
||||||
tasks.append(_gen_cover())
|
tasks.append(_gen_cover())
|
||||||
|
|
||||||
# Page Tasks
|
|
||||||
async def _gen_page(page):
|
async def _gen_page(page):
|
||||||
if page.image_prompt and not page.image_url:
|
if page.image_prompt and not page.image_url:
|
||||||
try:
|
try:
|
||||||
url = await generate_image(page.image_prompt, db=db)
|
page.image_url = await generate_image(page.image_prompt, db=db)
|
||||||
page.image_url = url
|
except Exception as exc:
|
||||||
except Exception as e:
|
failed_pages.append(page.page_number)
|
||||||
logger.warning("page_gen_failed", page=page.page_number, error=str(e))
|
logger.warning("page_gen_failed", page=page.page_number, error=str(exc))
|
||||||
|
|
||||||
for page in storybook.pages:
|
for page in storybook.pages:
|
||||||
tasks.append(_gen_page(page))
|
tasks.append(_gen_page(page))
|
||||||
|
|
||||||
# Execute
|
|
||||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
|
||||||
# Update cover result
|
|
||||||
cover_res = results[0]
|
cover_res = results[0]
|
||||||
if isinstance(cover_res, str):
|
if isinstance(cover_res, str):
|
||||||
final_cover_url = cover_res
|
final_cover_url = cover_res
|
||||||
@@ -254,6 +337,20 @@ async def generate_storybook_service(
|
|||||||
cover_prompt=storybook.cover_prompt,
|
cover_prompt=storybook.cover_prompt,
|
||||||
image_url=final_cover_url,
|
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)
|
db.add(story)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(story)
|
await db.refresh(story)
|
||||||
@@ -280,6 +377,10 @@ async def generate_storybook_service(
|
|||||||
pages=response_pages,
|
pages=response_pages,
|
||||||
cover_prompt=storybook.cover_prompt,
|
cover_prompt=storybook.cover_prompt,
|
||||||
cover_url=final_cover_url,
|
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,
|
cover_prompt=result.cover_prompt_suggestion,
|
||||||
mode=result.mode,
|
mode=result.mode,
|
||||||
)
|
)
|
||||||
|
sync_story_status(
|
||||||
|
story,
|
||||||
|
image_status=StoryAssetStatus.NOT_REQUESTED,
|
||||||
|
audio_status=StoryAssetStatus.NOT_REQUESTED,
|
||||||
|
last_error=None,
|
||||||
|
)
|
||||||
db.add(story)
|
db.add(story)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(story)
|
await db.refresh(story)
|
||||||
@@ -366,12 +473,25 @@ async def generate_story_cover(
|
|||||||
if not story.cover_prompt:
|
if not story.cover_prompt:
|
||||||
raise HTTPException(status_code=400, detail="Story has no 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:
|
try:
|
||||||
image_url = await generate_image(story.cover_prompt, db=db)
|
image_url = await generate_image(story.cover_prompt, db=db)
|
||||||
story.image_url = image_url
|
story.image_url = image_url
|
||||||
|
sync_story_status(
|
||||||
|
story,
|
||||||
|
image_status=StoryAssetStatus.READY,
|
||||||
|
)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
return image_url
|
return image_url
|
||||||
except Exception as e:
|
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))
|
logger.error("cover_generation_failed", story_id=story_id, error=str(e))
|
||||||
raise HTTPException(status_code=500, detail=f"Image generation failed: {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:
|
if not story.story_text:
|
||||||
raise HTTPException(status_code=400, detail="Story has no text")
|
raise HTTPException(status_code=400, detail="Story has no text")
|
||||||
|
|
||||||
# TODO: Check if audio is already cached/saved?
|
if story.audio_path and audio_cache_exists(story.audio_path):
|
||||||
# For now, generate on the fly via provider
|
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
|
from app.services.provider_router import text_to_speech
|
||||||
|
|
||||||
|
sync_story_status(story, audio_status=StoryAssetStatus.GENERATING)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
audio_data = await text_to_speech(story.story_text, db=db)
|
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
|
return audio_data
|
||||||
except Exception as e:
|
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))
|
logger.error("audio_generation_failed", story_id=story_id, error=str(e))
|
||||||
raise HTTPException(status_code=500, detail=f"Audio generation failed: {e}")
|
raise HTTPException(status_code=500, detail=f"Audio generation failed: {e}")
|
||||||
|
|
||||||
|
|||||||
112
backend/app/services/story_status.py
Normal file
112
backend/app/services/story_status.py
Normal 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
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
"""测试配置和 fixtures。"""
|
"""Pytest fixtures for backend tests."""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
from collections.abc import AsyncGenerator
|
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("SECRET_KEY", "test-secret-key-for-testing")
|
||||||
os.environ.setdefault("DATABASE_URL", "sqlite+aiosqlite:///:memory:")
|
os.environ.setdefault("DATABASE_URL", "sqlite+aiosqlite:///:memory:")
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
from app.core.security import create_access_token
|
from app.core.security import create_access_token
|
||||||
from app.db.database import get_db
|
from app.db.database import get_db
|
||||||
from app.db.models import Base, Story, User
|
from app.db.models import Base, Story, User
|
||||||
@@ -19,7 +20,8 @@ from app.main import app
|
|||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
async def async_engine():
|
async def async_engine():
|
||||||
"""创建内存数据库引擎。"""
|
"""Create an in-memory database engine."""
|
||||||
|
|
||||||
engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
|
engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
|
||||||
async with engine.begin() as conn:
|
async with engine.begin() as conn:
|
||||||
await conn.run_sync(Base.metadata.create_all)
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
@@ -29,7 +31,8 @@ async def async_engine():
|
|||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
async def db_session(async_engine) -> AsyncGenerator[AsyncSession, None]:
|
async def db_session(async_engine) -> AsyncGenerator[AsyncSession, None]:
|
||||||
"""创建数据库会话。"""
|
"""Create a database session."""
|
||||||
|
|
||||||
session_factory = async_sessionmaker(
|
session_factory = async_sessionmaker(
|
||||||
async_engine, class_=AsyncSession, expire_on_commit=False
|
async_engine, class_=AsyncSession, expire_on_commit=False
|
||||||
)
|
)
|
||||||
@@ -39,7 +42,8 @@ async def db_session(async_engine) -> AsyncGenerator[AsyncSession, None]:
|
|||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
async def test_user(db_session: AsyncSession) -> User:
|
async def test_user(db_session: AsyncSession) -> User:
|
||||||
"""创建测试用户。"""
|
"""Create a test user."""
|
||||||
|
|
||||||
user = User(
|
user = User(
|
||||||
id="github:12345",
|
id="github:12345",
|
||||||
name="Test User",
|
name="Test User",
|
||||||
@@ -54,13 +58,74 @@ async def test_user(db_session: AsyncSession) -> User:
|
|||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
async def test_story(db_session: AsyncSession, test_user: User) -> Story:
|
async def test_story(db_session: AsyncSession, test_user: User) -> Story:
|
||||||
"""创建测试故事。"""
|
"""Create a plain generated story."""
|
||||||
|
|
||||||
story = Story(
|
story = Story(
|
||||||
user_id=test_user.id,
|
user_id=test_user.id,
|
||||||
title="测试故事",
|
title="测试故事",
|
||||||
story_text="从前有一只小兔子...",
|
story_text="从前有一只小兔子。",
|
||||||
cover_prompt="A cute rabbit in a forest",
|
cover_prompt="A cute rabbit in a forest",
|
||||||
mode="generated",
|
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)
|
db_session.add(story)
|
||||||
await db_session.commit()
|
await db_session.commit()
|
||||||
@@ -70,13 +135,14 @@ async def test_story(db_session: AsyncSession, test_user: User) -> Story:
|
|||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def auth_token(test_user: User) -> str:
|
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})
|
return create_access_token({"sub": test_user.id})
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def client(db_session: AsyncSession) -> TestClient:
|
def client(db_session: AsyncSession) -> TestClient:
|
||||||
"""创建测试客户端。"""
|
"""Create a test client."""
|
||||||
|
|
||||||
async def override_get_db():
|
async def override_get_db():
|
||||||
yield db_session
|
yield db_session
|
||||||
@@ -89,35 +155,45 @@ def client(db_session: AsyncSession) -> TestClient:
|
|||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def auth_client(client: TestClient, auth_token: str) -> TestClient:
|
def auth_client(client: TestClient, auth_token: str) -> TestClient:
|
||||||
"""带认证的测试客户端。"""
|
"""Create an authenticated test client."""
|
||||||
|
|
||||||
client.cookies.set("access_token", auth_token)
|
client.cookies.set("access_token", auth_token)
|
||||||
return client
|
return client
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def bypass_rate_limit():
|
def bypass_rate_limit():
|
||||||
"""默认绕过限流,让非限流测试正常运行。"""
|
"""Bypass rate limiting in most tests."""
|
||||||
|
|
||||||
with patch("app.core.rate_limiter.get_redis", new_callable=AsyncMock) as mock_redis:
|
with patch("app.core.rate_limiter.get_redis", new_callable=AsyncMock) as mock_redis:
|
||||||
# 创建一个模拟的 Redis 客户端,所有操作返回安全默认值
|
|
||||||
redis_instance = AsyncMock()
|
redis_instance = AsyncMock()
|
||||||
redis_instance.incr.return_value = 1 # 始终返回 1 (不触发限流)
|
redis_instance.incr.return_value = 1
|
||||||
redis_instance.expire.return_value = True
|
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.ttl.return_value = 0
|
||||||
redis_instance.delete.return_value = 1
|
redis_instance.delete.return_value = 1
|
||||||
mock_redis.return_value = redis_instance
|
mock_redis.return_value = redis_instance
|
||||||
yield 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
|
@pytest.fixture
|
||||||
def mock_text_provider():
|
def mock_text_provider():
|
||||||
"""Mock 文本生成适配器 API 调用。"""
|
"""Mock text generation."""
|
||||||
|
|
||||||
from app.services.adapters.text.models import StoryOutput
|
from app.services.adapters.text.models import StoryOutput
|
||||||
|
|
||||||
mock_result = StoryOutput(
|
mock_result = StoryOutput(
|
||||||
mode="generated",
|
mode="generated",
|
||||||
title="小兔子的冒险",
|
title="小兔子的冒险",
|
||||||
story_text="从前有一只小兔子...",
|
story_text="从前有一只小兔子。",
|
||||||
cover_prompt_suggestion="A cute rabbit",
|
cover_prompt_suggestion="A cute rabbit",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -128,7 +204,8 @@ def mock_text_provider():
|
|||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def mock_image_provider():
|
def mock_image_provider():
|
||||||
"""Mock 图像生成。"""
|
"""Mock image generation."""
|
||||||
|
|
||||||
with patch("app.services.story_service.generate_image", new_callable=AsyncMock) as mock:
|
with patch("app.services.story_service.generate_image", new_callable=AsyncMock) as mock:
|
||||||
mock.return_value = "https://example.com/image.png"
|
mock.return_value = "https://example.com/image.png"
|
||||||
yield mock
|
yield mock
|
||||||
@@ -136,7 +213,8 @@ def mock_image_provider():
|
|||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def mock_tts_provider():
|
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:
|
with patch("app.services.provider_router.text_to_speech", new_callable=AsyncMock) as mock:
|
||||||
mock.return_value = b"fake-audio-bytes"
|
mock.return_value = b"fake-audio-bytes"
|
||||||
yield mock
|
yield mock
|
||||||
@@ -144,7 +222,8 @@ def mock_tts_provider():
|
|||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def mock_all_providers(mock_text_provider, mock_image_provider, mock_tts_provider):
|
def mock_all_providers(mock_text_provider, mock_image_provider, mock_tts_provider):
|
||||||
"""Mock 所有 AI 供应商。"""
|
"""Group all mocked providers."""
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"text_primary": mock_text_provider,
|
"text_primary": mock_text_provider,
|
||||||
"image_primary": mock_image_provider,
|
"image_primary": mock_image_provider,
|
||||||
|
|||||||
@@ -1,26 +1,41 @@
|
|||||||
"""故事 API 测试。"""
|
"""Tests for story-related API endpoints."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
from unittest.mock import AsyncMock, patch
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
import pytest
|
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.services.adapters.storybook.primary import Storybook, StorybookPage
|
||||||
|
|
||||||
# ── 注意 ──────────────────────────────────────────────────────────────────────
|
|
||||||
# 以下路由尚未实现 (stories.py 中没有对应端点),相关测试标记为 skip:
|
def build_storybook_output() -> Storybook:
|
||||||
# GET /api/stories (列表)
|
"""Create a reusable mocked storybook payload."""
|
||||||
# GET /api/stories/{id} (详情)
|
|
||||||
# DELETE /api/stories/{id} (删除)
|
return Storybook(
|
||||||
# POST /api/image/generate/{id} (封面图片生成)
|
title="森林里的发光冒险",
|
||||||
# GET /api/audio/{id} (音频)
|
main_character="小兔子露露",
|
||||||
# 实现后请取消 skip 标记。
|
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:
|
class TestStoryGenerate:
|
||||||
"""故事生成测试。"""
|
"""Tests for basic story generation."""
|
||||||
|
|
||||||
def test_generate_without_auth(self, client: TestClient):
|
def test_generate_without_auth(self, client: TestClient):
|
||||||
"""未登录时生成故事。"""
|
|
||||||
response = client.post(
|
response = client.post(
|
||||||
"/api/stories/generate",
|
"/api/stories/generate",
|
||||||
json={"type": "keywords", "data": "小兔子, 森林"},
|
json={"type": "keywords", "data": "小兔子, 森林"},
|
||||||
@@ -28,7 +43,6 @@ class TestStoryGenerate:
|
|||||||
assert response.status_code == 401
|
assert response.status_code == 401
|
||||||
|
|
||||||
def test_generate_with_empty_data(self, auth_client: TestClient):
|
def test_generate_with_empty_data(self, auth_client: TestClient):
|
||||||
"""空数据生成故事。"""
|
|
||||||
response = auth_client.post(
|
response = auth_client.post(
|
||||||
"/api/stories/generate",
|
"/api/stories/generate",
|
||||||
json={"type": "keywords", "data": ""},
|
json={"type": "keywords", "data": ""},
|
||||||
@@ -36,7 +50,6 @@ class TestStoryGenerate:
|
|||||||
assert response.status_code == 422
|
assert response.status_code == 422
|
||||||
|
|
||||||
def test_generate_with_invalid_type(self, auth_client: TestClient):
|
def test_generate_with_invalid_type(self, auth_client: TestClient):
|
||||||
"""无效类型生成故事。"""
|
|
||||||
response = auth_client.post(
|
response = auth_client.post(
|
||||||
"/api/stories/generate",
|
"/api/stories/generate",
|
||||||
json={"type": "invalid", "data": "test"},
|
json={"type": "invalid", "data": "test"},
|
||||||
@@ -44,7 +57,6 @@ class TestStoryGenerate:
|
|||||||
assert response.status_code == 422
|
assert response.status_code == 422
|
||||||
|
|
||||||
def test_generate_story_success(self, auth_client: TestClient, mock_text_provider):
|
def test_generate_story_success(self, auth_client: TestClient, mock_text_provider):
|
||||||
"""成功生成故事。"""
|
|
||||||
response = auth_client.post(
|
response = auth_client.post(
|
||||||
"/api/stories/generate",
|
"/api/stories/generate",
|
||||||
json={"type": "keywords", "data": "小兔子, 森林, 勇气"},
|
json={"type": "keywords", "data": "小兔子, 森林, 勇气"},
|
||||||
@@ -55,82 +67,96 @@ class TestStoryGenerate:
|
|||||||
assert "title" in data
|
assert "title" in data
|
||||||
assert "story_text" in data
|
assert "story_text" in data
|
||||||
assert data["mode"] == "generated"
|
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:
|
class TestStoryList:
|
||||||
"""故事列表测试。"""
|
"""Tests for story listing."""
|
||||||
|
|
||||||
def test_list_without_auth(self, client: TestClient):
|
def test_list_without_auth(self, client: TestClient):
|
||||||
"""未登录时获取列表。"""
|
|
||||||
response = client.get("/api/stories")
|
response = client.get("/api/stories")
|
||||||
assert response.status_code == 401
|
assert response.status_code == 401
|
||||||
|
|
||||||
def test_list_empty(self, auth_client: TestClient):
|
def test_list_empty(self, auth_client: TestClient):
|
||||||
"""空列表。"""
|
|
||||||
response = auth_client.get("/api/stories")
|
response = auth_client.get("/api/stories")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json() == []
|
assert response.json() == []
|
||||||
|
|
||||||
def test_list_with_stories(self, auth_client: TestClient, test_story):
|
def test_list_with_stories(self, auth_client: TestClient, test_story):
|
||||||
"""有故事时获取列表。"""
|
|
||||||
response = auth_client.get("/api/stories")
|
response = auth_client.get("/api/stories")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
data = response.json()
|
data = response.json()
|
||||||
assert len(data) == 1
|
assert len(data) == 1
|
||||||
assert data[0]["id"] == test_story.id
|
assert data[0]["id"] == test_story.id
|
||||||
assert data[0]["title"] == test_story.title
|
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):
|
def test_list_pagination(self, auth_client: TestClient, test_story):
|
||||||
"""分页测试。"""
|
|
||||||
response = auth_client.get("/api/stories?limit=1&offset=0")
|
response = auth_client.get("/api/stories?limit=1&offset=0")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
data = response.json()
|
assert len(response.json()) == 1
|
||||||
assert len(data) == 1
|
|
||||||
|
|
||||||
response = auth_client.get("/api/stories?limit=1&offset=1")
|
response = auth_client.get("/api/stories?limit=1&offset=1")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
data = response.json()
|
assert len(response.json()) == 0
|
||||||
assert len(data) == 0
|
|
||||||
|
|
||||||
|
|
||||||
class TestStoryDetail:
|
class TestStoryDetail:
|
||||||
"""故事详情测试。"""
|
"""Tests for story detail retrieval."""
|
||||||
|
|
||||||
def test_get_story_without_auth(self, client: TestClient, test_story):
|
def test_get_story_without_auth(self, client: TestClient, test_story):
|
||||||
"""未登录时获取详情。"""
|
|
||||||
response = client.get(f"/api/stories/{test_story.id}")
|
response = client.get(f"/api/stories/{test_story.id}")
|
||||||
assert response.status_code == 401
|
assert response.status_code == 401
|
||||||
|
|
||||||
def test_get_story_not_found(self, auth_client: TestClient):
|
def test_get_story_not_found(self, auth_client: TestClient):
|
||||||
"""故事不存在。"""
|
|
||||||
response = auth_client.get("/api/stories/99999")
|
response = auth_client.get("/api/stories/99999")
|
||||||
assert response.status_code == 404
|
assert response.status_code == 404
|
||||||
|
|
||||||
def test_get_story_success(self, auth_client: TestClient, test_story):
|
def test_get_story_success(self, auth_client: TestClient, test_story):
|
||||||
"""成功获取详情。"""
|
|
||||||
response = auth_client.get(f"/api/stories/{test_story.id}")
|
response = auth_client.get(f"/api/stories/{test_story.id}")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
data = response.json()
|
data = response.json()
|
||||||
assert data["id"] == test_story.id
|
assert data["id"] == test_story.id
|
||||||
assert data["title"] == test_story.title
|
assert data["title"] == test_story.title
|
||||||
assert data["story_text"] == test_story.story_text
|
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:
|
class TestStoryDelete:
|
||||||
"""故事删除测试。"""
|
"""Tests for story deletion."""
|
||||||
|
|
||||||
def test_delete_without_auth(self, client: TestClient, test_story):
|
def test_delete_without_auth(self, client: TestClient, test_story):
|
||||||
"""未登录时删除。"""
|
|
||||||
response = client.delete(f"/api/stories/{test_story.id}")
|
response = client.delete(f"/api/stories/{test_story.id}")
|
||||||
assert response.status_code == 401
|
assert response.status_code == 401
|
||||||
|
|
||||||
def test_delete_not_found(self, auth_client: TestClient):
|
def test_delete_not_found(self, auth_client: TestClient):
|
||||||
"""删除不存在的故事。"""
|
|
||||||
response = auth_client.delete("/api/stories/99999")
|
response = auth_client.delete("/api/stories/99999")
|
||||||
assert response.status_code == 404
|
assert response.status_code == 404
|
||||||
|
|
||||||
def test_delete_success(self, auth_client: TestClient, test_story):
|
def test_delete_success(self, auth_client: TestClient, test_story):
|
||||||
"""成功删除故事。"""
|
|
||||||
response = auth_client.delete(f"/api/stories/{test_story.id}")
|
response = auth_client.delete(f"/api/stories/{test_story.id}")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json()["message"] == "Deleted"
|
assert response.json()["message"] == "Deleted"
|
||||||
@@ -140,11 +166,14 @@ class TestStoryDelete:
|
|||||||
|
|
||||||
|
|
||||||
class TestRateLimit:
|
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):
|
def test_rate_limit_allows_normal_requests(
|
||||||
"""正常请求不触发限流。"""
|
self,
|
||||||
# bypass_rate_limit 默认 incr 返回 1,不触发限流
|
auth_client: TestClient,
|
||||||
|
mock_text_provider,
|
||||||
|
bypass_rate_limit,
|
||||||
|
):
|
||||||
for _ in range(3):
|
for _ in range(3):
|
||||||
response = auth_client.post(
|
response = auth_client.post(
|
||||||
"/api/stories/generate",
|
"/api/stories/generate",
|
||||||
@@ -152,9 +181,11 @@ class TestRateLimit:
|
|||||||
)
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
def test_rate_limit_blocks_excess_requests(self, auth_client: TestClient, bypass_rate_limit):
|
def test_rate_limit_blocks_excess_requests(
|
||||||
"""超限请求被阻止。"""
|
self,
|
||||||
# 让 incr 返回超限值 (> RATE_LIMIT_REQUESTS)
|
auth_client: TestClient,
|
||||||
|
bypass_rate_limit,
|
||||||
|
):
|
||||||
bypass_rate_limit.incr.return_value = 11
|
bypass_rate_limit.incr.return_value = 11
|
||||||
|
|
||||||
response = auth_client.post(
|
response = auth_client.post(
|
||||||
@@ -166,52 +197,118 @@ class TestRateLimit:
|
|||||||
|
|
||||||
|
|
||||||
class TestImageGenerate:
|
class TestImageGenerate:
|
||||||
"""封面图片生成测试。"""
|
"""Tests for cover generation endpoint."""
|
||||||
|
|
||||||
def test_generate_image_without_auth(self, client: TestClient, test_story):
|
def test_generate_image_without_auth(self, client: TestClient, test_story):
|
||||||
"""未登录时生成图片。"""
|
|
||||||
response = client.post(f"/api/image/generate/{test_story.id}")
|
response = client.post(f"/api/image/generate/{test_story.id}")
|
||||||
assert response.status_code == 401
|
assert response.status_code == 401
|
||||||
|
|
||||||
def test_generate_image_not_found(self, auth_client: TestClient):
|
def test_generate_image_not_found(self, auth_client: TestClient):
|
||||||
"""故事不存在。"""
|
|
||||||
response = auth_client.post("/api/image/generate/99999")
|
response = auth_client.post("/api/image/generate/99999")
|
||||||
assert response.status_code == 404
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
class TestAudio:
|
class TestAudio:
|
||||||
"""语音朗读测试。"""
|
"""Tests for story audio endpoint."""
|
||||||
|
|
||||||
def test_get_audio_without_auth(self, client: TestClient, test_story):
|
def test_get_audio_without_auth(self, client: TestClient, test_story):
|
||||||
"""未登录时获取音频。"""
|
|
||||||
response = client.get(f"/api/audio/{test_story.id}")
|
response = client.get(f"/api/audio/{test_story.id}")
|
||||||
assert response.status_code == 401
|
assert response.status_code == 401
|
||||||
|
|
||||||
def test_get_audio_not_found(self, auth_client: TestClient):
|
def test_get_audio_not_found(self, auth_client: TestClient):
|
||||||
"""故事不存在。"""
|
|
||||||
response = auth_client.get("/api/audio/99999")
|
response = auth_client.get("/api/audio/99999")
|
||||||
assert response.status_code == 404
|
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}")
|
response = auth_client.get(f"/api/audio/{test_story.id}")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.headers["content-type"] == "audio/mpeg"
|
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:
|
class TestGenerateFull:
|
||||||
"""完整故事生成测试(/api/stories/generate/full)。"""
|
"""Tests for complete story generation."""
|
||||||
|
|
||||||
def test_generate_full_without_auth(self, client: TestClient):
|
def test_generate_full_without_auth(self, client: TestClient):
|
||||||
"""未登录时生成完整故事。"""
|
|
||||||
response = client.post(
|
response = client.post(
|
||||||
"/api/stories/generate/full",
|
"/api/stories/generate/full",
|
||||||
json={"type": "keywords", "data": "小兔子, 森林"},
|
json={"type": "keywords", "data": "小兔子, 森林"},
|
||||||
)
|
)
|
||||||
assert response.status_code == 401
|
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(
|
response = auth_client.post(
|
||||||
"/api/stories/generate/full",
|
"/api/stories/generate/full",
|
||||||
json={"type": "keywords", "data": "小兔子, 森林, 勇气"},
|
json={"type": "keywords", "data": "小兔子, 森林, 勇气"},
|
||||||
@@ -223,11 +320,14 @@ class TestGenerateFull:
|
|||||||
assert "story_text" in data
|
assert "story_text" in data
|
||||||
assert data["mode"] == "generated"
|
assert data["mode"] == "generated"
|
||||||
assert data["image_url"] == "https://example.com/image.png"
|
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["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):
|
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:
|
with patch("app.services.story_service.generate_image", new_callable=AsyncMock) as mock_img:
|
||||||
mock_img.side_effect = Exception("Image API error")
|
mock_img.side_effect = Exception("Image API error")
|
||||||
response = auth_client.post(
|
response = auth_client.post(
|
||||||
@@ -239,9 +339,17 @@ class TestGenerateFull:
|
|||||||
assert data["image_url"] is None
|
assert data["image_url"] is None
|
||||||
assert "image" in data["errors"]
|
assert "image" in data["errors"]
|
||||||
assert "Image API error" in data["errors"]["image"]
|
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(
|
response = auth_client.post(
|
||||||
"/api/stories/generate/full",
|
"/api/stories/generate/full",
|
||||||
json={
|
json={
|
||||||
@@ -257,11 +365,80 @@ class TestGenerateFull:
|
|||||||
|
|
||||||
|
|
||||||
class TestImageGenerateSuccess:
|
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}")
|
response = auth_client.post(f"/api/image/generate/{test_story.id}")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
data = response.json()
|
data = response.json()
|
||||||
assert data["image_url"] == "https://example.com/image.png"
|
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"]
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ export interface Storybook {
|
|||||||
pages: StorybookPage[]
|
pages: StorybookPage[]
|
||||||
cover_prompt: string
|
cover_prompt: string
|
||||||
cover_url?: string
|
cover_url?: string
|
||||||
|
generation_status?: string
|
||||||
|
image_status?: string
|
||||||
|
audio_status?: string
|
||||||
|
last_error?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useStorybookStore = defineStore('storybook', () => {
|
export const useStorybookStore = defineStore('storybook', () => {
|
||||||
|
|||||||
79
frontend/src/utils/storyStatus.ts
Normal file
79
frontend/src/utils/storyStatus.ts
Normal 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
|
||||||
|
}
|
||||||
@@ -1,19 +1,20 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { api } from '../api/client'
|
import { api } from '../api/client'
|
||||||
|
import CreateStoryModal from '../components/CreateStoryModal.vue'
|
||||||
import BaseButton from '../components/ui/BaseButton.vue'
|
import BaseButton from '../components/ui/BaseButton.vue'
|
||||||
import BaseCard from '../components/ui/BaseCard.vue'
|
import BaseCard from '../components/ui/BaseCard.vue'
|
||||||
import LoadingSpinner from '../components/ui/LoadingSpinner.vue'
|
|
||||||
import EmptyState from '../components/ui/EmptyState.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 {
|
import {
|
||||||
BookOpenIcon,
|
BookOpenIcon,
|
||||||
ChevronRightIcon,
|
ChevronRightIcon,
|
||||||
ExclamationCircleIcon,
|
ExclamationCircleIcon,
|
||||||
PhotoIcon,
|
PhotoIcon,
|
||||||
SparklesIcon,
|
|
||||||
PlusIcon,
|
PlusIcon,
|
||||||
|
SparklesIcon,
|
||||||
} from '@heroicons/vue/24/outline'
|
} from '@heroicons/vue/24/outline'
|
||||||
|
|
||||||
interface StoryItem {
|
interface StoryItem {
|
||||||
@@ -21,6 +22,11 @@ interface StoryItem {
|
|||||||
title: string
|
title: string
|
||||||
image_url: string | null
|
image_url: string | null
|
||||||
created_at: string
|
created_at: string
|
||||||
|
mode: string
|
||||||
|
generation_status: string
|
||||||
|
image_status: string
|
||||||
|
audio_status: string
|
||||||
|
last_error: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -29,6 +35,16 @@ const loading = ref(true)
|
|||||||
const error = ref('')
|
const error = ref('')
|
||||||
const showCreateModal = ref(false)
|
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() {
|
async function fetchStories() {
|
||||||
try {
|
try {
|
||||||
stories.value = await api.get<StoryItem[]>('/api/stories')
|
stories.value = await api.get<StoryItem[]>('/api/stories')
|
||||||
@@ -60,8 +76,13 @@ function goToCreate() {
|
|||||||
showCreateModal.value = true
|
showCreateModal.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getStoryLink(story: StoryItem) {
|
||||||
|
return story.mode === 'storybook' ? `/storybook/view/${story.id}` : `/story/${story.id}`
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
fetchStories()
|
void fetchStories()
|
||||||
|
|
||||||
if (router.currentRoute.value.query.openCreate) {
|
if (router.currentRoute.value.query.openCreate) {
|
||||||
showCreateModal.value = true
|
showCreateModal.value = true
|
||||||
router.replace({ query: { ...router.currentRoute.value.query, openCreate: undefined } })
|
router.replace({ query: { ...router.currentRoute.value.query, openCreate: undefined } })
|
||||||
@@ -71,11 +92,10 @@ onMounted(() => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="max-w-6xl mx-auto px-4">
|
<div class="max-w-6xl mx-auto px-4">
|
||||||
<!-- 页面标题 -->
|
|
||||||
<div class="flex items-center justify-between mb-8">
|
<div class="flex items-center justify-between mb-8">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-3xl font-bold gradient-text mb-2">我的故事</h1>
|
<h1 class="text-3xl font-bold gradient-text mb-2">我的故事</h1>
|
||||||
<p class="text-gray-500">收藏的所有童话故事</p>
|
<p class="text-gray-500">回看每个作品的生成质量、资源状态和可优化点。</p>
|
||||||
</div>
|
</div>
|
||||||
<BaseButton @click="goToCreate">
|
<BaseButton @click="goToCreate">
|
||||||
<SparklesIcon class="h-5 w-5 mr-2" />
|
<SparklesIcon class="h-5 w-5 mr-2" />
|
||||||
@@ -83,12 +103,10 @@ onMounted(() => {
|
|||||||
</BaseButton>
|
</BaseButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 加载状态 -->
|
|
||||||
<div v-if="loading" class="py-20">
|
<div v-if="loading" class="py-20">
|
||||||
<LoadingSpinner text="加载中..." />
|
<LoadingSpinner text="正在加载内容..." />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 错误状态 -->
|
|
||||||
<div v-else-if="error" class="py-10">
|
<div v-else-if="error" class="py-10">
|
||||||
<EmptyState
|
<EmptyState
|
||||||
:icon="ExclamationCircleIcon"
|
:icon="ExclamationCircleIcon"
|
||||||
@@ -97,54 +115,53 @@ onMounted(() => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 空状态 -->
|
|
||||||
<div v-else-if="stories.length === 0" class="py-10">
|
<div v-else-if="stories.length === 0" class="py-10">
|
||||||
<EmptyState
|
<EmptyState
|
||||||
:icon="BookOpenIcon"
|
:icon="BookOpenIcon"
|
||||||
title="开始你的创作之旅"
|
title="从第一个作品开始"
|
||||||
description="还没有创作任何故事,现在就开始为孩子创作第一个专属童话故事吧!"
|
description="现在还没有故事或绘本,先做一个能完整跑通的版本,后面再持续优化。"
|
||||||
>
|
>
|
||||||
<template #action>
|
<template #action>
|
||||||
<BaseButton @click="goToCreate">
|
<BaseButton @click="goToCreate">
|
||||||
<PlusIcon class="h-5 w-5 mr-2" />
|
<PlusIcon class="h-5 w-5 mr-2" />
|
||||||
创作第一个故事
|
创作第一个作品
|
||||||
</BaseButton>
|
</BaseButton>
|
||||||
</template>
|
</template>
|
||||||
</EmptyState>
|
</EmptyState>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 故事列表 -->
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<!-- 统计卡片 -->
|
|
||||||
<BaseCard class="mb-8" padding="lg">
|
<BaseCard class="mb-8" padding="lg">
|
||||||
<div class="flex items-center justify-around divide-x divide-gray-100">
|
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
<div class="text-center px-4">
|
<div class="text-center px-4 py-2">
|
||||||
<div class="text-3xl font-bold text-gray-800">{{ stories.length }}</div>
|
<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>
|
||||||
<div class="text-center px-4">
|
<div class="text-center px-4 py-2">
|
||||||
<div class="text-3xl font-bold text-gray-800">
|
<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>
|
||||||
<div class="text-gray-500 text-sm mt-1">已配图</div>
|
<div class="text-gray-500 text-sm mt-1">绘本数量</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-center px-4">
|
<div class="text-center px-4 py-2">
|
||||||
<BookOpenIcon class="h-8 w-8 text-purple-500 mx-auto" />
|
<div class="text-3xl font-bold text-gray-800">{{ completedCount }}</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 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>
|
||||||
</div>
|
</div>
|
||||||
</BaseCard>
|
</BaseCard>
|
||||||
|
|
||||||
<!-- 故事网格 -->
|
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
<router-link
|
<router-link
|
||||||
v-for="story in stories"
|
v-for="story in stories"
|
||||||
:key="story.id"
|
:key="story.id"
|
||||||
:to="`/story/${story.id}`"
|
:to="getStoryLink(story)"
|
||||||
class="block group"
|
class="block group"
|
||||||
>
|
>
|
||||||
<BaseCard hover padding="none" class="h-full overflow-hidden flex flex-col">
|
<BaseCard hover padding="none" class="h-full overflow-hidden flex flex-col">
|
||||||
<!-- 封面图 -->
|
|
||||||
<div class="relative aspect-[4/3] overflow-hidden bg-gray-100">
|
<div class="relative aspect-[4/3] overflow-hidden bg-gray-100">
|
||||||
<img
|
<img
|
||||||
v-if="story.image_url"
|
v-if="story.image_url"
|
||||||
@@ -159,23 +176,64 @@ onMounted(() => {
|
|||||||
<PhotoIcon class="h-12 w-12" />
|
<PhotoIcon class="h-12 w-12" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 悬停阅读提示 -->
|
<div class="absolute top-4 left-4 flex flex-wrap gap-2">
|
||||||
<div class="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity duration-300 flex items-center justify-center">
|
<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">
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 信息区 -->
|
|
||||||
<div class="p-5 flex-1 flex flex-col">
|
<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 }}
|
{{ story.title }}
|
||||||
</h3>
|
</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">
|
<div class="mt-auto flex items-center justify-between text-sm text-gray-500">
|
||||||
<span>{{ formatDate(story.created_at) }}</span>
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, onUnmounted } from 'vue'
|
import { computed, onUnmounted, ref, watch } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { api } from '../api/client'
|
import { api } from '../api/client'
|
||||||
import BaseButton from '../components/ui/BaseButton.vue'
|
import BaseButton from '../components/ui/BaseButton.vue'
|
||||||
import ConfirmModal from '../components/ui/ConfirmModal.vue'
|
import ConfirmModal from '../components/ui/ConfirmModal.vue'
|
||||||
import LoadingSpinner from '../components/ui/LoadingSpinner.vue'
|
import LoadingSpinner from '../components/ui/LoadingSpinner.vue'
|
||||||
|
import { getAssetStatusMeta, getGenerationStatusMeta } from '../utils/storyStatus'
|
||||||
import {
|
import {
|
||||||
ArrowLeftIcon,
|
ArrowLeftIcon,
|
||||||
ExclamationTriangleIcon,
|
ExclamationTriangleIcon,
|
||||||
@@ -12,21 +13,38 @@ import {
|
|||||||
SpeakerWaveIcon,
|
SpeakerWaveIcon,
|
||||||
SparklesIcon,
|
SparklesIcon,
|
||||||
TrashIcon,
|
TrashIcon,
|
||||||
XMarkIcon,
|
|
||||||
} from '@heroicons/vue/24/outline'
|
} from '@heroicons/vue/24/outline'
|
||||||
|
|
||||||
const route = useRoute()
|
|
||||||
const router = useRouter()
|
|
||||||
|
|
||||||
interface Story {
|
interface Story {
|
||||||
id: number
|
id: number
|
||||||
title: string
|
title: string
|
||||||
story_text: string
|
story_text: string | null
|
||||||
cover_prompt: string | null
|
cover_prompt: string | null
|
||||||
image_url: string | null
|
image_url: string | null
|
||||||
mode: string
|
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 story = ref<Story | null>(null)
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const imageLoading = ref(false)
|
const imageLoading = ref(false)
|
||||||
@@ -38,11 +56,29 @@ const audioProgress = ref(0)
|
|||||||
const audioDuration = ref(0)
|
const audioDuration = ref(0)
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
const showDeleteConfirm = ref(false)
|
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() {
|
async function fetchStory() {
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
|
||||||
try {
|
try {
|
||||||
story.value = await api.get<Story>(`/api/stories/${route.params.id}`)
|
await refreshStorySnapshot()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = e instanceof Error ? e.message : '加载失败'
|
error.value = e instanceof Error ? e.message : '加载失败'
|
||||||
} finally {
|
} finally {
|
||||||
@@ -52,12 +88,20 @@ async function fetchStory() {
|
|||||||
|
|
||||||
async function generateImage() {
|
async function generateImage() {
|
||||||
if (!story.value) return
|
if (!story.value) return
|
||||||
|
|
||||||
imageLoading.value = true
|
imageLoading.value = true
|
||||||
|
error.value = ''
|
||||||
|
|
||||||
try {
|
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.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) {
|
} catch (e) {
|
||||||
error.value = e instanceof Error ? e.message : '图片生成失败'
|
error.value = e instanceof Error ? e.message : '图片生成失败'
|
||||||
|
await refreshStorySnapshot().catch(() => undefined)
|
||||||
} finally {
|
} finally {
|
||||||
imageLoading.value = false
|
imageLoading.value = false
|
||||||
}
|
}
|
||||||
@@ -65,16 +109,25 @@ async function generateImage() {
|
|||||||
|
|
||||||
async function loadAudio() {
|
async function loadAudio() {
|
||||||
if (!story.value || audioUrl.value) return
|
if (!story.value || audioUrl.value) return
|
||||||
|
|
||||||
audioLoading.value = true
|
audioLoading.value = true
|
||||||
|
error.value = ''
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/audio/${story.value.id}`, {
|
const response = await fetch(`/api/audio/${story.value.id}`, {
|
||||||
credentials: 'include',
|
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()
|
const blob = await response.blob()
|
||||||
audioUrl.value = URL.createObjectURL(blob)
|
audioUrl.value = URL.createObjectURL(blob)
|
||||||
|
await refreshStorySnapshot().catch(() => undefined)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = e instanceof Error ? e.message : '音频加载失败'
|
error.value = e instanceof Error ? e.message : '音频加载失败'
|
||||||
|
await refreshStorySnapshot().catch(() => undefined)
|
||||||
} finally {
|
} finally {
|
||||||
audioLoading.value = false
|
audioLoading.value = false
|
||||||
}
|
}
|
||||||
@@ -82,24 +135,29 @@ async function loadAudio() {
|
|||||||
|
|
||||||
function togglePlay() {
|
function togglePlay() {
|
||||||
if (!audioRef.value) return
|
if (!audioRef.value) return
|
||||||
|
|
||||||
if (isPlaying.value) {
|
if (isPlaying.value) {
|
||||||
audioRef.value.pause()
|
audioRef.value.pause()
|
||||||
} else {
|
} else {
|
||||||
audioRef.value.play()
|
void audioRef.value.play()
|
||||||
}
|
}
|
||||||
|
|
||||||
isPlaying.value = !isPlaying.value
|
isPlaying.value = !isPlaying.value
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateProgress() {
|
function updateProgress() {
|
||||||
if (!audioRef.value) return
|
if (!audioRef.value) return
|
||||||
|
|
||||||
audioProgress.value = audioRef.value.currentTime
|
audioProgress.value = audioRef.value.currentTime
|
||||||
audioDuration.value = audioRef.value.duration || 0
|
audioDuration.value = audioRef.value.duration || 0
|
||||||
}
|
}
|
||||||
|
|
||||||
function seekAudio(e: MouseEvent) {
|
function seekAudio(event: MouseEvent) {
|
||||||
if (!audioRef.value || !audioDuration.value) return
|
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
|
audioRef.value.currentTime = percent * audioDuration.value
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,9 +169,10 @@ function formatTime(seconds: number) {
|
|||||||
|
|
||||||
async function deleteStory() {
|
async function deleteStory() {
|
||||||
if (!story.value) return
|
if (!story.value) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await api.delete(`/api/stories/${story.value.id}`)
|
await api.delete(`/api/stories/${story.value.id}`)
|
||||||
router.push('/my-stories')
|
await router.push('/my-stories')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = e instanceof Error ? e.message : '删除失败'
|
error.value = e instanceof Error ? e.message : '删除失败'
|
||||||
}
|
}
|
||||||
@@ -124,12 +183,14 @@ async function confirmDelete() {
|
|||||||
await deleteStory()
|
await deleteStory()
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
watch(
|
||||||
fetchStory()
|
() => route.params.id,
|
||||||
if (route.query.imageError === '1') {
|
() => {
|
||||||
imageGenerationFailed.value = true
|
story.value = null
|
||||||
}
|
void fetchStory()
|
||||||
})
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
if (audioUrl.value) {
|
if (audioUrl.value) {
|
||||||
@@ -139,7 +200,7 @@ onUnmounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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">
|
<div v-if="loading" class="py-20">
|
||||||
<LoadingSpinner size="lg" text="正在加载故事..." />
|
<LoadingSpinner size="lg" text="正在加载故事..." />
|
||||||
</div>
|
</div>
|
||||||
@@ -156,29 +217,16 @@ onUnmounted(() => {
|
|||||||
返回
|
返回
|
||||||
</BaseButton>
|
</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
|
<div
|
||||||
v-if="imageGenerationFailed && !story?.image_url"
|
v-if="story.last_error"
|
||||||
class="p-4 bg-amber-50 border border-amber-200 text-amber-700 rounded-xl flex items-center justify-between"
|
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 mt-0.5 flex-shrink-0" />
|
||||||
<ExclamationTriangleIcon class="h-5 w-5" />
|
<div>
|
||||||
<span>封面生成失败,您可以稍后重试</span>
|
<div class="font-semibold mb-1">最近一次资源生成异常</div>
|
||||||
|
<p class="text-sm leading-6">{{ story.last_error }}</p>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</Transition>
|
|
||||||
|
|
||||||
<div class="glass rounded-3xl shadow-2xl overflow-hidden">
|
<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">
|
<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"
|
:alt="story.title"
|
||||||
class="w-full h-full object-cover"
|
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" />
|
<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
|
<BaseButton
|
||||||
|
v-if="story.cover_prompt"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
:loading="imageLoading"
|
:loading="imageLoading"
|
||||||
@click="generateImage"
|
@click="generateImage"
|
||||||
>
|
>
|
||||||
<template v-if="imageLoading">AI 正在绘制...</template>
|
<template v-if="imageLoading">正在生成封面...</template>
|
||||||
<template v-else>生成精美封面</template>
|
<template v-else>{{ story.image_status === 'failed' ? '重试生成封面' : '生成封面' }}</template>
|
||||||
</BaseButton>
|
</BaseButton>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div class="p-8 md:p-12 -mt-16 relative">
|
<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 }}
|
{{ story.title }}
|
||||||
</h1>
|
</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">
|
<div class="prose prose-lg max-w-none mb-10">
|
||||||
<p
|
<p
|
||||||
v-for="(paragraph, index) in story.story_text.split('\n\n')"
|
v-for="(paragraph, index) in storyParagraphs"
|
||||||
:key="index"
|
: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"
|
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"
|
@click="loadAudio"
|
||||||
class="mx-auto"
|
class="mx-auto"
|
||||||
>
|
>
|
||||||
<template v-if="audioLoading">加载中...</template>
|
<template v-if="audioLoading">正在准备音频...</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<SpeakerWaveIcon class="h-5 w-5" />
|
<SpeakerWaveIcon class="h-5 w-5" />
|
||||||
听故事
|
试听故事
|
||||||
</template>
|
</template>
|
||||||
</BaseButton>
|
</BaseButton>
|
||||||
</div>
|
</div>
|
||||||
@@ -262,7 +342,7 @@ onUnmounted(() => {
|
|||||||
<div
|
<div
|
||||||
class="h-full bg-gradient-to-r from-purple-500 to-pink-500 rounded-full transition-all duration-100"
|
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}%` }"
|
:style="{ width: `${(audioProgress / audioDuration) * 100 || 0}%` }"
|
||||||
></div>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between text-sm text-gray-500 mt-1">
|
<div class="flex justify-between text-sm text-gray-500 mt-1">
|
||||||
<span>{{ formatTime(audioProgress) }}</span>
|
<span>{{ formatTime(audioProgress) }}</span>
|
||||||
@@ -301,7 +381,7 @@ onUnmounted(() => {
|
|||||||
<ConfirmModal
|
<ConfirmModal
|
||||||
:show="showDeleteConfirm"
|
:show="showDeleteConfirm"
|
||||||
title="确定删除这个故事吗?"
|
title="确定删除这个故事吗?"
|
||||||
message="删除后将无法恢复"
|
message="删除后将无法恢复。"
|
||||||
confirm-text="确定删除"
|
confirm-text="确定删除"
|
||||||
cancel-text="取消"
|
cancel-text="取消"
|
||||||
variant="danger"
|
variant="danger"
|
||||||
|
|||||||
@@ -1,34 +1,86 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { api } from '../api/client'
|
||||||
import { useStorybookStore } from '../stores/storybook'
|
import { useStorybookStore } from '../stores/storybook'
|
||||||
import BaseButton from '../components/ui/BaseButton.vue'
|
import BaseButton from '../components/ui/BaseButton.vue'
|
||||||
|
import LoadingSpinner from '../components/ui/LoadingSpinner.vue'
|
||||||
|
import { getAssetStatusMeta, getGenerationStatusMeta } from '../utils/storyStatus'
|
||||||
import {
|
import {
|
||||||
ArrowLeftIcon,
|
ArrowLeftIcon,
|
||||||
ArrowRightIcon,
|
ArrowRightIcon,
|
||||||
HomeIcon,
|
|
||||||
BookOpenIcon,
|
BookOpenIcon,
|
||||||
|
ExclamationTriangleIcon,
|
||||||
|
HomeIcon,
|
||||||
|
PhotoIcon,
|
||||||
SparklesIcon,
|
SparklesIcon,
|
||||||
PhotoIcon
|
|
||||||
} from '@heroicons/vue/24/outline'
|
} 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 router = useRouter()
|
||||||
const store = useStorybookStore()
|
const store = useStorybookStore()
|
||||||
|
|
||||||
const storybook = computed(() => store.currentStorybook)
|
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 totalPages = computed(() => storybook.value?.pages.length || 0)
|
||||||
const isCover = computed(() => currentPageIndex.value === -1)
|
const isCover = computed(() => currentPageIndex.value === -1)
|
||||||
const isLastPage = computed(() => currentPageIndex.value === totalPages.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(() => {
|
const currentPage = computed(() => {
|
||||||
if (!storybook.value || isCover.value) return null
|
if (!storybook.value || isCover.value) return null
|
||||||
return storybook.value.pages[currentPageIndex.value]
|
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() {
|
function goHome() {
|
||||||
store.clearStorybook()
|
store.clearStorybook()
|
||||||
router.push('/')
|
router.push('/')
|
||||||
@@ -46,16 +98,89 @@ function prevPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
async function loadStorybook() {
|
||||||
if (!storybook.value) {
|
loading.value = true
|
||||||
router.push('/')
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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">
|
<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">
|
<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" />
|
<HomeIcon class="w-6 h-6" />
|
||||||
@@ -63,39 +188,31 @@ onMounted(() => {
|
|||||||
<div class="text-white font-serif text-lg text-shadow">
|
<div class="text-white font-serif text-lg text-shadow">
|
||||||
{{ storybook.title }}
|
{{ storybook.title }}
|
||||||
</div>
|
</div>
|
||||||
<div class="w-10"></div> <!-- 占位 -->
|
<div class="w-10"></div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<!-- 主展示区 -->
|
|
||||||
<div class="h-screen w-full flex items-center justify-center p-4 md:p-8 relative overflow-hidden">
|
<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-[#0D0F1A] z-0">
|
||||||
<div class="absolute inset-0 bg-gradient-to-br from-[#1a1a2e] to-[#0D0F1A]"></div>
|
<div class="absolute inset-0 bg-gradient-to-br from-[#1a1a2e] to-[#0D0F1A]"></div>
|
||||||
<div class="stars"></div>
|
<div class="stars"></div>
|
||||||
</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 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 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">
|
<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">
|
<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" />
|
<img :src="storybook.cover_url" class="w-full h-full object-cover transition-transform duration-1000 group-hover:scale-105" />
|
||||||
</template>
|
</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">
|
<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" />
|
<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>
|
||||||
<!-- 封面遮罩 -->
|
|
||||||
<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 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">
|
<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>
|
<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>
|
</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="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">
|
<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>
|
<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>
|
<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">
|
<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.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.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>
|
</div>
|
||||||
|
|
||||||
<BaseButton size="lg" @click="nextPage" class="self-start shadow-xl hover:shadow-2xl hover:-translate-y-1 transition-all">
|
<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>
|
</BaseButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 内页模式 -->
|
|
||||||
<div v-else class="w-full h-full flex flex-col md:flex-row animate-fade-in relative">
|
<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">
|
<div class="absolute bottom-4 right-6 text-amber-900/30 font-serif text-xl z-20">
|
||||||
{{ currentPageIndex + 1 }} / {{ totalPages }}
|
{{ currentPageIndex + 1 }} / {{ totalPages }}
|
||||||
</div>
|
</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">
|
<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">
|
<template v-if="currentPage?.image_url">
|
||||||
<img :src="currentPage.image_url" class="w-full h-full object-cover" />
|
<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">
|
<div class="inline-block p-6 rounded-full bg-amber-50 mb-4">
|
||||||
<PhotoIcon class="w-10 h-10 text-amber-300" />
|
<PhotoIcon class="w-10 h-10 text-amber-300" />
|
||||||
</div>
|
</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>
|
||||||
</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="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">
|
<div class="prose prose-xl prose-amber font-serif text-amber-900 leading-relaxed text-center md:text-left">
|
||||||
<p>{{ currentPage?.text }}</p>
|
<p>{{ currentPage?.text }}</p>
|
||||||
@@ -145,7 +291,6 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 翻页控制 (悬浮) -->
|
|
||||||
<button
|
<button
|
||||||
v-if="!isCover"
|
v-if="!isCover"
|
||||||
@click="prevPage"
|
@click="prevPage"
|
||||||
@@ -162,7 +307,6 @@ onMounted(() => {
|
|||||||
<ArrowRightIcon class="w-6 h-6 md:w-8 md:h-8" />
|
<ArrowRightIcon class="w-6 h-6 md:w-8 md:h-8" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- 最后一页的完成按钮 -->
|
|
||||||
<BaseButton
|
<BaseButton
|
||||||
v-if="isLastPage"
|
v-if="isLastPage"
|
||||||
@click="goHome"
|
@click="goHome"
|
||||||
@@ -170,7 +314,6 @@ onMounted(() => {
|
|||||||
>
|
>
|
||||||
读完了,再来一本
|
读完了,再来一本
|
||||||
</BaseButton>
|
</BaseButton>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
Reference in New Issue
Block a user