510 lines
18 KiB
Vue
510 lines
18 KiB
Vue
<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 { 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()
|
|
|
|
// 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 canceling = ref(false)
|
|
const cancelRequested = ref(false)
|
|
const error = ref('')
|
|
const activeGenerationJobId = ref<string | null>(null)
|
|
|
|
// 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 })),
|
|
)
|
|
const requestedOutputMode = computed<'story' | 'storybook'>(() =>
|
|
inputType.value === 'full_story' ? 'story' : outputMode.value === 'storybook' ? 'storybook' : 'story',
|
|
)
|
|
const generationTitle = computed(() =>
|
|
requestedOutputMode.value === 'storybook' ? '绘本排版中...' : '故事编织中...',
|
|
)
|
|
const generationSteps = computed(() => {
|
|
if (requestedOutputMode.value === 'storybook') {
|
|
return [
|
|
'正在提交后台任务...',
|
|
'Worker 会生成绘本分镜和每页文字...',
|
|
'主记录一落库就能通过 ID 找回...',
|
|
'插图会继续在后台补全...',
|
|
'稍后自动进入可翻页阅读模式。',
|
|
]
|
|
}
|
|
|
|
return [
|
|
'正在提交后台任务...',
|
|
'Worker 会生成故事正文并保存主记录...',
|
|
'主内容一可读就会自动跳转详情页...',
|
|
'封面会继续在后台补全,失败也能重试...',
|
|
'马上进入故事详情页。',
|
|
]
|
|
})
|
|
|
|
interface GenerationAcceptedResponse {
|
|
id: number | null
|
|
generation_job_id: string | null
|
|
}
|
|
|
|
interface GenerationJobDetail {
|
|
story_id: number | null
|
|
status: string
|
|
current_step: string
|
|
is_terminal: boolean
|
|
error_message: string | null
|
|
}
|
|
|
|
interface GenerationJobActionResponse {
|
|
status: string
|
|
current_step: string
|
|
}
|
|
|
|
const JOB_POLL_INTERVAL_MS = 1500
|
|
const JOB_POLL_MAX_ATTEMPTS = 80
|
|
|
|
// Methods
|
|
function close() {
|
|
emit('update:modelValue', false)
|
|
error.value = ''
|
|
activeGenerationJobId.value = null
|
|
cancelRequested.value = false
|
|
canceling.value = false
|
|
}
|
|
|
|
function sleep(ms: number) {
|
|
return new Promise((resolve) => {
|
|
window.setTimeout(resolve, ms)
|
|
})
|
|
}
|
|
|
|
async function waitForStoryId(jobId: string) {
|
|
for (let attempt = 0; attempt < JOB_POLL_MAX_ATTEMPTS; attempt += 1) {
|
|
const detail = await api.get<GenerationJobDetail>(`/api/generations/jobs/${jobId}`)
|
|
if (detail.status === 'canceled' || detail.current_step === 'generation_canceled') {
|
|
return null
|
|
}
|
|
if (detail.story_id) {
|
|
return detail.story_id
|
|
}
|
|
if (detail.is_terminal) {
|
|
throw new Error(detail.error_message || '生成失败,请稍后重试')
|
|
}
|
|
await sleep(JOB_POLL_INTERVAL_MS)
|
|
}
|
|
|
|
throw new Error('任务已提交,但主内容落库超时,请稍后到故事库查看最新结果')
|
|
}
|
|
|
|
async function cancelGenerationJob() {
|
|
if (!activeGenerationJobId.value || canceling.value || cancelRequested.value) return
|
|
|
|
canceling.value = true
|
|
error.value = ''
|
|
try {
|
|
const result = await api.post<GenerationJobActionResponse>(
|
|
`/api/generations/jobs/${activeGenerationJobId.value}/cancel`,
|
|
)
|
|
cancelRequested.value = true
|
|
if (result.status === 'canceled' || result.current_step === 'generation_canceled') {
|
|
loading.value = false
|
|
close()
|
|
}
|
|
} catch (e) {
|
|
error.value = e instanceof Error ? e.message : '取消任务失败'
|
|
} finally {
|
|
canceling.value = false
|
|
}
|
|
}
|
|
|
|
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
|
|
cancelRequested.value = false
|
|
activeGenerationJobId.value = null
|
|
error.value = ''
|
|
|
|
try {
|
|
const payload: Record<string, unknown> = {
|
|
output_mode: requestedOutputMode.value,
|
|
type: inputType.value,
|
|
data: inputData.value,
|
|
education_theme: educationTheme.value || undefined,
|
|
generate_images: true,
|
|
page_count: 6,
|
|
}
|
|
if (selectedProfileId.value) payload.child_profile_id = selectedProfileId.value
|
|
if (selectedUniverseId.value) payload.universe_id = selectedUniverseId.value
|
|
|
|
const accepted = await api.post<GenerationAcceptedResponse>('/api/generations', payload)
|
|
const jobId = accepted.generation_job_id
|
|
if (!jobId) {
|
|
throw new Error('生成任务已创建,但缺少任务编号')
|
|
}
|
|
activeGenerationJobId.value = jobId
|
|
|
|
const storyId = accepted.id ?? await waitForStoryId(jobId)
|
|
if (storyId === null) {
|
|
close()
|
|
return
|
|
}
|
|
close()
|
|
if (requestedOutputMode.value === 'storybook') {
|
|
router.push(`/storybook/view/${storyId}`)
|
|
} else {
|
|
router.push(`/story/${storyId}`)
|
|
}
|
|
} catch (e) {
|
|
error.value = e instanceof Error ? e.message : '生成失败'
|
|
} finally {
|
|
loading.value = false
|
|
activeGenerationJobId.value = null
|
|
cancelRequested.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"
|
|
:title="generationTitle"
|
|
:steps="generationSteps"
|
|
/>
|
|
<div
|
|
v-if="loading && activeGenerationJobId"
|
|
class="fixed bottom-10 z-[110] flex flex-col items-center gap-3"
|
|
>
|
|
<BaseButton
|
|
variant="secondary"
|
|
:loading="canceling"
|
|
:disabled="cancelRequested"
|
|
@click="cancelGenerationJob"
|
|
>
|
|
{{ cancelRequested ? '正在取消任务...' : '取消任务' }}
|
|
</BaseButton>
|
|
<p class="text-sm text-white/70">
|
|
{{ cancelRequested ? '已提交取消请求,会在安全检查点停止任务。' : '如果是误触发起,可以现在取消后台任务。' }}
|
|
</p>
|
|
</div>
|
|
|
|
<!-- 模态框内容 -->
|
|
<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>
|
|
|
|
<!-- 提交按钮 -->
|
|
<div class="mb-4 rounded-lg border border-amber-400/20 bg-amber-300/10 px-4 py-3 text-sm text-amber-100 leading-6">
|
|
<div class="font-semibold mb-1">
|
|
{{ requestedOutputMode === 'storybook' ? '绘本会先保存,再补全插图' : '故事会先可读,再补全封面' }}
|
|
</div>
|
|
<p>
|
|
{{ requestedOutputMode === 'storybook'
|
|
? '即使部分插图暂时失败,绘本文字也会保留在故事库,稍后可以继续补全。'
|
|
: '封面或语音失败不会影响正文阅读,结果页会给出状态和重试入口。' }}
|
|
</p>
|
|
</div>
|
|
|
|
<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>
|