wip: snapshot full local workspace state
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

This commit is contained in:
2026-04-17 18:58:11 +08:00
parent fea4ef012f
commit b8d3cb4644
181 changed files with 16964 additions and 17486 deletions

View File

@@ -1,221 +1,221 @@
<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>
<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>