Some checks are pending
Build and Push Docker Images / changes (push) Waiting to run
Build and Push Docker Images / build-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (push) Blocked by required conditions
Build and Push Docker Images / build-admin-frontend (push) Blocked by required conditions
222 lines
8.9 KiB
Vue
222 lines
8.9 KiB
Vue
<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,
|
||
CalendarIcon,
|
||
ChevronLeftIcon,
|
||
ExclamationCircleIcon
|
||
} from '@heroicons/vue/24/solid'
|
||
|
||
interface TimelineEvent {
|
||
date: string
|
||
type: 'story' | 'achievement' | 'milestone'
|
||
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 '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'
|
||
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 {
|
||
// Ideally we should also fetch profile basic info here or if the timeline endpoint included it
|
||
// For now, let's just fetch timeline.
|
||
// Wait, let's fetch profile first to get the name
|
||
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.metadata?.story_id) {
|
||
// Check mode
|
||
if (event.metadata.mode === 'storybook') {
|
||
// 这里的逻辑有点复杂,因为目前 storybook viewer 是读 Store 的。
|
||
// 如果要持久化查看,需要修改 Viewer 支持从 ID 加载。
|
||
// 暂时先只支持跳转到普通故事详情,或者给出提示
|
||
// TODO: Viewer support loading by ID
|
||
router.push(`/story/${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>
|
||
</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>
|