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

429 lines
18 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 {
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
}
const router = useRouter()
const stories = ref<StoryItem[]>([])
const providerAnalytics = ref<GenerationProviderAnalytics | null>(null)
const opsSummary = ref<GenerationOpsSummary | null>(null)
const loading = ref(true)
const error = ref('')
const showCreateModal = ref(false)
const selectedWindow = 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)
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}` : ''}`
}
async function fetchStories() {
try {
const [storyList, analytics, ops] = await Promise.all([
api.get<StoryItem[]>('/api/stories'),
api.get<GenerationProviderAnalytics>(buildProviderAnalyticsPath()),
api.get<GenerationOpsSummary>('/api/generations/ops-summary'),
])
stories.value = storyList
providerAnalytics.value = analytics
opsSummary.value = ops
} 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 goToCreate() {
showCreateModal.value = true
}
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 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], () => {
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 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">Provider 运营摘要</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>