Files
dreamweaver/admin-frontend/src/views/ChildProfileTimeline.vue

225 lines
9.2 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 { onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { api } from '../api/client'
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,
HeartIcon,
CalendarIcon,
ChevronLeftIcon,
ExclamationCircleIcon
} from '@heroicons/vue/24/solid'
interface TimelineEvent {
date: string
type: 'story' | 'achievement' | 'milestone' | 'reading_event' | 'memory'
title: string
description: string | null
image_url: string | null
metadata: {
story_id?: number
mode?: string
[key: string]: any
} | null
}
interface TimelineResponse {
events: TimelineEvent[]
}
const route = useRoute()
const router = useRouter()
const loading = ref(true)
const error = ref('')
const events = ref<TimelineEvent[]>([])
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 '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 'reading_event': return 'text-emerald-500'
case 'memory': return 'text-pink-500'
case 'achievement': return 'text-yellow-500'
default: return 'text-gray-500'
}
}
function formatDate(isoStr: string) {
const date = new Date(isoStr)
return date.toLocaleDateString('zh-CN', {
month: 'long',
day: 'numeric',
year: 'numeric'
})
}
async function fetchTimeline() {
loading.value = true
try {
const profile = await api.get<any>(`/api/profiles/${profileId}`)
profileName.value = profile.name
const data = await api.get<TimelineResponse>(`/api/profiles/${profileId}/timeline`)
events.value = data.events
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
} finally {
loading.value = false
}
}
function handleEventClick(event: TimelineEvent) {
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 {
router.push(`/story/${event.metadata.story_id}`)
}
}
}
onMounted(fetchTimeline)
</script>
<template>
<div class="min-h-screen bg-slate-50 relative overflow-x-hidden font-sans">
<!-- 背景装饰 -->
<div class="absolute inset-0 z-0 pointer-events-none">
<div class="fixed top-0 right-0 w-96 h-96 bg-purple-200 rounded-full mix-blend-multiply filter blur-3xl opacity-30 animate-blob"></div>
<div class="fixed bottom-0 left-0 w-96 h-96 bg-pink-200 rounded-full mix-blend-multiply filter blur-3xl opacity-30 animate-blob animation-delay-2000"></div>
</div>
<!-- 顶部导航 -->
<div class="relative z-10 max-w-4xl mx-auto px-4 py-8">
<BaseButton as="router-link" :to="`/profiles/${profileId}`" variant="secondary" class="mb-8 flex w-32 items-center justify-center gap-2 shadow-sm hover:shadow-md transition-shadow">
<ChevronLeftIcon class="h-4 w-4" /> 返回档案
</BaseButton>
<div v-if="loading" class="py-20">
<LoadingSpinner text="正在追溯时光..." />
</div>
<div v-else-if="error" class="py-20">
<EmptyState
:icon="ExclamationCircleIcon"
title="出错了"
:description="error"
/>
</div>
<template v-else>
<div class="text-center mb-16 animate-fade-in-down">
<h1 class="text-4xl md:text-5xl font-extrabold gradient-text mb-4 tracking-tight">成长足迹</h1>
<p v-if="profileName" class="text-xl text-gray-600 font-medium"> {{ profileName }} 的奇妙冒险之旅 </p>
</div>
<!-- 暂无数据 -->
<div v-if="events.length === 0" class="text-center py-20 bg-white/50 backdrop-blur rounded-3xl border border-white">
<SparklesIcon class="h-16 w-16 text-purple-300 mx-auto mb-4" />
<p class="text-xl text-gray-500">还没有开始冒险呢先来创作第一个故事吧</p>
</div>
<!-- 时间轴内容 -->
<div v-else class="relative pb-20">
<!-- 垂直线 -->
<div class="absolute left-4 md:left-1/2 md:transform md:-translate-x-1/2 top-4 bottom-0 w-1 bg-gradient-to-b from-purple-400 via-pink-400 to-blue-400 rounded-full opacity-40"></div>
<!-- 事件列表 -->
<div v-for="(event, index) in events" :key="index"
class="mb-12 flex flex-col md:flex-row items-center w-full group relative"
:class="index % 2 === 0 ? 'md:flex-row' : 'md:flex-row-reverse'"
>
<!-- 宽度占位 (Desktop) -->
<div class="hidden md:block md:w-5/12"></div>
<!-- 中轴点 -->
<div class="z-20 absolute left-2 md:static flex items-center justify-center border-4 border-white shadow-lg shrink-0 group-hover:scale-110 transition-transform duration-300 w-6 h-6 rounded-full md:w-10 md:h-10 bg-white ring-4 ring-purple-100">
<component :is="getIcon(event.type)" class="h-3 w-3 md:h-5 md:w-5" :class="getColor(event.type)" />
</div>
<!-- 卡片 -->
<div class="w-full pl-12 md:pl-0 md:w-5/12 order-1" :class="index % 2 === 0 ? 'md:text-right md:pr-12' : 'md:text-left md:pl-12'">
<div
class="bg-white/90 backdrop-blur-md p-6 rounded-2xl shadow-xl hover:shadow-2xl transition-all duration-300 border border-white/60 transform hover:-translate-y-1 cursor-pointer overflow-hidden relative"
@click="handleEventClick(event)"
>
<!-- 装饰背景 -->
<div class="absolute -right-10 -top-10 w-24 h-24 bg-gradient-to-br from-purple-100 to-transparent rounded-full opacity-50"></div>
<div class="flex items-center gap-2 mb-3 text-sm text-gray-500 font-semibold uppercase tracking-wider" :class="index % 2 === 0 ? 'md:justify-end' : 'md:justify-start'">
<CalendarIcon class="h-4 w-4 text-purple-400" />
{{ formatDate(event.date) }}
</div>
<h3 class="text-xl font-bold text-gray-800 mb-2 group-hover:text-purple-600 transition-colors">{{ event.title }}</h3>
<p v-if="event.description" class="text-gray-600 text-sm leading-relaxed mb-4">{{ event.description }}</p>
<div v-if="event.image_url" class="relative mt-4 aspect-video rounded-xl overflow-hidden shadow-md group-hover:shadow-lg transition-shadow">
<img :src="event.image_url" loading="lazy" class="w-full h-full object-cover transform group-hover:scale-105 transition-transform duration-700" />
<div class="absolute inset-0 bg-gradient-to-t from-black/20 to-transparent"></div>
</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 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>
</div>
</div>
</template>
<style scoped>
.gradient-text {
@apply bg-clip-text text-transparent bg-gradient-to-r from-purple-600 to-pink-600;
}
.animate-blob {
animation: blob 7s infinite;
}
.animation-delay-2000 {
animation-delay: 2s;
}
@keyframes blob {
0% { transform: translate(0px, 0px) scale(1); }
33% { transform: translate(30px, -50px) scale(1.1); }
66% { transform: translate(-20px, 20px) scale(0.9); }
100% { transform: translate(0px, 0px) scale(1); }
}
.animate-fade-in-down {
animation: fadeInDown 0.8s ease-out;
}
@keyframes fadeInDown {
from { opacity: 0; transform: translateY(-20px); }
to { opacity: 1; transform: translateY(0); }
}
</style>