wip: snapshot full local workspace state
Some checks failed
Build and Push Docker Images / changes (push) Has been cancelled
Build and Push Docker Images / build-backend (push) Has been cancelled
Build and Push Docker Images / build-frontend (push) Has been cancelled
Build and Push Docker Images / build-admin-frontend (push) Has been cancelled

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,28 +1,28 @@
const BASE_URL = ''
class ApiClient {
async request<T>(url: string, options: RequestInit = {}): Promise<T> {
const response = await fetch(`${BASE_URL}${url}`, {
...options,
credentials: 'include',
headers: {
'Content-Type': 'application/json',
...options.headers,
},
})
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: '请求失败' }))
throw new Error(error.detail || '请求失败')
}
return response.json()
}
get<T>(url: string): Promise<T> {
return this.request<T>(url)
}
const BASE_URL = ''
class ApiClient {
async request<T>(url: string, options: RequestInit = {}): Promise<T> {
const response = await fetch(`${BASE_URL}${url}`, {
...options,
credentials: 'include',
headers: {
'Content-Type': 'application/json',
...options.headers,
},
})
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: '请求失败' }))
throw new Error(error.detail || '请求失败')
}
return response.json()
}
get<T>(url: string): Promise<T> {
return this.request<T>(url)
}
post<T>(url: string, data?: unknown): Promise<T> {
return this.request<T>(url, {
method: 'POST',
@@ -41,5 +41,5 @@ class ApiClient {
return this.request<T>(url, { method: 'DELETE' })
}
}
export const api = new ApiClient()
export const api = new ApiClient()

View File

@@ -1,221 +1,221 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import BaseButton from './ui/BaseButton.vue'
import BaseInput from './ui/BaseInput.vue'
import BaseSelect from './ui/BaseSelect.vue'
import { XMarkIcon } from '@heroicons/vue/24/outline'
interface Props {
show: boolean
}
defineProps<Props>()
const emit = defineEmits<{
(e: 'close'): void
(e: 'submit', data: { type: string; value: Record<string, unknown> }): void
}>()
const selectedType = ref('favorite_character')
const loading = ref(false)
// 喜欢的角色表单
const characterForm = ref({
name: '',
description: '',
})
// 回避元素表单
const scaryForm = ref({
keyword: '',
category: 'other',
})
// 阅读偏好表单
const preferenceForm = ref({
preference: '',
category: '',
})
const typeOptions = [
{ value: 'favorite_character', label: '💕 喜欢的角色' },
{ value: 'scary_element', label: '⚠️ 回避元素' },
{ value: 'reading_preference', label: '📚 阅读偏好' },
]
const categoryOptions = [
{ value: 'creature', label: '生物' },
{ value: 'scene', label: '场景' },
{ value: 'action', label: '动作' },
{ value: 'other', label: '其他' },
]
const isValid = computed(() => {
switch (selectedType.value) {
case 'favorite_character':
return characterForm.value.name.trim().length > 0
case 'scary_element':
return scaryForm.value.keyword.trim().length > 0
case 'reading_preference':
return preferenceForm.value.preference.trim().length > 0
default:
return false
}
})
function resetForms() {
characterForm.value = { name: '', description: '' }
scaryForm.value = { keyword: '', category: 'other' }
preferenceForm.value = { preference: '', category: '' }
}
function handleClose() {
resetForms()
emit('close')
}
function handleSubmit() {
if (!isValid.value) return
let value: Record<string, unknown> = {}
switch (selectedType.value) {
case 'favorite_character':
value = {
name: characterForm.value.name.trim(),
description: characterForm.value.description.trim(),
}
break
case 'scary_element':
value = {
keyword: scaryForm.value.keyword.trim(),
category: scaryForm.value.category,
}
break
case 'reading_preference':
value = {
preference: preferenceForm.value.preference.trim(),
category: preferenceForm.value.category.trim(),
}
break
}
emit('submit', { type: selectedType.value, value })
resetForms()
}
</script>
<template>
<Teleport to="body">
<Transition name="modal">
<div
v-if="show"
class="fixed inset-0 z-50 flex items-center justify-center p-4"
>
<!-- 背景遮罩 -->
<div
class="absolute inset-0 bg-black/50 backdrop-blur-sm"
@click="handleClose"
></div>
<!-- 模态框内容 -->
<div class="relative bg-white rounded-2xl shadow-2xl w-full max-w-md p-6 transform transition-all">
<!-- 标题栏 -->
<div class="flex items-center justify-between mb-6">
<h2 class="text-xl font-bold text-gray-800">添加记忆</h2>
<button
@click="handleClose"
class="p-2 rounded-lg hover:bg-gray-100 transition-colors"
>
<XMarkIcon class="w-5 h-5 text-gray-500" />
</button>
</div>
<!-- 类型选择 -->
<div class="mb-4">
<BaseSelect
v-model="selectedType"
label="记忆类型"
:options="typeOptions"
/>
</div>
<!-- 喜欢的角色表单 -->
<div v-if="selectedType === 'favorite_character'" class="space-y-4">
<BaseInput
v-model="characterForm.name"
label="角色名称"
placeholder="例如:小兔子、勇敢的骑士"
required
/>
<BaseInput
v-model="characterForm.description"
label="角色描述(可选)"
placeholder="简短描述这个角色的特点"
/>
</div>
<!-- 回避元素表单 -->
<div v-if="selectedType === 'scary_element'" class="space-y-4">
<BaseInput
v-model="scaryForm.keyword"
label="回避的元素"
placeholder="例如:大灰狼、黑暗的森林"
required
/>
<BaseSelect
v-model="scaryForm.category"
label="分类"
:options="categoryOptions"
/>
<p class="text-sm text-gray-500">
添加后生成故事时会自动避免出现这些元素
</p>
</div>
<!-- 阅读偏好表单 -->
<div v-if="selectedType === 'reading_preference'" class="space-y-4">
<BaseInput
v-model="preferenceForm.preference"
label="偏好内容"
placeholder="例如:喜欢冒险故事、喜欢动物主题"
required
/>
<BaseInput
v-model="preferenceForm.category"
label="偏好类别(可选)"
placeholder="例如:题材、风格、长度"
/>
</div>
<!-- 操作按钮 -->
<div class="flex justify-end gap-3 mt-6 pt-4 border-t border-gray-100">
<BaseButton variant="secondary" @click="handleClose">
取消
</BaseButton>
<BaseButton :disabled="!isValid || loading" @click="handleSubmit">
{{ loading ? '添加中...' : '添加' }}
</BaseButton>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.modal-enter-active,
.modal-leave-active {
transition: all 0.3s ease;
}
.modal-enter-from,
.modal-leave-to {
opacity: 0;
}
.modal-enter-from > div:last-child,
.modal-leave-to > div:last-child {
transform: scale(0.95);
}
</style>
<script setup lang="ts">
import { ref, computed } from 'vue'
import BaseButton from './ui/BaseButton.vue'
import BaseInput from './ui/BaseInput.vue'
import BaseSelect from './ui/BaseSelect.vue'
import { XMarkIcon } from '@heroicons/vue/24/outline'
interface Props {
show: boolean
}
defineProps<Props>()
const emit = defineEmits<{
(e: 'close'): void
(e: 'submit', data: { type: string; value: Record<string, unknown> }): void
}>()
const selectedType = ref('favorite_character')
const loading = ref(false)
// 喜欢的角色表单
const characterForm = ref({
name: '',
description: '',
})
// 回避元素表单
const scaryForm = ref({
keyword: '',
category: 'other',
})
// 阅读偏好表单
const preferenceForm = ref({
preference: '',
category: '',
})
const typeOptions = [
{ value: 'favorite_character', label: '💕 喜欢的角色' },
{ value: 'scary_element', label: '⚠️ 回避元素' },
{ value: 'reading_preference', label: '📚 阅读偏好' },
]
const categoryOptions = [
{ value: 'creature', label: '生物' },
{ value: 'scene', label: '场景' },
{ value: 'action', label: '动作' },
{ value: 'other', label: '其他' },
]
const isValid = computed(() => {
switch (selectedType.value) {
case 'favorite_character':
return characterForm.value.name.trim().length > 0
case 'scary_element':
return scaryForm.value.keyword.trim().length > 0
case 'reading_preference':
return preferenceForm.value.preference.trim().length > 0
default:
return false
}
})
function resetForms() {
characterForm.value = { name: '', description: '' }
scaryForm.value = { keyword: '', category: 'other' }
preferenceForm.value = { preference: '', category: '' }
}
function handleClose() {
resetForms()
emit('close')
}
function handleSubmit() {
if (!isValid.value) return
let value: Record<string, unknown> = {}
switch (selectedType.value) {
case 'favorite_character':
value = {
name: characterForm.value.name.trim(),
description: characterForm.value.description.trim(),
}
break
case 'scary_element':
value = {
keyword: scaryForm.value.keyword.trim(),
category: scaryForm.value.category,
}
break
case 'reading_preference':
value = {
preference: preferenceForm.value.preference.trim(),
category: preferenceForm.value.category.trim(),
}
break
}
emit('submit', { type: selectedType.value, value })
resetForms()
}
</script>
<template>
<Teleport to="body">
<Transition name="modal">
<div
v-if="show"
class="fixed inset-0 z-50 flex items-center justify-center p-4"
>
<!-- 背景遮罩 -->
<div
class="absolute inset-0 bg-black/50 backdrop-blur-sm"
@click="handleClose"
></div>
<!-- 模态框内容 -->
<div class="relative bg-white rounded-2xl shadow-2xl w-full max-w-md p-6 transform transition-all">
<!-- 标题栏 -->
<div class="flex items-center justify-between mb-6">
<h2 class="text-xl font-bold text-gray-800">添加记忆</h2>
<button
@click="handleClose"
class="p-2 rounded-lg hover:bg-gray-100 transition-colors"
>
<XMarkIcon class="w-5 h-5 text-gray-500" />
</button>
</div>
<!-- 类型选择 -->
<div class="mb-4">
<BaseSelect
v-model="selectedType"
label="记忆类型"
:options="typeOptions"
/>
</div>
<!-- 喜欢的角色表单 -->
<div v-if="selectedType === 'favorite_character'" class="space-y-4">
<BaseInput
v-model="characterForm.name"
label="角色名称"
placeholder="例如:小兔子、勇敢的骑士"
required
/>
<BaseInput
v-model="characterForm.description"
label="角色描述(可选)"
placeholder="简短描述这个角色的特点"
/>
</div>
<!-- 回避元素表单 -->
<div v-if="selectedType === 'scary_element'" class="space-y-4">
<BaseInput
v-model="scaryForm.keyword"
label="回避的元素"
placeholder="例如:大灰狼、黑暗的森林"
required
/>
<BaseSelect
v-model="scaryForm.category"
label="分类"
:options="categoryOptions"
/>
<p class="text-sm text-gray-500">
添加后生成故事时会自动避免出现这些元素
</p>
</div>
<!-- 阅读偏好表单 -->
<div v-if="selectedType === 'reading_preference'" class="space-y-4">
<BaseInput
v-model="preferenceForm.preference"
label="偏好内容"
placeholder="例如:喜欢冒险故事、喜欢动物主题"
required
/>
<BaseInput
v-model="preferenceForm.category"
label="偏好类别(可选)"
placeholder="例如:题材、风格、长度"
/>
</div>
<!-- 操作按钮 -->
<div class="flex justify-end gap-3 mt-6 pt-4 border-t border-gray-100">
<BaseButton variant="secondary" @click="handleClose">
取消
</BaseButton>
<BaseButton :disabled="!isValid || loading" @click="handleSubmit">
{{ loading ? '添加中...' : '添加' }}
</BaseButton>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.modal-enter-active,
.modal-leave-active {
transition: all 0.3s ease;
}
.modal-enter-from,
.modal-leave-to {
opacity: 0;
}
.modal-enter-from > div:last-child,
.modal-leave-to > div:last-child {
transform: scale(0.95);
}
</style>

View File

@@ -1,380 +1,377 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import type { Component } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useUserStore } from '../stores/user'
import { useStorybookStore } from '../stores/storybook'
import { api } from '../api/client'
import BaseButton from './ui/BaseButton.vue'
import BaseInput from './ui/BaseInput.vue'
import BaseSelect from './ui/BaseSelect.vue'
import BaseTextarea from './ui/BaseTextarea.vue'
import AnalysisAnimation from './ui/AnalysisAnimation.vue'
import {
SparklesIcon,
PencilSquareIcon,
BookOpenIcon,
PhotoIcon,
XMarkIcon,
ExclamationCircleIcon,
ShieldCheckIcon,
UserGroupIcon,
ShareIcon,
CheckBadgeIcon,
ArrowPathIcon,
HeartIcon
} from '@heroicons/vue/24/outline'
const props = defineProps<{
modelValue: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
}>()
const { t } = useI18n()
const router = useRouter()
const userStore = useUserStore()
const storybookStore = useStorybookStore()
// State
const inputType = ref<'keywords' | 'full_story'>('keywords')
const outputMode = ref<'full_story' | 'storybook'>('full_story')
const inputData = ref('')
const educationTheme = ref('')
const loading = ref(false)
const error = ref('')
// Data
interface ChildProfile {
id: string
name: string
}
interface StoryUniverse {
id: string
name: string
}
const profiles = ref<ChildProfile[]>([])
const universes = ref<StoryUniverse[]>([])
const selectedProfileId = ref('')
const selectedUniverseId = ref('')
const profileError = ref('')
// Themes
type ThemeOption = { icon: Component; label: string; value: string }
const themes: ThemeOption[] = [
{ icon: ShieldCheckIcon, label: t('home.themeCourage'), value: '勇气' },
{ icon: UserGroupIcon, label: t('home.themeFriendship'), value: '友谊' },
{ icon: ShareIcon, label: t('home.themeSharing'), value: '分享' },
{ icon: CheckBadgeIcon, label: t('home.themeHonesty'), value: '诚实' },
{ icon: ArrowPathIcon, label: t('home.themePersistence'), value: '坚持' },
{ icon: HeartIcon, label: t('home.themeTolerance'), value: '包容' },
]
const profileOptions = computed(() =>
profiles.value.map(profile => ({ value: profile.id, label: profile.name })),
)
const universeOptions = computed(() =>
universes.value.map(universe => ({ value: universe.id, label: universe.name })),
)
// Methods
function close() {
emit('update:modelValue', false)
error.value = ''
}
async function fetchProfiles() {
if (!userStore.user) return
profileError.value = ''
try {
const data = await api.get<{ profiles: ChildProfile[] }>('/api/profiles')
profiles.value = data.profiles
if (!selectedProfileId.value && profiles.value.length > 0) {
selectedProfileId.value = profiles.value[0].id
}
} catch (e) {
profileError.value = e instanceof Error ? e.message : '档案加载失败'
}
}
async function fetchUniverses(profileId: string) {
selectedUniverseId.value = ''
if (!profileId) {
universes.value = []
return
}
try {
const data = await api.get<{ universes: StoryUniverse[] }>(`/api/profiles/${profileId}/universes`)
universes.value = data.universes
if (universes.value.length > 0) {
selectedUniverseId.value = universes.value[0].id
}
} catch (e) {
profileError.value = e instanceof Error ? e.message : '宇宙加载失败'
}
}
watch(selectedProfileId, (newId) => {
if (newId) fetchUniverses(newId)
})
watch(() => props.modelValue, (isOpen) => {
if (isOpen) {
fetchProfiles()
}
})
async function generateStory() {
if (!inputData.value.trim()) {
error.value = t('home.errorEmpty')
return
}
loading.value = true
error.value = ''
try {
const payload: Record<string, unknown> = {
type: inputType.value,
data: inputData.value,
education_theme: educationTheme.value || undefined,
}
if (selectedProfileId.value) payload.child_profile_id = selectedProfileId.value
if (selectedUniverseId.value) payload.universe_id = selectedUniverseId.value
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import type { Component } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useUserStore } from '../stores/user'
import { useStorybookStore } from '../stores/storybook'
import { api } from '../api/client'
import BaseButton from './ui/BaseButton.vue'
import BaseInput from './ui/BaseInput.vue'
import BaseSelect from './ui/BaseSelect.vue'
import BaseTextarea from './ui/BaseTextarea.vue'
import AnalysisAnimation from './ui/AnalysisAnimation.vue'
import {
SparklesIcon,
PencilSquareIcon,
BookOpenIcon,
PhotoIcon,
XMarkIcon,
ExclamationCircleIcon,
ShieldCheckIcon,
UserGroupIcon,
ShareIcon,
CheckBadgeIcon,
ArrowPathIcon,
HeartIcon
} from '@heroicons/vue/24/outline'
const props = defineProps<{
modelValue: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
}>()
const { t } = useI18n()
const router = useRouter()
const userStore = useUserStore()
const storybookStore = useStorybookStore()
// State
const inputType = ref<'keywords' | 'full_story'>('keywords')
const outputMode = ref<'full_story' | 'storybook'>('full_story')
const inputData = ref('')
const educationTheme = ref('')
const loading = ref(false)
const error = ref('')
// Data
interface ChildProfile {
id: string
name: string
}
interface StoryUniverse {
id: string
name: string
}
const profiles = ref<ChildProfile[]>([])
const universes = ref<StoryUniverse[]>([])
const selectedProfileId = ref('')
const selectedUniverseId = ref('')
const profileError = ref('')
// Themes
type ThemeOption = { icon: Component; label: string; value: string }
const themes: ThemeOption[] = [
{ icon: ShieldCheckIcon, label: t('home.themeCourage'), value: '勇气' },
{ icon: UserGroupIcon, label: t('home.themeFriendship'), value: '友谊' },
{ icon: ShareIcon, label: t('home.themeSharing'), value: '分享' },
{ icon: CheckBadgeIcon, label: t('home.themeHonesty'), value: '诚实' },
{ icon: ArrowPathIcon, label: t('home.themePersistence'), value: '坚持' },
{ icon: HeartIcon, label: t('home.themeTolerance'), value: '包容' },
]
const profileOptions = computed(() =>
profiles.value.map(profile => ({ value: profile.id, label: profile.name })),
)
const universeOptions = computed(() =>
universes.value.map(universe => ({ value: universe.id, label: universe.name })),
)
// Methods
function close() {
emit('update:modelValue', false)
error.value = ''
}
async function fetchProfiles() {
if (!userStore.user) return
profileError.value = ''
try {
const data = await api.get<{ profiles: ChildProfile[] }>('/api/profiles')
profiles.value = data.profiles
if (!selectedProfileId.value && profiles.value.length > 0) {
selectedProfileId.value = profiles.value[0].id
}
} catch (e) {
profileError.value = e instanceof Error ? e.message : '档案加载失败'
}
}
async function fetchUniverses(profileId: string) {
selectedUniverseId.value = ''
if (!profileId) {
universes.value = []
return
}
try {
const data = await api.get<{ universes: StoryUniverse[] }>(`/api/profiles/${profileId}/universes`)
universes.value = data.universes
if (universes.value.length > 0) {
selectedUniverseId.value = universes.value[0].id
}
} catch (e) {
profileError.value = e instanceof Error ? e.message : '宇宙加载失败'
}
}
watch(selectedProfileId, (newId) => {
if (newId) fetchUniverses(newId)
})
watch(() => props.modelValue, (isOpen) => {
if (isOpen) {
fetchProfiles()
}
})
async function generateStory() {
if (!inputData.value.trim()) {
error.value = t('home.errorEmpty')
return
}
loading.value = true
error.value = ''
try {
const payload: Record<string, unknown> = {
type: inputType.value,
data: inputData.value,
education_theme: educationTheme.value || undefined,
}
if (selectedProfileId.value) payload.child_profile_id = selectedProfileId.value
if (selectedUniverseId.value) payload.universe_id = selectedUniverseId.value
if (outputMode.value === 'storybook') {
const response = await api.post<any>('/api/storybook/generate', {
keywords: inputData.value,
education_theme: educationTheme.value || undefined,
generate_images: true,
page_count: 6,
child_profile_id: selectedProfileId.value || undefined,
universe_id: selectedUniverseId.value || undefined
page_count: 6,
child_profile_id: selectedProfileId.value || undefined,
universe_id: selectedUniverseId.value || undefined
})
storybookStore.setStorybook(response)
close()
router.push('/storybook/view')
const storybookPath = response.id ? `/storybook/view/${response.id}` : '/storybook/view'
router.push(storybookPath)
} else {
const result = await api.post<any>('/api/stories/generate/full', payload)
const query: Record<string, string> = {}
if (result.errors && Object.keys(result.errors).length > 0) {
if (result.errors.image) query.imageError = '1'
}
close()
router.push({ path: `/story/${result.id}`, query })
router.push(`/story/${result.id}`)
}
} catch (e) {
error.value = e instanceof Error ? e.message : '生成失败'
} finally {
loading.value = false
}
}
</script>
<template>
<Teleport to="body">
<Transition
enter-active-class="transition-opacity duration-300"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition-opacity duration-300"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-if="modelValue"
class="fixed inset-0 z-50 flex items-center justify-center p-4"
>
<!-- 遮罩层 -->
<div
class="absolute inset-0 bg-black/60 backdrop-blur-sm"
@click="!loading && close()"
></div>
<!-- 全屏加载动画 -->
<AnalysisAnimation v-if="loading" />
<!-- 模态框内容 -->
<div v-else class="relative w-full max-w-2xl max-h-[90vh] overflow-y-auto bg-[#1C2035] border border-gray-700/50 rounded-3xl shadow-2xl p-6 md:p-8">
<!-- 关闭按钮 -->
<button
@click="close"
class="absolute top-4 right-4 p-2 rounded-full hover:bg-[var(--bg-elevated)] transition-colors z-10"
>
<XMarkIcon class="h-6 w-6 text-gray-400" />
</button>
<!-- 标题 -->
<h2 class="text-2xl font-bold text-gray-100 mb-6">
{{ t('home.createModalTitle') }}
</h2>
<!-- 输入类型切换 -->
<div class="flex space-x-3 mb-6">
<button
@click="inputType = 'keywords'"
:class="[
'flex-1 py-3 rounded-xl font-semibold transition-all duration-300 flex items-center justify-center space-x-2',
inputType === 'keywords'
? 'btn-magic text-[#0D0F1A] shadow-lg bg-gradient-to-r from-[#FFD369] to-[#FF9F43]'
: 'bg-[#1C2035] text-gray-400 hover:border-[#FFD369] border border-white/10'
]"
>
<SparklesIcon class="h-5 w-5" />
<span>{{ t('home.inputTypeKeywords') }}</span>
</button>
<button
@click="inputType = 'full_story'"
:class="[
'flex-1 py-3 rounded-xl font-semibold transition-all duration-300 flex items-center justify-center space-x-2',
inputType === 'full_story'
? 'btn-magic text-[#0D0F1A] shadow-lg bg-gradient-to-r from-[#FFD369] to-[#FF9F43]'
: 'bg-[#1C2035] text-gray-400 hover:border-[#FFD369] border border-white/10'
]"
>
<PencilSquareIcon class="h-5 w-5" />
<span>{{ t('home.inputTypeStory') }}</span>
</button>
</div>
<!-- 呈现形式选择 (仅在关键词模式下可用) -->
<div class="flex space-x-3 mb-6" v-if="inputType === 'keywords'">
<button
@click="outputMode = 'full_story'"
:class="[
'flex-1 py-3 rounded-xl font-semibold transition-all duration-300 flex items-center justify-center space-x-2',
outputMode === 'full_story'
? 'bg-gradient-to-r from-blue-500 to-indigo-600 text-white shadow-lg'
: 'bg-[#1C2035] text-gray-400 hover:border-[#FFD369] border border-white/10'
]"
>
<BookOpenIcon class="h-5 w-5" />
<span>普通故事</span>
</button>
<button
@click="outputMode = 'storybook'"
:class="[
'flex-1 py-3 rounded-xl font-semibold transition-all duration-300 flex items-center justify-center space-x-2',
outputMode === 'storybook'
? 'bg-gradient-to-r from-amber-500 to-orange-600 text-white shadow-lg'
: 'bg-[#1C2035] text-gray-400 hover:border-[#FFD369] border border-white/10'
]"
>
<PhotoIcon class="h-5 w-5" />
<span>绘本模式</span>
</button>
</div>
<!-- 孩子档案选择 -->
<div class="mb-6">
<label class="block text-gray-300 font-semibold mb-2">
{{ t('home.selectProfile') }}
<span class="text-gray-500 font-normal text-sm ml-1">{{ t('home.selectProfileOptional') }}</span>
</label>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<BaseSelect
v-model="selectedProfileId"
:options="[{ value: '', label: t('home.noProfile') }, ...profileOptions]"
/>
<BaseSelect
v-model="selectedUniverseId"
:options="[{ value: '', label: t('home.noUniverse') }, ...universeOptions]"
:disabled="!selectedProfileId || universes.length === 0"
/>
</div>
<div v-if="profileError" class="text-sm text-red-500 mt-2">{{ profileError }}</div>
<div v-if="selectedProfileId && universes.length === 0" class="text-sm text-gray-500 mt-2">
{{ t('home.noUniverseHint') }}
</div>
</div>
<!-- 输入区域 -->
<div class="mb-6">
<label class="block text-gray-300 font-semibold mb-2">
{{ inputType === 'keywords' ? t('home.inputLabel') : t('home.inputLabelStory') }}
</label>
<BaseTextarea
v-model="inputData"
:placeholder="inputType === 'keywords' ? t('home.inputPlaceholder') : t('home.inputPlaceholderStory')"
:rows="5"
:maxLength="5000"
/>
</div>
<!-- 教育主题选择 -->
<div class="mb-6">
<label class="block text-gray-300 font-semibold mb-2">
{{ t('home.themeLabel') }}
<span class="text-gray-500 font-normal text-sm ml-1">{{ t('home.themeOptional') }}</span>
</label>
<div class="flex flex-wrap gap-2">
<button
v-for="theme in themes"
:key="theme.value"
@click="educationTheme = educationTheme === theme.value ? '' : theme.value"
:class="[
'px-4 py-2 rounded-lg font-medium transition-all duration-300 flex items-center space-x-1.5 text-sm',
educationTheme === theme.value
? 'bg-gradient-to-r from-[#FFD369] to-[#FF9F43] text-[#0D0F1A] shadow-md'
: 'bg-[#1C2035] text-gray-400 hover:border-[#FFD369] border border-white/10'
]"
>
<component :is="theme.icon" class="h-4 w-4" />
<span>{{ theme.label }}</span>
</button>
<BaseInput
v-model="educationTheme"
:placeholder="t('home.themeCustom')"
class="w-28"
/>
</div>
</div>
<!-- 错误提示 -->
<Transition
enter-active-class="transition-all duration-300"
enter-from-class="opacity-0 -translate-y-2"
enter-to-class="opacity-100 translate-y-0"
leave-active-class="transition-all duration-300"
leave-from-class="opacity-100 translate-y-0"
leave-to-class="opacity-0 -translate-y-2"
>
<div v-if="error" class="mb-4 p-3 bg-red-900/20 border border-red-800 text-red-400 rounded-xl flex items-center space-x-2">
<ExclamationCircleIcon class="h-5 w-5 flex-shrink-0" />
<span>{{ error }}</span>
</div>
</Transition>
<!-- 提交按钮 -->
<BaseButton
class="w-full"
size="lg"
:loading="loading"
:disabled="loading"
@click="generateStory"
>
<template v-if="loading">
{{ t('home.generating') }}
</template>
<template v-else>
<SparklesIcon class="h-5 w-5 mr-2" />
{{ t('home.startCreate') }}
</template>
</BaseButton>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
/* 临时添加一些 btn-magic 样式确保兼容 */
.btn-magic {
background: linear-gradient(135deg, #FFD369 0%, #FF9F43 100%);
color: #0D0F1A;
}
</style>
error.value = e instanceof Error ? e.message : '生成失败'
} finally {
loading.value = false
}
}
</script>
<template>
<Teleport to="body">
<Transition
enter-active-class="transition-opacity duration-300"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition-opacity duration-300"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-if="modelValue"
class="fixed inset-0 z-50 flex items-center justify-center p-4"
>
<!-- 遮罩层 -->
<div
class="absolute inset-0 bg-black/60 backdrop-blur-sm"
@click="!loading && close()"
></div>
<!-- 全屏加载动画 -->
<AnalysisAnimation v-if="loading" />
<!-- 模态框内容 -->
<div v-else class="relative w-full max-w-2xl max-h-[90vh] overflow-y-auto bg-[#1C2035] border border-gray-700/50 rounded-3xl shadow-2xl p-6 md:p-8">
<!-- 关闭按钮 -->
<button
@click="close"
class="absolute top-4 right-4 p-2 rounded-full hover:bg-[var(--bg-elevated)] transition-colors z-10"
>
<XMarkIcon class="h-6 w-6 text-gray-400" />
</button>
<!-- 标题 -->
<h2 class="text-2xl font-bold text-gray-100 mb-6">
{{ t('home.createModalTitle') }}
</h2>
<!-- 输入类型切换 -->
<div class="flex space-x-3 mb-6">
<button
@click="inputType = 'keywords'"
:class="[
'flex-1 py-3 rounded-xl font-semibold transition-all duration-300 flex items-center justify-center space-x-2',
inputType === 'keywords'
? 'btn-magic text-[#0D0F1A] shadow-lg bg-gradient-to-r from-[#FFD369] to-[#FF9F43]'
: 'bg-[#1C2035] text-gray-400 hover:border-[#FFD369] border border-white/10'
]"
>
<SparklesIcon class="h-5 w-5" />
<span>{{ t('home.inputTypeKeywords') }}</span>
</button>
<button
@click="inputType = 'full_story'"
:class="[
'flex-1 py-3 rounded-xl font-semibold transition-all duration-300 flex items-center justify-center space-x-2',
inputType === 'full_story'
? 'btn-magic text-[#0D0F1A] shadow-lg bg-gradient-to-r from-[#FFD369] to-[#FF9F43]'
: 'bg-[#1C2035] text-gray-400 hover:border-[#FFD369] border border-white/10'
]"
>
<PencilSquareIcon class="h-5 w-5" />
<span>{{ t('home.inputTypeStory') }}</span>
</button>
</div>
<!-- 呈现形式选择 (仅在关键词模式下可用) -->
<div class="flex space-x-3 mb-6" v-if="inputType === 'keywords'">
<button
@click="outputMode = 'full_story'"
:class="[
'flex-1 py-3 rounded-xl font-semibold transition-all duration-300 flex items-center justify-center space-x-2',
outputMode === 'full_story'
? 'bg-gradient-to-r from-blue-500 to-indigo-600 text-white shadow-lg'
: 'bg-[#1C2035] text-gray-400 hover:border-[#FFD369] border border-white/10'
]"
>
<BookOpenIcon class="h-5 w-5" />
<span>普通故事</span>
</button>
<button
@click="outputMode = 'storybook'"
:class="[
'flex-1 py-3 rounded-xl font-semibold transition-all duration-300 flex items-center justify-center space-x-2',
outputMode === 'storybook'
? 'bg-gradient-to-r from-amber-500 to-orange-600 text-white shadow-lg'
: 'bg-[#1C2035] text-gray-400 hover:border-[#FFD369] border border-white/10'
]"
>
<PhotoIcon class="h-5 w-5" />
<span>绘本模式</span>
</button>
</div>
<!-- 孩子档案选择 -->
<div class="mb-6">
<label class="block text-gray-300 font-semibold mb-2">
{{ t('home.selectProfile') }}
<span class="text-gray-500 font-normal text-sm ml-1">{{ t('home.selectProfileOptional') }}</span>
</label>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<BaseSelect
v-model="selectedProfileId"
:options="[{ value: '', label: t('home.noProfile') }, ...profileOptions]"
/>
<BaseSelect
v-model="selectedUniverseId"
:options="[{ value: '', label: t('home.noUniverse') }, ...universeOptions]"
:disabled="!selectedProfileId || universes.length === 0"
/>
</div>
<div v-if="profileError" class="text-sm text-red-500 mt-2">{{ profileError }}</div>
<div v-if="selectedProfileId && universes.length === 0" class="text-sm text-gray-500 mt-2">
{{ t('home.noUniverseHint') }}
</div>
</div>
<!-- 输入区域 -->
<div class="mb-6">
<label class="block text-gray-300 font-semibold mb-2">
{{ inputType === 'keywords' ? t('home.inputLabel') : t('home.inputLabelStory') }}
</label>
<BaseTextarea
v-model="inputData"
:placeholder="inputType === 'keywords' ? t('home.inputPlaceholder') : t('home.inputPlaceholderStory')"
:rows="5"
:maxLength="5000"
/>
</div>
<!-- 教育主题选择 -->
<div class="mb-6">
<label class="block text-gray-300 font-semibold mb-2">
{{ t('home.themeLabel') }}
<span class="text-gray-500 font-normal text-sm ml-1">{{ t('home.themeOptional') }}</span>
</label>
<div class="flex flex-wrap gap-2">
<button
v-for="theme in themes"
:key="theme.value"
@click="educationTheme = educationTheme === theme.value ? '' : theme.value"
:class="[
'px-4 py-2 rounded-lg font-medium transition-all duration-300 flex items-center space-x-1.5 text-sm',
educationTheme === theme.value
? 'bg-gradient-to-r from-[#FFD369] to-[#FF9F43] text-[#0D0F1A] shadow-md'
: 'bg-[#1C2035] text-gray-400 hover:border-[#FFD369] border border-white/10'
]"
>
<component :is="theme.icon" class="h-4 w-4" />
<span>{{ theme.label }}</span>
</button>
<BaseInput
v-model="educationTheme"
:placeholder="t('home.themeCustom')"
class="w-28"
/>
</div>
</div>
<!-- 错误提示 -->
<Transition
enter-active-class="transition-all duration-300"
enter-from-class="opacity-0 -translate-y-2"
enter-to-class="opacity-100 translate-y-0"
leave-active-class="transition-all duration-300"
leave-from-class="opacity-100 translate-y-0"
leave-to-class="opacity-0 -translate-y-2"
>
<div v-if="error" class="mb-4 p-3 bg-red-900/20 border border-red-800 text-red-400 rounded-xl flex items-center space-x-2">
<ExclamationCircleIcon class="h-5 w-5 flex-shrink-0" />
<span>{{ error }}</span>
</div>
</Transition>
<!-- 提交按钮 -->
<BaseButton
class="w-full"
size="lg"
:loading="loading"
:disabled="loading"
@click="generateStory"
>
<template v-if="loading">
{{ t('home.generating') }}
</template>
<template v-else>
<SparklesIcon class="h-5 w-5 mr-2" />
{{ t('home.startCreate') }}
</template>
</BaseButton>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
/* 临时添加一些 btn-magic 样式确保兼容 */
.btn-magic {
background: linear-gradient(135deg, #FFD369 0%, #FF9F43 100%);
color: #0D0F1A;
}
</style>

View File

@@ -1,226 +1,226 @@
<script setup lang="ts">
import { computed } from 'vue'
import {
HeartIcon,
BookOpenIcon,
ExclamationTriangleIcon,
AcademicCapIcon,
SparklesIcon,
StarIcon,
TrophyIcon,
LightBulbIcon
} from '@heroicons/vue/24/outline'
export interface MemoryItem {
id: string
type: string
value: Record<string, unknown>
base_weight: number
ttl_days: number | null
created_at: string
last_used_at: string | null
}
interface Props {
memories: MemoryItem[]
loading?: boolean
showActions?: boolean
}
const props = withDefaults(defineProps<Props>(), {
loading: false,
showActions: true,
})
const emit = defineEmits<{
(e: 'delete', id: string): void
}>()
// 记忆类型配置
const typeConfig: Record<string, {
label: string
icon: typeof HeartIcon
color: string
bgColor: string
}> = {
recent_story: {
label: '近期故事',
icon: BookOpenIcon,
color: 'text-blue-600',
bgColor: 'bg-blue-50',
},
favorite_character: {
label: '喜欢的角色',
icon: HeartIcon,
color: 'text-pink-600',
bgColor: 'bg-pink-50',
},
scary_element: {
label: '回避元素',
icon: ExclamationTriangleIcon,
color: 'text-amber-600',
bgColor: 'bg-amber-50',
},
vocabulary_growth: {
label: '词汇积累',
icon: AcademicCapIcon,
color: 'text-green-600',
bgColor: 'bg-green-50',
},
emotional_highlight: {
label: '情感高光',
icon: SparklesIcon,
color: 'text-purple-600',
bgColor: 'bg-purple-50',
},
reading_preference: {
label: '阅读偏好',
icon: StarIcon,
color: 'text-indigo-600',
bgColor: 'bg-indigo-50',
},
milestone: {
label: '里程碑',
icon: TrophyIcon,
color: 'text-yellow-600',
bgColor: 'bg-yellow-50',
},
skill_mastered: {
label: '掌握的技能',
icon: LightBulbIcon,
color: 'text-teal-600',
bgColor: 'bg-teal-50',
},
}
// 按类型分组记忆
const groupedMemories = computed(() => {
const groups: Record<string, MemoryItem[]> = {}
for (const memory of props.memories) {
if (!groups[memory.type]) {
groups[memory.type] = []
}
groups[memory.type].push(memory)
}
return groups
})
// 获取记忆的显示文本
function getMemoryDisplayText(memory: MemoryItem): string {
const value = memory.value as Record<string, unknown>
switch (memory.type) {
case 'recent_story':
return value.title as string || '未知故事'
case 'favorite_character':
return `${value.name || '未知角色'}${value.description ? ` - ${value.description}` : ''}`
case 'scary_element':
return value.keyword as string || '未知元素'
case 'vocabulary_growth':
return value.word as string || '未知词汇'
case 'emotional_highlight':
return value.description as string || '情感记忆'
case 'reading_preference':
return value.preference as string || '阅读偏好'
case 'milestone':
return value.title as string || '里程碑'
case 'skill_mastered':
return value.skill as string || '技能'
default:
return JSON.stringify(value)
}
}
// 格式化日期
function formatDate(dateStr: string): string {
const date = new Date(dateStr)
return date.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' })
}
function handleDelete(id: string) {
if (window.confirm('确定要删除这条记忆吗?')) {
emit('delete', id)
}
}
</script>
<template>
<div class="space-y-4">
<!-- 加载状态 -->
<div v-if="loading" class="flex items-center justify-center py-8">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-500"></div>
</div>
<!-- 空状态 -->
<div v-else-if="memories.length === 0" class="text-center py-8 text-gray-500">
<SparklesIcon class="w-12 h-12 mx-auto mb-3 text-gray-300" />
<p>还没有记忆记录</p>
<p class="text-sm mt-1">阅读故事后会自动积累记忆</p>
</div>
<!-- 记忆列表 -->
<div v-else class="space-y-6">
<div v-for="(items, type) in groupedMemories" :key="type">
<div class="flex items-center gap-2 mb-3">
<component
:is="typeConfig[type]?.icon || SparklesIcon"
:class="['w-5 h-5', typeConfig[type]?.color || 'text-gray-500']"
/>
<h3 class="font-semibold text-gray-700">
{{ typeConfig[type]?.label || type }}
</h3>
<span class="text-xs text-gray-400 bg-gray-100 px-2 py-0.5 rounded-full">
{{ items.length }}
</span>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div
v-for="memory in items"
:key="memory.id"
:class="[
'group relative p-4 rounded-xl border transition-all hover:shadow-md',
typeConfig[memory.type]?.bgColor || 'bg-gray-50',
'border-gray-100'
]"
>
<div class="flex items-start justify-between">
<div class="flex-1 min-w-0">
<p class="font-medium text-gray-800 truncate">
{{ getMemoryDisplayText(memory) }}
</p>
<p class="text-xs text-gray-400 mt-1">
{{ formatDate(memory.created_at) }}
<span v-if="memory.ttl_days" class="ml-2">
· 有效期 {{ memory.ttl_days }}
</span>
</p>
</div>
<!-- 删除按钮 -->
<button
v-if="showActions"
@click="handleDelete(memory.id)"
class="opacity-0 group-hover:opacity-100 transition-opacity p-1 rounded-lg hover:bg-red-100 text-gray-400 hover:text-red-500"
title="删除"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- 权重指示器 -->
<div
v-if="memory.base_weight > 1"
class="absolute top-2 right-2 w-2 h-2 rounded-full bg-yellow-400"
title="高权重记忆"
></div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import {
HeartIcon,
BookOpenIcon,
ExclamationTriangleIcon,
AcademicCapIcon,
SparklesIcon,
StarIcon,
TrophyIcon,
LightBulbIcon
} from '@heroicons/vue/24/outline'
export interface MemoryItem {
id: string
type: string
value: Record<string, unknown>
base_weight: number
ttl_days: number | null
created_at: string
last_used_at: string | null
}
interface Props {
memories: MemoryItem[]
loading?: boolean
showActions?: boolean
}
const props = withDefaults(defineProps<Props>(), {
loading: false,
showActions: true,
})
const emit = defineEmits<{
(e: 'delete', id: string): void
}>()
// 记忆类型配置
const typeConfig: Record<string, {
label: string
icon: typeof HeartIcon
color: string
bgColor: string
}> = {
recent_story: {
label: '近期故事',
icon: BookOpenIcon,
color: 'text-blue-600',
bgColor: 'bg-blue-50',
},
favorite_character: {
label: '喜欢的角色',
icon: HeartIcon,
color: 'text-pink-600',
bgColor: 'bg-pink-50',
},
scary_element: {
label: '回避元素',
icon: ExclamationTriangleIcon,
color: 'text-amber-600',
bgColor: 'bg-amber-50',
},
vocabulary_growth: {
label: '词汇积累',
icon: AcademicCapIcon,
color: 'text-green-600',
bgColor: 'bg-green-50',
},
emotional_highlight: {
label: '情感高光',
icon: SparklesIcon,
color: 'text-purple-600',
bgColor: 'bg-purple-50',
},
reading_preference: {
label: '阅读偏好',
icon: StarIcon,
color: 'text-indigo-600',
bgColor: 'bg-indigo-50',
},
milestone: {
label: '里程碑',
icon: TrophyIcon,
color: 'text-yellow-600',
bgColor: 'bg-yellow-50',
},
skill_mastered: {
label: '掌握的技能',
icon: LightBulbIcon,
color: 'text-teal-600',
bgColor: 'bg-teal-50',
},
}
// 按类型分组记忆
const groupedMemories = computed(() => {
const groups: Record<string, MemoryItem[]> = {}
for (const memory of props.memories) {
if (!groups[memory.type]) {
groups[memory.type] = []
}
groups[memory.type].push(memory)
}
return groups
})
// 获取记忆的显示文本
function getMemoryDisplayText(memory: MemoryItem): string {
const value = memory.value as Record<string, unknown>
switch (memory.type) {
case 'recent_story':
return value.title as string || '未知故事'
case 'favorite_character':
return `${value.name || '未知角色'}${value.description ? ` - ${value.description}` : ''}`
case 'scary_element':
return value.keyword as string || '未知元素'
case 'vocabulary_growth':
return value.word as string || '未知词汇'
case 'emotional_highlight':
return value.description as string || '情感记忆'
case 'reading_preference':
return value.preference as string || '阅读偏好'
case 'milestone':
return value.title as string || '里程碑'
case 'skill_mastered':
return value.skill as string || '技能'
default:
return JSON.stringify(value)
}
}
// 格式化日期
function formatDate(dateStr: string): string {
const date = new Date(dateStr)
return date.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' })
}
function handleDelete(id: string) {
if (window.confirm('确定要删除这条记忆吗?')) {
emit('delete', id)
}
}
</script>
<template>
<div class="space-y-4">
<!-- 加载状态 -->
<div v-if="loading" class="flex items-center justify-center py-8">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-500"></div>
</div>
<!-- 空状态 -->
<div v-else-if="memories.length === 0" class="text-center py-8 text-gray-500">
<SparklesIcon class="w-12 h-12 mx-auto mb-3 text-gray-300" />
<p>还没有记忆记录</p>
<p class="text-sm mt-1">阅读故事后会自动积累记忆</p>
</div>
<!-- 记忆列表 -->
<div v-else class="space-y-6">
<div v-for="(items, type) in groupedMemories" :key="type">
<div class="flex items-center gap-2 mb-3">
<component
:is="typeConfig[type]?.icon || SparklesIcon"
:class="['w-5 h-5', typeConfig[type]?.color || 'text-gray-500']"
/>
<h3 class="font-semibold text-gray-700">
{{ typeConfig[type]?.label || type }}
</h3>
<span class="text-xs text-gray-400 bg-gray-100 px-2 py-0.5 rounded-full">
{{ items.length }}
</span>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div
v-for="memory in items"
:key="memory.id"
:class="[
'group relative p-4 rounded-xl border transition-all hover:shadow-md',
typeConfig[memory.type]?.bgColor || 'bg-gray-50',
'border-gray-100'
]"
>
<div class="flex items-start justify-between">
<div class="flex-1 min-w-0">
<p class="font-medium text-gray-800 truncate">
{{ getMemoryDisplayText(memory) }}
</p>
<p class="text-xs text-gray-400 mt-1">
{{ formatDate(memory.created_at) }}
<span v-if="memory.ttl_days" class="ml-2">
· 有效期 {{ memory.ttl_days }}
</span>
</p>
</div>
<!-- 删除按钮 -->
<button
v-if="showActions"
@click="handleDelete(memory.id)"
class="opacity-0 group-hover:opacity-100 transition-opacity p-1 rounded-lg hover:bg-red-100 text-gray-400 hover:text-red-500"
title="删除"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- 权重指示器 -->
<div
v-if="memory.base_weight > 1"
class="absolute top-2 right-2 w-2 h-2 rounded-full bg-yellow-400"
title="高权重记忆"
></div>
</div>
</div>
</div>
</div>
</div>
</template>

