Files
dreamweaver/frontend/src/views/MyStories.vue

645 lines
28 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { api } from '../api/client'
import CreateStoryModal from '../components/CreateStoryModal.vue'
import BaseButton from '../components/ui/BaseButton.vue'
import BaseCard from '../components/ui/BaseCard.vue'
import EmptyState from '../components/ui/EmptyState.vue'
import LoadingSpinner from '../components/ui/LoadingSpinner.vue'
import type { GenerationOpsSummary, GenerationProviderAnalytics } from '../types/generation'
import type { VoiceSessionAnalytics, VoiceSessionSummary } from '../types/voiceSession'
import {
getVoiceSessionNextAction,
getVoiceSessionNextStep,
} from '../utils/voiceSession'
import {
getAssetStatusMeta,
getGenerationStatusMeta,
isReadableGenerationStatus,
needsGenerationAttention,
} from '../utils/storyStatus'
import {
BookOpenIcon,
ChevronRightIcon,
ExclamationCircleIcon,
PhotoIcon,
PlusIcon,
SparklesIcon,
} from '@heroicons/vue/24/outline'
interface StoryItem {
id: number
title: string
image_url: string | null
created_at: string
mode: string
generation_status: string
text_status: string
image_status: string
audio_status: string
last_error: string | null
}
type VoiceAttentionReason = 'pending_confirmation' | 'safety_intervention' | 'failed_turn'
type VoiceStudioFocusTarget = 'confirmation' | 'safety' | 'failed' | 'text'
const router = useRouter()
const stories = ref<StoryItem[]>([])
const providerAnalytics = ref<GenerationProviderAnalytics | null>(null)
const opsSummary = ref<GenerationOpsSummary | null>(null)
const activeVoiceSession = ref<VoiceSessionSummary | null>(null)
const voiceAnalytics = ref<VoiceSessionAnalytics | null>(null)
const loading = ref(true)
const error = ref('')
const showCreateModal = ref(false)
const selectedWindow = ref<'7' | '30' | 'all'>('30')
const selectedVoiceWindow = ref<'7' | '30' | 'all'>('30')
const selectedCapability = ref<'all' | 'text' | 'image' | 'tts' | 'storybook'>('all')
const readableCount = computed(() =>
stories.value.filter((story) => isReadableGenerationStatus(story.generation_status)).length,
)
const attentionCount = computed(() =>
stories.value.filter((story) => needsGenerationAttention(story.generation_status)).length,
)
const providerSuccessRate = computed(() => {
if (!providerAnalytics.value?.total_calls) return null
return Math.round(
(providerAnalytics.value.successful_calls / providerAnalytics.value.total_calls) * 100,
)
})
const topProvider = computed(() => providerAnalytics.value?.by_provider[0] ?? null)
const topFailureReason = computed(() => providerAnalytics.value?.failure_reasons[0] ?? null)
const voiceTurnSuccessRate = computed(() => {
if (!voiceAnalytics.value) return null
return Math.round(voiceAnalytics.value.turn_success_rate * 100)
})
const voiceFinalizeRate = computed(() => {
if (!voiceAnalytics.value) return null
return Math.round(voiceAnalytics.value.finalize_conversion_rate * 100)
})
const voiceAnalyticsWindowLabel = computed(() =>
formatWindowLabel(voiceAnalytics.value?.window_days ?? null),
)
function buildProviderAnalyticsPath() {
const params = new URLSearchParams()
if (selectedWindow.value !== 'all') {
params.set('days', selectedWindow.value)
}
if (selectedCapability.value !== 'all') {
params.set('capability', selectedCapability.value)
}
const query = params.toString()
return `/api/generations/provider-analytics${query ? `?${query}` : ''}`
}
function buildVoiceAnalyticsPath() {
if (selectedVoiceWindow.value === 'all') {
return '/api/voice-sessions/analytics'
}
return `/api/voice-sessions/analytics?days=${selectedVoiceWindow.value}`
}
async function fetchStories() {
try {
const [storyList, analytics, ops, activeSession, voiceOverview] = await Promise.all([
api.get<StoryItem[]>('/api/stories'),
api.get<GenerationProviderAnalytics>(buildProviderAnalyticsPath()),
api.get<GenerationOpsSummary>('/api/generations/ops-summary'),
api.get<VoiceSessionSummary | null>('/api/voice-sessions/active').catch(() => null),
api.get<VoiceSessionAnalytics>(buildVoiceAnalyticsPath()).catch(() => null),
])
stories.value = storyList
providerAnalytics.value = analytics
opsSummary.value = ops
activeVoiceSession.value = activeSession
voiceAnalytics.value = voiceOverview
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
} finally {
loading.value = false
}
}
function formatDate(dateStr: string) {
const date = new Date(dateStr)
const now = new Date()
const diff = now.getTime() - date.getTime()
const days = Math.floor(diff / (1000 * 60 * 60 * 24))
if (days === 0) return '今天'
if (days === 1) return '昨天'
if (days < 7) return `${days} 天前`
return date.toLocaleDateString('zh-CN', {
year: 'numeric',
month: 'long',
day: 'numeric',
})
}
function formatWindowLabel(windowDays: number | null | undefined) {
if (typeof windowDays === 'number') {
return `最近 ${windowDays}`
}
return '全部历史'
}
function goToCreate() {
showCreateModal.value = true
}
function goToVoiceStudio(options?: {
reason?: VoiceAttentionReason
sessionId?: string
focus?: VoiceStudioFocusTarget
}) {
const query: Record<string, string> = {}
if (options?.reason) {
query.filter = 'attention'
query.reason = options.reason
}
if (options?.sessionId) {
query.session = options.sessionId
}
if (options?.focus) {
query.focus = options.focus
}
router.push({ path: '/voice-studio', query })
}
function continueActiveVoiceSession() {
if (!activeVoiceSession.value) {
goToVoiceStudio()
return
}
const action = getVoiceSessionNextAction(activeVoiceSession.value)
if (action.storyId) {
router.push(`/story/${action.storyId}`)
return
}
goToVoiceStudio({
reason: action.reason,
sessionId: activeVoiceSession.value.id,
focus: action.focus,
})
}
function getStoryLink(story: StoryItem) {
return story.mode === 'storybook' ? `/storybook/view/${story.id}` : `/story/${story.id}`
}
function formatLatency(value?: number | null) {
return typeof value === 'number' ? `${Math.round(value)}ms` : '暂无'
}
function formatCost(value?: number | null) {
return typeof value === 'number' ? `$${value.toFixed(4)}` : '$0.0000'
}
function formatOutputMode(value: string) {
switch (value) {
case 'storybook':
return '绘本'
case 'asset_retry':
return '资源重试'
case 'asset_generation':
return '资源生成'
default:
return '故事'
}
}
function setWindow(value: '7' | '30' | 'all') {
selectedWindow.value = value
}
function setVoiceWindow(value: '7' | '30' | 'all') {
selectedVoiceWindow.value = value
}
function setCapability(value: 'all' | 'text' | 'image' | 'tts' | 'storybook') {
selectedCapability.value = value
}
onMounted(() => {
void fetchStories()
if (router.currentRoute.value.query.openCreate) {
showCreateModal.value = true
router.replace({ query: { ...router.currentRoute.value.query, openCreate: undefined } })
}
})
watch([selectedWindow, selectedCapability, selectedVoiceWindow], () => {
void fetchStories()
})
</script>
<template>
<div class="max-w-6xl mx-auto px-4">
<div class="flex items-center justify-between mb-8">
<div>
<h1 class="text-3xl font-bold gradient-text mb-2">我的故事</h1>
<p class="text-gray-500">回看每个作品的生成质量资源状态和可优化点</p>
</div>
<BaseButton @click="goToCreate">
<SparklesIcon class="h-5 w-5 mr-2" />
创作新故事
</BaseButton>
</div>
<div v-if="loading" class="py-20">
<LoadingSpinner text="正在加载内容..." />
</div>
<div v-else-if="error" class="py-10">
<EmptyState
:icon="ExclamationCircleIcon"
title="加载失败"
:description="error"
/>
</div>
<div v-else-if="stories.length === 0" class="py-10">
<EmptyState
:icon="BookOpenIcon"
title="从第一个作品开始"
description="现在还没有故事或绘本,先做一个能完整跑通的版本,后面再持续优化。"
>
<template #action>
<BaseButton @click="goToCreate">
<PlusIcon class="h-5 w-5 mr-2" />
创作第一个作品
</BaseButton>
</template>
</EmptyState>
</div>
<template v-else>
<BaseCard
v-if="activeVoiceSession"
class="mb-8 border border-purple-100 bg-purple-50/60"
padding="lg"
>
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<h2 class="text-xl font-bold text-gray-800">语音共创正在进行中</h2>
<p class="mt-2 text-sm leading-6 text-gray-600">
最近的语音共创会话仍可继续
{{ activeVoiceSession.working_title || '未命名语音会话' }}
当前状态 {{ activeVoiceSession.status }}已完成 {{ activeVoiceSession.total_turns }}
</p>
<p
v-if="activeVoiceSession.latest_requires_confirmation"
class="mt-2 text-sm text-amber-700"
>
上一轮仍在等待家长确认建议优先回到语音共创工作台处理
</p>
<p
v-else-if="activeVoiceSession.latest_safety_message"
class="mt-2 text-sm text-rose-700"
>
最近一轮触发了儿童内容安全兜底建议回到工作台查看详细记录
</p>
<div
class="mt-3 rounded-xl border px-3 py-3 text-sm leading-6"
:class="getVoiceSessionNextStep(activeVoiceSession).toneClass"
>
<div class="font-medium">
建议动作{{ getVoiceSessionNextStep(activeVoiceSession).label }}
</div>
<div class="mt-1 opacity-90">
{{ getVoiceSessionNextStep(activeVoiceSession).description }}
</div>
</div>
</div>
<BaseButton @click="continueActiveVoiceSession">
<SparklesIcon class="h-5 w-5 mr-2" />
{{ getVoiceSessionNextAction(activeVoiceSession).label }}
</BaseButton>
</div>
</BaseCard>
<BaseCard
v-if="voiceAnalytics && voiceAnalytics.total_sessions"
class="mb-8 border border-violet-100 bg-violet-50/40"
padding="lg"
>
<div class="flex flex-col gap-5 lg:flex-row lg:items-center lg:justify-between">
<div>
<h2 class="text-xl font-bold text-gray-800">语音共创运营摘要</h2>
<p class="mt-2 text-sm leading-6 text-gray-600">
{{ voiceAnalyticsWindowLabel }}你的语音共创已经累计
{{ voiceAnalytics.total_sessions }} 个会话{{ voiceAnalytics.total_turns }} turn
</p>
<p
v-if="voiceAnalytics.low_confidence_turns || voiceAnalytics.safety_interventions"
class="mt-2 text-sm text-gray-500"
>
低置信度确认 {{ voiceAnalytics.low_confidence_turns }}
安全介入 {{ voiceAnalytics.safety_interventions }}
</p>
<p
v-if="voiceAnalytics.attention_sessions"
class="mt-2 text-sm text-amber-700"
>
当前仍有 {{ voiceAnalytics.attention_sessions }} 个语音会话建议优先回到工作台处理
待确认 {{ voiceAnalytics.confirmation_attention_sessions }}
安全介入 {{ voiceAnalytics.safety_attention_sessions }}
失败待处理 {{ voiceAnalytics.failed_attention_sessions }}
</p>
<div
v-if="voiceAnalytics.attention_sessions"
class="mt-3 flex flex-wrap gap-2"
>
<button
v-if="voiceAnalytics.confirmation_attention_sessions"
type="button"
class="rounded-lg border border-amber-200 bg-amber-50 px-3 py-1.5 text-sm text-amber-700 transition-colors hover:border-amber-400"
@click="goToVoiceStudio({ reason: 'pending_confirmation', focus: 'confirmation' })"
>
查看待确认会话
</button>
<button
v-if="voiceAnalytics.safety_attention_sessions"
type="button"
class="rounded-lg border border-rose-200 bg-rose-50 px-3 py-1.5 text-sm text-rose-700 transition-colors hover:border-rose-400"
@click="goToVoiceStudio({ reason: 'safety_intervention', focus: 'safety' })"
>
查看安全介入会话
</button>
<button
v-if="voiceAnalytics.failed_attention_sessions"
type="button"
class="rounded-lg border border-slate-200 bg-slate-100 px-3 py-1.5 text-sm text-slate-700 transition-colors hover:border-slate-400"
@click="goToVoiceStudio({ reason: 'failed_turn', focus: 'failed' })"
>
查看失败待处理会话
</button>
</div>
<div class="mt-4 flex flex-wrap gap-2">
<button type="button" class="rounded-lg border px-3 py-1.5 text-sm transition-colors" :class="selectedVoiceWindow === '7' ? 'border-violet-700 bg-violet-700 text-white' : 'border-gray-200 bg-white text-gray-600 hover:border-gray-400'" @click="setVoiceWindow('7')">最近 7 </button>
<button type="button" class="rounded-lg border px-3 py-1.5 text-sm transition-colors" :class="selectedVoiceWindow === '30' ? 'border-violet-700 bg-violet-700 text-white' : 'border-gray-200 bg-white text-gray-600 hover:border-gray-400'" @click="setVoiceWindow('30')">最近 30 </button>
<button type="button" class="rounded-lg border px-3 py-1.5 text-sm transition-colors" :class="selectedVoiceWindow === 'all' ? 'border-violet-700 bg-violet-700 text-white' : 'border-gray-200 bg-white text-gray-600 hover:border-gray-400'" @click="setVoiceWindow('all')">全部</button>
</div>
</div>
<div class="grid grid-cols-2 gap-3 sm:grid-cols-4 lg:min-w-[520px]">
<div class="rounded-lg border border-white/80 bg-white px-3 py-3">
<div class="text-xs text-gray-500">Turn 成功率</div>
<div class="mt-1 text-lg font-semibold text-gray-800">
{{ voiceTurnSuccessRate }}%
</div>
</div>
<div class="rounded-lg border border-white/80 bg-white px-3 py-3">
<div class="text-xs text-gray-500">保存转化率</div>
<div class="mt-1 text-lg font-semibold text-emerald-700">
{{ voiceFinalizeRate }}%
</div>
</div>
<div class="rounded-lg border border-white/80 bg-white px-3 py-3">
<div class="text-xs text-gray-500">转写 / 语音失败</div>
<div class="mt-1 text-lg font-semibold text-gray-800">
{{ voiceAnalytics.asr_failures }} / {{ voiceAnalytics.tts_failures }}
</div>
</div>
<div class="rounded-lg border border-white/80 bg-white px-3 py-3">
<div class="text-xs text-gray-500">已完成会话</div>
<div class="mt-1 text-lg font-semibold text-violet-700">
{{ voiceAnalytics.finalized_sessions }}
</div>
</div>
</div>
</div>
</BaseCard>
<BaseCard class="mb-8" padding="lg">
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div class="text-center px-4 py-2">
<div class="text-3xl font-bold text-gray-800">{{ stories.length }}</div>
<div class="text-gray-500 text-sm mt-1">内容总数</div>
</div>
<div class="text-center px-4 py-2">
<div class="text-3xl font-bold text-gray-800">
{{ stories.filter((story) => story.mode === 'storybook').length }}
</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">{{ readableCount }}</div>
<div class="text-gray-500 text-sm mt-1">可阅读</div>
</div>
<div class="text-center px-4 py-2">
<div class="text-3xl font-bold text-gray-800">{{ attentionCount }}</div>
<div class="text-gray-500 text-sm mt-1">需关注</div>
</div>
</div>
</BaseCard>
<BaseCard
v-if="providerAnalytics?.total_calls"
class="mb-8"
padding="lg"
>
<div class="flex flex-col gap-5 lg:flex-row lg:items-center lg:justify-between">
<div>
<h2 class="text-xl font-bold text-gray-800">供应商运营摘要</h2>
<p class="mt-2 text-sm leading-6 text-gray-500">
最近生成和资源补全留下的供应商调用轨迹
</p>
<div class="mt-4 flex flex-wrap gap-2">
<button type="button" class="rounded-lg border px-3 py-1.5 text-sm transition-colors" :class="selectedWindow === '7' ? 'border-gray-900 bg-gray-900 text-white' : 'border-gray-200 bg-white text-gray-600 hover:border-gray-400'" @click="setWindow('7')">最近 7 </button>
<button type="button" class="rounded-lg border px-3 py-1.5 text-sm transition-colors" :class="selectedWindow === '30' ? 'border-gray-900 bg-gray-900 text-white' : 'border-gray-200 bg-white text-gray-600 hover:border-gray-400'" @click="setWindow('30')">最近 30 </button>
<button type="button" class="rounded-lg border px-3 py-1.5 text-sm transition-colors" :class="selectedWindow === 'all' ? 'border-gray-900 bg-gray-900 text-white' : 'border-gray-200 bg-white text-gray-600 hover:border-gray-400'" @click="setWindow('all')">全部</button>
</div>
<div class="mt-3 flex flex-wrap gap-2">
<button type="button" class="rounded-lg border px-3 py-1.5 text-sm transition-colors" :class="selectedCapability === 'all' ? 'border-emerald-600 bg-emerald-600 text-white' : 'border-gray-200 bg-white text-gray-600 hover:border-gray-400'" @click="setCapability('all')">全部能力</button>
<button type="button" class="rounded-lg border px-3 py-1.5 text-sm transition-colors" :class="selectedCapability === 'text' ? 'border-emerald-600 bg-emerald-600 text-white' : 'border-gray-200 bg-white text-gray-600 hover:border-gray-400'" @click="setCapability('text')">文本</button>
<button type="button" class="rounded-lg border px-3 py-1.5 text-sm transition-colors" :class="selectedCapability === 'image' ? 'border-emerald-600 bg-emerald-600 text-white' : 'border-gray-200 bg-white text-gray-600 hover:border-gray-400'" @click="setCapability('image')">图片</button>
<button type="button" class="rounded-lg border px-3 py-1.5 text-sm transition-colors" :class="selectedCapability === 'tts' ? 'border-emerald-600 bg-emerald-600 text-white' : 'border-gray-200 bg-white text-gray-600 hover:border-gray-400'" @click="setCapability('tts')">语音</button>
<button type="button" class="rounded-lg border px-3 py-1.5 text-sm transition-colors" :class="selectedCapability === 'storybook' ? 'border-emerald-600 bg-emerald-600 text-white' : 'border-gray-200 bg-white text-gray-600 hover:border-gray-400'" @click="setCapability('storybook')">绘本</button>
</div>
</div>
<div class="grid grid-cols-2 gap-3 sm:grid-cols-4 lg:min-w-[520px]">
<div class="rounded-lg border border-gray-100 bg-gray-50 px-3 py-3">
<div class="text-xs text-gray-500">成功率</div>
<div class="mt-1 text-lg font-semibold text-gray-800">{{ providerSuccessRate }}%</div>
</div>
<div class="rounded-lg border border-gray-100 bg-gray-50 px-3 py-3">
<div class="text-xs text-gray-500">平均耗时</div>
<div class="mt-1 text-lg font-semibold text-gray-800">{{ formatLatency(providerAnalytics.avg_latency_ms) }}</div>
</div>
<div class="rounded-lg border border-gray-100 bg-gray-50 px-3 py-3">
<div class="text-xs text-gray-500">预估成本</div>
<div class="mt-1 text-lg font-semibold text-gray-800">{{ formatCost(providerAnalytics.estimated_cost_usd) }}</div>
</div>
<div class="rounded-lg border border-gray-100 bg-gray-50 px-3 py-3">
<div class="text-xs text-gray-500">调用次数</div>
<div class="mt-1 text-lg font-semibold text-gray-800">{{ providerAnalytics.total_calls }}</div>
</div>
</div>
</div>
<p v-if="topProvider" class="mt-4 text-sm text-gray-500">
当前样本中最前面的能力组合是 {{ topProvider.capability }} / {{ topProvider.adapter }}成功 {{ topProvider.success_count }} 失败 {{ topProvider.failure_count }}
</p>
<p v-if="topFailureReason" class="mt-2 text-sm text-rose-600">
最常见失败原因{{ topFailureReason.reason }}{{ topFailureReason.count }}
</p>
</BaseCard>
<BaseCard
v-if="opsSummary"
class="mb-8"
padding="lg"
>
<div class="flex flex-col gap-5 lg:flex-row lg:items-center lg:justify-between">
<div>
<h2 class="text-xl font-bold text-gray-800">任务运行概览</h2>
<p class="mt-2 text-sm leading-6 text-gray-500">
最近 {{ opsSummary.window_hours }} 小时的任务健康度运行超过
{{ opsSummary.stale_threshold_minutes }} 分钟会被视为卡住
</p>
</div>
<div class="grid grid-cols-2 gap-3 sm:grid-cols-4 lg:min-w-[520px]">
<div class="rounded-lg border border-gray-100 bg-gray-50 px-3 py-3">
<div class="text-xs text-gray-500">运行中</div>
<div class="mt-1 text-lg font-semibold text-gray-800">{{ opsSummary.active_jobs }}</div>
</div>
<div class="rounded-lg border border-gray-100 bg-gray-50 px-3 py-3">
<div class="text-xs text-gray-500">超时待收敛</div>
<div class="mt-1 text-lg font-semibold" :class="opsSummary.stale_running_jobs ? 'text-amber-600' : 'text-gray-800'">
{{ opsSummary.stale_running_jobs }}
</div>
</div>
<div class="rounded-lg border border-gray-100 bg-gray-50 px-3 py-3">
<div class="text-xs text-gray-500">最近失败</div>
<div class="mt-1 text-lg font-semibold" :class="opsSummary.failed_jobs ? 'text-rose-600' : 'text-gray-800'">
{{ opsSummary.failed_jobs }}
</div>
</div>
<div class="rounded-lg border border-gray-100 bg-gray-50 px-3 py-3">
<div class="text-xs text-gray-500">资源任务</div>
<div class="mt-1 text-lg font-semibold text-gray-800">{{ opsSummary.asset_retry_jobs }}</div>
</div>
</div>
</div>
<p v-if="opsSummary.degraded_jobs" class="mt-4 text-sm text-amber-600">
最近 {{ opsSummary.window_hours }} 小时有 {{ opsSummary.degraded_jobs }} 个任务以降级完成收尾
</p>
<div v-if="opsSummary.recent_failures.length" class="mt-4 space-y-3">
<div
v-for="failure in opsSummary.recent_failures"
:key="failure.job_id"
class="rounded-lg border border-rose-100 bg-rose-50 px-4 py-3"
>
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="text-sm font-semibold text-gray-800">
{{ failure.story_title || `${formatOutputMode(failure.output_mode)}任务` }}
</div>
<div class="text-xs text-gray-500">{{ formatDate(failure.updated_at) }}</div>
</div>
<div class="mt-1 text-xs text-rose-600">
{{ failure.failure_label }} · {{ failure.error_message || '请打开任务轨迹查看原因' }}
</div>
</div>
</div>
<p v-else class="mt-4 text-sm text-emerald-600">
最近 {{ opsSummary.window_hours }} 小时没有失败任务当前链路比较稳定
</p>
</BaseCard>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
<router-link
v-for="story in stories"
:key="story.id"
:to="getStoryLink(story)"
class="block group"
>
<BaseCard hover padding="none" class="h-full overflow-hidden flex flex-col">
<div class="relative aspect-[4/3] overflow-hidden bg-gray-100">
<img
v-if="story.image_url"
:src="story.image_url"
:alt="story.title"
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
/>
<div
v-else
class="w-full h-full flex items-center justify-center text-gray-300"
>
<PhotoIcon class="h-12 w-12" />
</div>
<div class="absolute top-4 left-4 flex flex-wrap gap-2">
<span
class="px-2.5 py-1 rounded-full text-xs font-medium backdrop-blur-sm"
:class="story.mode === 'storybook' ? 'bg-amber-100/90 text-amber-800' : 'bg-violet-100/90 text-violet-800'"
>
{{ story.mode === 'storybook' ? '绘本' : '故事' }}
</span>
<span
class="px-2.5 py-1 rounded-full text-xs font-medium backdrop-blur-sm"
:class="getGenerationStatusMeta(story.generation_status).badgeClass"
>
{{ getGenerationStatusMeta(story.generation_status).label }}
</span>
</div>
<div class="absolute inset-0 bg-black/35 opacity-0 group-hover:opacity-100 transition-opacity duration-300 flex items-center justify-center">
<span class="inline-flex items-center gap-1 px-4 py-2 bg-white/90 text-gray-900 rounded-full font-medium shadow-lg backdrop-blur-sm transform translate-y-4 group-hover:translate-y-0 transition-transform duration-300">
{{ story.mode === 'storybook' ? '阅读绘本' : '阅读故事' }}
<ChevronRightIcon class="h-4 w-4" />
</span>
</div>
</div>
<div class="p-5 flex-1 flex flex-col">
<h3 class="font-bold text-xl text-gray-800 mb-3 line-clamp-2 group-hover:text-purple-600 transition-colors">
{{ story.title }}
</h3>
<p class="text-sm text-gray-500 mb-4 leading-6">
{{ getGenerationStatusMeta(story.generation_status).description }}
</p>
<div class="flex flex-wrap gap-2 mb-4">
<span
class="px-2 py-1 rounded-lg text-xs font-medium"
:class="getAssetStatusMeta(story.image_status).badgeClass"
>
封面{{ getAssetStatusMeta(story.image_status).label }}
</span>
<span
class="px-2 py-1 rounded-lg text-xs font-medium"
:class="getAssetStatusMeta(story.audio_status).badgeClass"
>
音频{{ getAssetStatusMeta(story.audio_status).label }}
</span>
</div>
<div
v-if="story.last_error"
class="mb-4 px-3 py-2 rounded-xl bg-amber-50 text-amber-700 text-sm line-clamp-2"
>
{{ story.last_error }}
</div>
<div class="mt-auto flex items-center justify-between text-sm text-gray-500">
<span>{{ formatDate(story.created_at) }}</span>
<span>
{{ story.image_url ? '已有封面' : '待补封面' }}
</span>
</div>
</div>
</BaseCard>
</router-link>
</div>
</template>
<CreateStoryModal v-model="showCreateModal" />
</div>
</template>