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,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>