feat: add week 3 audio and timeline enhancements

This commit is contained in:
2026-04-18 22:10:48 +08:00
parent 4d54c144a8
commit 70efaf3ccf
20 changed files with 606 additions and 56 deletions

View File

@@ -6,18 +6,19 @@ import BaseButton from '../components/ui/BaseButton.vue'
import LoadingSpinner from '../components/ui/LoadingSpinner.vue'
import EmptyState from '../components/ui/EmptyState.vue'
import {
SparklesIcon,
BookOpenIcon,
TrophyIcon,
FlagIcon,
CalendarIcon,
SparklesIcon,
BookOpenIcon,
TrophyIcon,
FlagIcon,
HeartIcon,
CalendarIcon,
ChevronLeftIcon,
ExclamationCircleIcon
} from '@heroicons/vue/24/solid'
interface TimelineEvent {
date: string
type: 'story' | 'achievement' | 'milestone'
interface TimelineEvent {
date: string
type: 'story' | 'achievement' | 'milestone' | 'reading_event' | 'memory'
title: string
description: string | null
image_url: string | null
@@ -41,19 +42,23 @@ const profileId = route.params.id as string
const profileName = ref('') // We might need to fetch profile details separately or store it
function getIcon(type: string) {
switch (type) {
case 'milestone': return FlagIcon
case 'story': return BookOpenIcon
case 'achievement': return TrophyIcon
switch (type) {
case 'milestone': return FlagIcon
case 'story': return BookOpenIcon
case 'reading_event': return CalendarIcon
case 'memory': return HeartIcon
case 'achievement': return TrophyIcon
default: return SparklesIcon
}
}
function getColor(type: string) {
switch (type) {
case 'milestone': return 'text-blue-500'
case 'story': return 'text-purple-500'
case 'achievement': return 'text-yellow-500'
switch (type) {
case 'milestone': return 'text-blue-500'
case 'story': return 'text-purple-500'
case 'reading_event': return 'text-emerald-500'
case 'memory': return 'text-pink-500'
case 'achievement': return 'text-yellow-500'
default: return 'text-gray-500'
}
}
@@ -83,7 +88,7 @@ async function fetchTimeline() {
}
function handleEventClick(event: TimelineEvent) {
if (event.type === 'story' && event.metadata?.story_id) {
if ((event.type === 'story' || event.type === 'reading_event' || event.type === 'memory') && event.metadata?.story_id) {
if (event.metadata.mode === 'storybook') {
router.push(`/storybook/view/${event.metadata.story_id}`)
} else {
@@ -175,11 +180,17 @@ onMounted(fetchTimeline)
</div>
<!-- Role Badge -->
<div v-if="event.type === 'achievement'" class="mt-4 inline-flex items-center px-3 py-1 rounded-full bg-yellow-50 text-yellow-700 text-xs font-bold border border-yellow-200">
<TrophyIcon class="h-3 w-3 mr-1" /> 成就解锁
</div>
</div>
</div>
<div v-if="event.type === 'achievement'" class="mt-4 inline-flex items-center px-3 py-1 rounded-full bg-yellow-50 text-yellow-700 text-xs font-bold border border-yellow-200">
<TrophyIcon class="h-3 w-3 mr-1" /> 成就解锁
</div>
<div v-else-if="event.type === 'reading_event'" class="mt-4 inline-flex items-center px-3 py-1 rounded-full bg-emerald-50 text-emerald-700 text-xs font-bold border border-emerald-200">
<CalendarIcon class="h-3 w-3 mr-1" /> 阅读记录
</div>
<div v-else-if="event.type === 'memory'" class="mt-4 inline-flex items-center px-3 py-1 rounded-full bg-pink-50 text-pink-700 text-xs font-bold border border-pink-200">
<HeartIcon class="h-3 w-3 mr-1" /> 记忆沉淀
</div>
</div>
</div>
</div>
</div>
</template>

View File

@@ -37,10 +37,25 @@ interface Story {
}> | null
}
interface AudioCacheStatus {
story_id: number
audio_ready: boolean
cache_exists: boolean
cache_size_bytes: number | null
cache_updated_at: string | null
generation_status: string
text_status: string
image_status: string
audio_status: string
last_error: string | null
retryable_assets: Array<'image' | 'audio'>
}
const route = useRoute()
const router = useRouter()
const story = ref<Story | null>(null)
const audioCacheStatus = ref<AudioCacheStatus | null>(null)
const loading = ref(true)
const imageLoading = ref(false)
const audioLoading = ref(false)
@@ -60,6 +75,12 @@ const audioMeta = computed(() => getAssetStatusMeta(story.value?.audio_status))
const canRetryImage = computed(() => story.value?.retryable_assets.includes('image') ?? false)
const canRetryAudio = computed(() => story.value?.retryable_assets.includes('audio') ?? false)
const isAudioGenerating = computed(() => story.value?.audio_status === 'generating')
const audioCacheLabel = computed(() => {
if (!audioCacheStatus.value?.cache_exists) return '暂无缓存'
const size = audioCacheStatus.value.cache_size_bytes ?? 0
const kb = Math.max(1, Math.round(size / 1024))
return `已缓存 · ${kb} KB`
})
const assetGuidance = computed(() => {
if (story.value?.generation_status === 'degraded_completed') {
return '正文已经可读,失败的资源可以单独重试,不会覆盖当前故事。'
@@ -85,6 +106,7 @@ async function refreshStorySnapshot() {
}
story.value = data
await refreshAudioStatus().catch(() => undefined)
}
async function fetchStory() {
@@ -137,6 +159,7 @@ async function loadAudio() {
const blob = await response.blob()
audioUrl.value = URL.createObjectURL(blob)
await refreshStorySnapshot().catch(() => undefined)
await refreshAudioStatus().catch(() => undefined)
} catch (e) {
error.value = e instanceof Error ? e.message : '音频加载失败'
await refreshStorySnapshot().catch(() => undefined)
@@ -159,6 +182,7 @@ async function retryAudio() {
audioUrl.value = null
await loadAudio()
}
await refreshAudioStatus().catch(() => undefined)
await generationTraceRef.value?.refresh()
} catch (e) {
error.value = e instanceof Error ? e.message : '音频生成失败'
@@ -168,6 +192,28 @@ async function retryAudio() {
}
}
async function refreshAudioStatus() {
if (!story.value) return
audioCacheStatus.value = await api.get<AudioCacheStatus>(`/api/audio/${story.value.id}/status`)
}
async function clearAudioCache() {
if (!story.value) return
audioLoading.value = true
error.value = ''
try {
audioCacheStatus.value = await api.delete<AudioCacheStatus>(`/api/audio/${story.value.id}/cache`)
audioUrl.value = null
story.value = await api.get<Story>(`/api/stories/${story.value.id}`)
} catch (e) {
error.value = e instanceof Error ? e.message : '清理音频缓存失败'
} finally {
audioLoading.value = false
}
}
function togglePlay() {
if (!audioRef.value) return
@@ -202,6 +248,16 @@ function formatTime(seconds: number) {
return `${mins}:${secs.toString().padStart(2, '0')}`
}
function formatDateTime(value?: string | null) {
if (!value) return '暂无'
return new Intl.DateTimeFormat('zh-CN', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
}).format(new Date(value))
}
async function deleteStory() {
if (!story.value) return
@@ -329,6 +385,12 @@ onUnmounted(() => {
<p class="text-sm text-gray-500 leading-6">
{{ audioMeta.description }} 音频首次生成后会缓存复用状态记录的是当前可播放结果
</p>
<div class="mt-3 rounded-lg border border-gray-100 bg-gray-50 px-3 py-2 text-xs text-gray-500">
<div>{{ audioCacheLabel }}</div>
<div v-if="audioCacheStatus?.cache_updated_at" class="mt-1">
更新时间{{ formatDateTime(audioCacheStatus.cache_updated_at) }}
</div>
</div>
<BaseButton
v-if="canRetryAudio"
size="sm"
@@ -339,6 +401,16 @@ onUnmounted(() => {
>
{{ story.audio_status === 'failed' ? '重试音频' : '生成音频' }}
</BaseButton>
<BaseButton
v-if="audioCacheStatus?.cache_exists"
size="sm"
variant="secondary"
:loading="audioLoading"
class="mt-3 w-full"
@click="clearAudioCache"
>
清理缓存
</BaseButton>
</div>
</div>