View File

@@ -1,139 +1,139 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
const steps = [
'正在接收梦境信号...',
'编织故事脉络...',
'绘制精美插画 (需要一点点魔法时间)...',
'撒上一些星光粉...',
'即将完成独一无二的绘本!'
]
const currentStepIndex = ref(0)
let stepInterval: number | undefined
onMounted(() => {
stepInterval = window.setInterval(() => {
if (currentStepIndex.value < steps.length - 1) {
currentStepIndex.value++
}
}, 2500)
})
onUnmounted(() => {
if (stepInterval) clearInterval(stepInterval)
})
</script>
<template>
<div class="fixed inset-0 z-[100] flex flex-col items-center justify-center bg-[#1C2035] overflow-hidden">
<!-- 背景星空 -->
<div class="absolute inset-0 overflow-hidden">
<div v-for="i in 20" :key="i"
class="absolute rounded-full bg-white animate-twinkle"
:style="{
top: `${Math.random() * 100}%`,
left: `${Math.random() * 100}%`,
width: `${Math.random() * 3 + 1}px`,
height: `${Math.random() * 3 + 1}px`,
animationDelay: `${Math.random() * 3}s`,
opacity: Math.random() * 0.7 + 0.3
}"
></div>
</div>
<!-- 核心动画梦境织机 -->
<div class="relative w-64 h-64 mb-12 flex items-center justify-center">
<!-- 外圈光晕 -->
<div class="absolute inset-0 border-4 border-amber-500/20 rounded-full animate-spin-slow"></div>
<div class="absolute inset-2 border-2 border-amber-400/30 rounded-full animate-spin-reverse-slower"></div>
<!-- 核心光球 -->
<div class="relative z-10 w-32 h-32 bg-gradient-to-br from-amber-300 to-orange-500 rounded-full shadow-[0_0_60px_rgba(245,158,11,0.5)] animate-pulse-glow flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" class="h-16 w-16 text-white animate-bounce-gentle" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19.428 15.428a2 2 0 00-1.022-.547l-2.384-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z" />
</svg>
</div>
<!-- 飞舞的粒子 -->
<div class="absolute inset-0 animate-spin-slow">
<div class="absolute top-0 left-1/2 w-3 h-3 bg-amber-200 rounded-full shadow-lg blur-[1px]"></div>
<div class="absolute bottom-10 right-10 w-2 h-2 bg-purple-300 rounded-full shadow-lg blur-[1px]"></div>
</div>
</div>
<!-- 文字提示 -->
<div class="z-10 text-center space-y-4">
<h3 class="text-3xl font-bold bg-gradient-to-r from-amber-200 to-orange-100 bg-clip-text text-transparent animate-gradient-x">
梦境编织中...
</h3>
<Transition mode="out-in" name="fade-slide">
<p :key="currentStepIndex" class="text-stone-300 text-lg font-medium tracking-wide h-8">
{{ steps[currentStepIndex] }}
</p>
</Transition>
</div>
</div>
</template>
<style scoped>
@keyframes spin-slow {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@keyframes spin-reverse-slower {
from { transform: rotate(360deg); }
to { transform: rotate(0deg); }
}
@keyframes pulse-glow {
0%, 100% { transform: scale(1); box-shadow: 0 0 40px rgba(245,158,11,0.4); }
50% { transform: scale(1.05); box-shadow: 0 0 70px rgba(245,158,11,0.7); }
}
@keyframes bounce-gentle {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-5px); }
}
@keyframes twinkle {
0%, 100% { opacity: 0.3; transform: scale(0.8); }
50% { opacity: 0.8; transform: scale(1.2); }
}
.animate-spin-slow {
animation: spin-slow 12s linear infinite;
}
.animate-spin-reverse-slower {
animation: spin-reverse-slower 20s linear infinite;
}
.animate-pulse-glow {
animation: pulse-glow 3s ease-in-out infinite;
}
.animate-bounce-gentle {
animation: bounce-gentle 3s ease-in-out infinite;
}
.animate-twinkle {
animation: twinkle 4s ease-in-out infinite;
}
.fade-slide-enter-active,
.fade-slide-leave-active {
transition: all 0.5s ease;
}
.fade-slide-enter-from {
opacity: 0;
transform: translateY(10px);
}
.fade-slide-leave-to {
opacity: 0;
transform: translateY(-10px);
}
</style>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
const steps = [
'正在接收梦境信号...',
'编织故事脉络...',
'绘制精美插画 (需要一点点魔法时间)...',
'撒上一些星光粉...',
'即将完成独一无二的绘本!'
]
const currentStepIndex = ref(0)
let stepInterval: number | undefined
onMounted(() => {
stepInterval = window.setInterval(() => {
if (currentStepIndex.value < steps.length - 1) {
currentStepIndex.value++
}
}, 2500)
})
onUnmounted(() => {
if (stepInterval) clearInterval(stepInterval)
})
</script>
<template>
<div class="fixed inset-0 z-[100] flex flex-col items-center justify-center bg-[#1C2035] overflow-hidden">
<!-- 背景星空 -->
<div class="absolute inset-0 overflow-hidden">
<div v-for="i in 20" :key="i"
class="absolute rounded-full bg-white animate-twinkle"
:style="{
top: `${Math.random() * 100}%`,
left: `${Math.random() * 100}%`,
width: `${Math.random() * 3 + 1}px`,
height: `${Math.random() * 3 + 1}px`,
animationDelay: `${Math.random() * 3}s`,
opacity: Math.random() * 0.7 + 0.3
}"
></div>
</div>
<!-- 核心动画梦境织机 -->
<div class="relative w-64 h-64 mb-12 flex items-center justify-center">
<!-- 外圈光晕 -->
<div class="absolute inset-0 border-4 border-amber-500/20 rounded-full animate-spin-slow"></div>
<div class="absolute inset-2 border-2 border-amber-400/30 rounded-full animate-spin-reverse-slower"></div>
<!-- 核心光球 -->
<div class="relative z-10 w-32 h-32 bg-gradient-to-br from-amber-300 to-orange-500 rounded-full shadow-[0_0_60px_rgba(245,158,11,0.5)] animate-pulse-glow flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" class="h-16 w-16 text-white animate-bounce-gentle" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19.428 15.428a2 2 0 00-1.022-.547l-2.384-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z" />
</svg>
</div>
<!-- 飞舞的粒子 -->
<div class="absolute inset-0 animate-spin-slow">
<div class="absolute top-0 left-1/2 w-3 h-3 bg-amber-200 rounded-full shadow-lg blur-[1px]"></div>
<div class="absolute bottom-10 right-10 w-2 h-2 bg-purple-300 rounded-full shadow-lg blur-[1px]"></div>
</div>
</div>
<!-- 文字提示 -->
<div class="z-10 text-center space-y-4">
<h3 class="text-3xl font-bold bg-gradient-to-r from-amber-200 to-orange-100 bg-clip-text text-transparent animate-gradient-x">
梦境编织中...
</h3>
<Transition mode="out-in" name="fade-slide">
<p :key="currentStepIndex" class="text-stone-300 text-lg font-medium tracking-wide h-8">
{{ steps[currentStepIndex] }}
</p>
</Transition>
</div>
</div>
</template>
<style scoped>
@keyframes spin-slow {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@keyframes spin-reverse-slower {
from { transform: rotate(360deg); }
to { transform: rotate(0deg); }
}
@keyframes pulse-glow {
0%, 100% { transform: scale(1); box-shadow: 0 0 40px rgba(245,158,11,0.4); }
50% { transform: scale(1.05); box-shadow: 0 0 70px rgba(245,158,11,0.7); }
}
@keyframes bounce-gentle {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-5px); }
}
@keyframes twinkle {
0%, 100% { opacity: 0.3; transform: scale(0.8); }
50% { opacity: 0.8; transform: scale(1.2); }
}
.animate-spin-slow {
animation: spin-slow 12s linear infinite;
}
.animate-spin-reverse-slower {
animation: spin-reverse-slower 20s linear infinite;
}
.animate-pulse-glow {
animation: pulse-glow 3s ease-in-out infinite;
}
.animate-bounce-gentle {
animation: bounce-gentle 3s ease-in-out infinite;
}
.animate-twinkle {
animation: twinkle 4s ease-in-out infinite;
}
.fade-slide-enter-active,
.fade-slide-leave-active {
transition: all 0.5s ease;
}
.fade-slide-enter-from {
opacity: 0;
transform: translateY(10px);
}
.fade-slide-leave-to {
opacity: 0;
transform: translateY(-10px);
}
</style>

