feat: add week 3 audio and timeline enhancements
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user