View File

@@ -84,4 +84,4 @@ function handleClick(event: MouseEvent) {
<component v-else-if="props.icon" :is="props.icon" class="h-5 w-5" aria-hidden="true" />
<slot />
</component>
</template>
</template>

View File

@@ -40,4 +40,4 @@ const baseClasses = computed(() => [
<div :class="[baseClasses, attrs.class]" v-bind="attrs">
<slot />
</div>
</template>
</template>

View File

@@ -68,4 +68,4 @@ const passthroughAttrs = computed(() => {
{{ props.error }}
</p>
</div>
</template>
</template>

View File

@@ -64,4 +64,4 @@ function handleChange(event: Event) {
</option>
</select>
</div>
</template>
</template>

View File

@@ -59,4 +59,4 @@ const passthroughAttrs = computed(() => {
</div>
</div>
</div>
</template>
</template>

View File

@@ -57,4 +57,4 @@ const headerClasses = computed(() => {
</div>
</div>
</Transition>
</template>
</template>

View File

@@ -42,4 +42,4 @@ function handleAction() {
{{ props.actionText }}
</BaseButton>
</div>
</template>
</template>

View File

@@ -33,4 +33,4 @@ const sizeClasses = computed(() => {
{{ props.text }}
</p>
</div>
</template>
</template>

View File

@@ -1,136 +1,136 @@
<script setup lang="ts">
import { XMarkIcon, CommandLineIcon } from '@heroicons/vue/24/outline'
defineProps<{
modelValue: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
}>()
function close() {
emit('update:modelValue', false)
}
function loginWithGithub() {
window.location.href = '/auth/github/signin'
}
function loginWithGoogle() {
window.location.href = '/auth/google/signin'
}
function loginWithDev() {
window.location.href = '/auth/dev/signin'
}
</script>
<template>
<Teleport to="body">
<Transition
enter-active-class="transition-opacity duration-300"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition-opacity duration-300"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-if="modelValue"
class="fixed inset-0 z-50 flex items-center justify-center p-4"
>
<!-- 遮罩层 -->
<div
class="absolute inset-0 bg-black/60 backdrop-blur-sm"
@click="close"
></div>
<!-- 对话框 -->
<div class="login-dialog glass rounded-3xl shadow-2xl p-8 w-full max-w-sm relative">
<!-- 关闭按钮 -->
<button
@click="close"
class="absolute top-4 right-4 p-2 rounded-full hover:bg-[var(--bg-elevated)] transition-colors"
>
<XMarkIcon class="h-5 w-5 text-[var(--text-muted)]" />
</button>
<!-- 标题 -->
<div class="text-center mb-8">
<div class="text-3xl mb-3"></div>
<h2 class="text-xl font-bold text-[var(--text)] mb-2">
登录开始创作
</h2>
<p class="text-sm text-[var(--text-muted)]">
选择你的登录方式
</p>
</div>
<!-- 登录按钮 -->
<div class="space-y-3">
<button
@click="loginWithDev"
class="login-btn w-full py-3.5 px-4 rounded-xl font-semibold flex items-center justify-center gap-3 transition-all duration-300 bg-gray-700/50 hover:bg-gray-700 text-white border-dashed border-gray-600"
>
<CommandLineIcon class="w-5 h-5" />
<span>开发模式一键登录</span>
</button>
<button
@click="loginWithGithub"
class="login-btn w-full py-3.5 px-4 rounded-xl font-semibold flex items-center justify-center gap-3 transition-all duration-300"
>
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
</svg>
<span>使用 GitHub 登录</span>
</button>
<button
@click="loginWithGoogle"
class="login-btn w-full py-3.5 px-4 rounded-xl font-semibold flex items-center justify-center gap-3 transition-all duration-300"
>
<svg class="w-5 h-5" viewBox="0 0 24 24">
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
</svg>
<span>使用 Google 登录</span>
</button>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.login-dialog {
--bg-deep: #0D0F1A;
--bg-card: #151829;
--bg-elevated: #1C2035;
--accent: #FFD369;
--text: #EAEAEA;
--text-muted: #6B7280;
--border: rgba(255,255,255,0.08);
}
.glass {
background: var(--bg-card);
border: 1px solid var(--border);
backdrop-filter: blur(10px);
}
.login-btn {
background: var(--bg-elevated);
border: 1px solid var(--border);
color: var(--text);
}
.login-btn:hover {
border-color: var(--accent);
background: rgba(255, 211, 105, 0.1);
}
</style>
<script setup lang="ts">
import { XMarkIcon, CommandLineIcon } from '@heroicons/vue/24/outline'
defineProps<{
modelValue: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
}>()
function close() {
emit('update:modelValue', false)
}
function loginWithGithub() {
window.location.href = '/auth/github/signin'
}
function loginWithGoogle() {
window.location.href = '/auth/google/signin'
}
function loginWithDev() {
window.location.href = '/auth/dev/signin'
}
</script>
<template>
<Teleport to="body">
<Transition
enter-active-class="transition-opacity duration-300"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition-opacity duration-300"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-if="modelValue"
class="fixed inset-0 z-50 flex items-center justify-center p-4"
>
<!-- 遮罩层 -->
<div
class="absolute inset-0 bg-black/60 backdrop-blur-sm"
@click="close"
></div>
<!-- 对话框 -->
<div class="login-dialog glass rounded-3xl shadow-2xl p-8 w-full max-w-sm relative">
<!-- 关闭按钮 -->
<button
@click="close"
class="absolute top-4 right-4 p-2 rounded-full hover:bg-[var(--bg-elevated)] transition-colors"
>
<XMarkIcon class="h-5 w-5 text-[var(--text-muted)]" />
</button>
<!-- 标题 -->
<div class="text-center mb-8">
<div class="text-3xl mb-3"></div>
<h2 class="text-xl font-bold text-[var(--text)] mb-2">
登录开始创作
</h2>
<p class="text-sm text-[var(--text-muted)]">
选择你的登录方式
</p>
</div>
<!-- 登录按钮 -->
<div class="space-y-3">
<button
@click="loginWithDev"
class="login-btn w-full py-3.5 px-4 rounded-xl font-semibold flex items-center justify-center gap-3 transition-all duration-300 bg-gray-700/50 hover:bg-gray-700 text-white border-dashed border-gray-600"
>
<CommandLineIcon class="w-5 h-5" />
<span>开发模式一键登录</span>
</button>
<button
@click="loginWithGithub"
class="login-btn w-full py-3.5 px-4 rounded-xl font-semibold flex items-center justify-center gap-3 transition-all duration-300"
>
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
</svg>
<span>使用 GitHub 登录</span>
</button>
<button
@click="loginWithGoogle"
class="login-btn w-full py-3.5 px-4 rounded-xl font-semibold flex items-center justify-center gap-3 transition-all duration-300"
>
<svg class="w-5 h-5" viewBox="0 0 24 24">
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
</svg>
<span>使用 Google 登录</span>
</button>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.login-dialog {
--bg-deep: #0D0F1A;
--bg-card: #151829;
--bg-elevated: #1C2035;
--accent: #FFD369;
--text: #EAEAEA;
--text-muted: #6B7280;
--border: rgba(255,255,255,0.08);
}
.glass {
background: var(--bg-card);
border: 1px solid var(--border);
backdrop-filter: blur(10px);
}
.login-btn {
background: var(--bg-elevated);
border: 1px solid var(--border);
color: var(--text);
}
.login-btn:hover {
border-color: var(--accent);
background: rgba(255, 211, 105, 0.1);
}
</style>

View File

@@ -5,4 +5,4 @@ export { default as BaseSelect } from './BaseSelect.vue'
export { default as BaseTextarea } from './BaseTextarea.vue'
export { default as LoadingSpinner } from './LoadingSpinner.vue'
export { default as EmptyState } from './EmptyState.vue'
export { default as ConfirmModal } from './ConfirmModal.vue'
export { default as ConfirmModal } from './ConfirmModal.vue'

View File

@@ -44,7 +44,7 @@ const router = createRouter({
component: () => import('./views/StoryDetail.vue'),
},
{
path: '/storybook/view',
path: '/storybook/view/:id?',
name: 'storybook-viewer',
component: () => import('./views/StorybookViewer.vue'),
},

View File

@@ -1,49 +1,49 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { api } from '../api/client'
interface User {
id: string
name: string
avatar_url: string | null
provider: string
}
export const useUserStore = defineStore('user', () => {
const user = ref<User | null>(null)
const loading = ref(false)
async function fetchSession() {
loading.value = true
try {
const data = await api.get<{ user: User | null }>('/auth/session')
user.value = data.user
} catch {
user.value = null
} finally {
loading.value = false
}
}
function loginWithGithub() {
window.location.href = '/auth/github/signin'
}
function loginWithGoogle() {
window.location.href = '/auth/google/signin'
}
async function logout() {
await api.post('/auth/signout')
user.value = null
}
return {
user,
loading,
fetchSession,
loginWithGithub,
loginWithGoogle,
logout,
}
})
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { api } from '../api/client'
interface User {
id: string
name: string
avatar_url: string | null
provider: string
}
export const useUserStore = defineStore('user', () => {
const user = ref<User | null>(null)
const loading = ref(false)
async function fetchSession() {
loading.value = true
try {
const data = await api.get<{ user: User | null }>('/auth/session')
user.value = data.user
} catch {
user.value = null
} finally {
loading.value = false
}
}
function loginWithGithub() {
window.location.href = '/auth/github/signin'
}
function loginWithGoogle() {
window.location.href = '/auth/google/signin'
}
async function logout() {
await api.post('/auth/signout')
user.value = null
}
return {
user,
loading,
fetchSession,
loginWithGithub,
loginWithGoogle,
logout,
}
})

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>

View File

@@ -171,4 +171,4 @@ onMounted(fetchProfiles)
</router-link>
</div>
</div>
</template>
</template>

View File

@@ -205,4 +205,4 @@ onMounted(fetchUniverse)
</BaseCard>
</div>
</div>
</template>
</template>

View File

@@ -1,7 +1,7 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}