Files
happy-life-star/mini-program/src/pages/main/ScriptView.vue
T
peanut 64476eee6d feat: AI 打字机流式输出、小程序脚本主页布局及灵感卡片优化
- life-script: 新增 aiRuntime 打字机流式服务,PathView/ScriptView/TimelineView 接入打字机效果
- mini-program: ScriptView 重构为打字机输出 + 卡片式灵感列表,主页布局优化
- web: aiRuntime 服务新增流式输出支持
- chat store: 消息状态管理和打字机流式渲染支持

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 18:35:33 +08:00

1691 lines
43 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view class="script-view">
<view class="cosmic-background">
<view class="cosmic-stars layer-one"></view>
<view class="cosmic-stars layer-two"></view>
<view class="cosmic-planet planet-main"></view>
<view class="cosmic-planet planet-soft"></view>
<view class="meteor meteor-one"></view>
<view class="meteor meteor-two"></view>
<view class="meteor meteor-three"></view>
</view>
<view v-if="viewState === 'home'" class="wish-home">
<view class="home-head">
<view class="history-button" @click="openScriptLibrary">
<view class="history-lines">
<view></view>
<view></view>
<view></view>
</view>
<text>历史</text>
</view>
<view class="head-action-row">
<view class="social-import-btn" @click="openSocialImport">
<text>导入社交数据</text>
</view>
<view class="script-list-btn" @click="openScriptLibrary">
<text>我的剧本</text>
</view>
</view>
</view>
<view class="hero-copy">
<text class="hero-title">今天有什么<text class="hero-highlight">心愿</text>想实现</text>
</view>
<view class="inspiration-section">
<view class="section-line">
<text class="section-title">灵感一下</text>
<text class="refresh" @click="shuffleInspirations">换一换</text>
</view>
<view class="recommend-grid">
<view
v-for="item in recommendations"
:key="item.text"
class="recommend-card"
@click="useRecommendation(item.text)"
>
<text class="recommend-text">{{ item.text }}</text>
</view>
</view>
</view>
<view class="wish-input-wrap">
<input
class="wish-input"
v-model="wishText"
confirm-type="send"
placeholder="写下你的心愿,AI帮你重写人生"
placeholder-class="placeholder"
@confirm="submitWish('text')"
/>
<view class="send-button" :class="{ disabled: !wishText.trim() }" @click="submitWish('text')">发送</view>
</view>
<view class="profile-boost">
<view class="boost-main" @click="openSocialInsights">
<text class="boost-title">人生素材画像</text>
<text class="boost-copy">{{ socialInsightCopy }}</text>
</view>
<switch
class="boost-switch"
:checked="useSocialInsights"
color="#a855f7"
@change="useSocialInsights = $event.detail.value"
/>
</view>
<view class="orb-wrap">
<view
class="mic-orb"
:class="{ pressing: voiceState === 'pressing', recognizing: voiceState === 'recognizing' }"
@touchstart.prevent="startVoicePress"
@touchend.prevent="endVoicePress"
@touchcancel.prevent="cancelVoicePress"
>
<view class="mic-symbol">
<view class="mic-head"></view>
<view class="mic-stem"></view>
<view class="mic-base"></view>
</view>
</view>
</view>
<text class="voice-copy">{{ voiceCopy }}</text>
</view>
<view v-else-if="viewState === 'generating'" class="generation-view">
<scroll-view
class="generation-scroll"
scroll-y
:scroll-into-view="generationScrollTarget"
:scroll-with-animation="true"
:enhanced="true"
:show-scrollbar="false"
@touchstart="pauseGenerationAutoScroll"
@scrolltolower="resumeGenerationAutoScroll"
>
<view class="generation-scroll-content">
<view class="conversation">
<view class="chat-bubble user">
<text>{{ wishText }}</text>
<text class="bubble-time">{{ currentMessageTime }}</text>
</view>
<view class="chat-bubble system">
<text>{{ generationTitle }}</text>
<view v-if="showThinkingDots" class="thinking-dots">
<view></view>
<view></view>
<view></view>
</view>
<text v-if="generationStatus === 'failed'" class="generation-error">{{ generationFailureCopy }}</text>
<text v-else-if="visibleStreamContent" class="stream-preview typing">{{ visibleStreamContent }}<text v-if="generating" class="typing-cursor">|</text></text>
<text class="bubble-time">{{ currentMessageTime }}</text>
</view>
</view>
<view class="generation-loading">
<view class="loading-orbit" :class="{ streaming: generationStatus === 'streaming', failed: generationStatus === 'failed' }">
<view class="orbit-ring outer"></view>
<view class="orbit-ring inner"></view>
<view class="orbit-core"></view>
</view>
<text class="loading-copy">{{ generationCopy }}</text>
<text v-if="generationStatus !== 'failed'" class="loading-subcopy">{{ generationSubcopy }}</text>
<view v-else class="generation-actions">
<button class="generation-action primary" @click="retryGeneration">再试一次</button>
<button class="generation-action secondary" @click="returnToEdit">返回修改</button>
</view>
</view>
<view :id="generationScrollAnchor" class="generation-scroll-anchor"></view>
</view>
</scroll-view>
</view>
<scroll-view
v-else
class="result-view"
scroll-y
:enhanced="true"
:show-scrollbar="false"
>
<view class="conversation compact">
<view class="chat-bubble user">
<text>{{ wishText }}</text>
<text class="bubble-time">{{ currentMessageTime }}</text>
</view>
<view class="chat-bubble system done">
<text>心愿已实现故事已为你展开</text>
<text class="bubble-time">{{ currentResultTime }}</text>
</view>
</view>
<view class="story-card">
<view class="story-head">
<view class="story-title-wrap">
<text class="story-title">{{ currentResult?.title || '我的人生剧本' }}</text>
<view class="tag-row">
<text v-for="tag in resultTags" :key="tag" class="tag">{{ tag }}</text>
</view>
</view>
<button class="close-icon" @click="closeResult">×</button>
</view>
<text class="story-body">{{ resultContent }}</text>
<view class="audio-section" @click="trackTtsClick">
<text class="audio-icon"></text>
<text class="audio-unavailable">{{ ttsButtonText }}</text>
</view>
<view class="result-actions">
<button class="action-btn" @click="changeDirection">换个方向</button>
<button class="action-btn" @click="notLikeMe">不像我</button>
<button class="action-btn primary" @click="trackTtsClick">{{ ttsButtonText }}</button>
</view>
</view>
</scroll-view>
</view>
</template>
<script setup>
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { useAppStore } from '../../stores/app.js'
import analytics from '../../services/analytics.js'
import * as socialImport from '../../services/socialImport.js'
import * as epicScriptService from '../../services/epicScript.js'
import { useTtsPlayer } from '../../composables/useTtsPlayer.js'
import { useTypewriterStream } from '../../composables/useTypewriterStream.js'
import { transcribeAudio } from '../../services/asr.js'
import { streamAiScene } from '../../services/aiRuntime.js'
const store = useAppStore()
const pagePath = '/pages/main/ScriptView'
const viewState = ref('home')
const wishText = ref('')
const voiceState = ref('idle')
const generationStartedAt = ref(0)
const currentResult = ref(null)
const currentMessageTime = ref('')
const currentResultTime = ref('')
const streamContent = ref('')
const generating = ref(false)
const streamWriter = useTypewriterStream({ interval: 24, step: 1 })
const generationStatus = ref('idle')
const generationError = ref('')
const generationHintIndex = ref(0)
const generationScrollAnchor = ref('generation-stream-anchor-a')
const generationScrollTarget = ref('')
const generationAutoFollow = ref(true)
const lastSubmitSource = ref('text')
const remainingCount = ref(3)
const style = ref('career')
const randomRecommendations = ref([])
const useSocialInsights = ref(true)
const confirmedInsights = ref([])
const ttsPlayer = useTtsPlayer({ pagePath })
let recorderManager = null
let recordStartedAt = 0
let recordCancelled = false
let generationHintTimer = null
let generationSlowTimer = null
let generationVerySlowTimer = null
let generationScrollTimer = null
const generationHints = [
'正在从你的心愿里寻找故事的起点',
'正在整理人生素材和情绪线索',
'正在安排一次更爽的命运转弯',
'正在让故事慢慢靠近你'
]
const fallbackRecommendations = [
{ text: '如果老板今天突然夸我,我的人生会怎样展开?', tag: '职场逆袭' },
{ text: '我不再内耗,专注搞钱,逆袭成行业顶尖', tag: '成长' },
{ text: '重生回18岁,这次我要活成自己喜欢的样子', tag: '重生' },
{ text: '我终于被所有人看见,也被自己认可', tag: '被认可' }
]
const recommendations = computed(() => {
const source = randomRecommendations.value.length ? randomRecommendations.value : (store.inspirationRecommendations || [])
return source.length ? source.slice(0, 4) : fallbackRecommendations
})
const socialInsightCopy = computed(() => {
if (!confirmedInsights.value.length) return '未确认画像,点这里导入社交内容'
return `已确认 ${confirmedInsights.value.length} 个,将辅助生成更像你的剧本`
})
const voiceCopy = computed(() => {
if (voiceState.value === 'pressing') return '松开后识别心愿'
if (voiceState.value === 'recognizing') return '正在识别你的心愿……'
if (voiceState.value === 'error') return '语音暂不可用,可以先输入文字'
return '按住说话,即刻如愿'
})
const resultTags = computed(() => {
const tags = currentResult.value?.tags
if (Array.isArray(tags) && tags.length) return tags.slice(0, 3)
const styleText = currentResult.value?.style || '爽文'
return [styleText, '成长', '被看见']
})
const resultContent = computed(() => {
return currentResult.value?.content || currentResult.value?.summary || '故事正在生成,请稍后查看。'
})
const visibleStreamContent = computed(() => streamWriter.visibleText.value)
const scrollGenerationToLatest = () => {
if (viewState.value !== 'generating') return
if (!generationAutoFollow.value) return
if (generationScrollTimer) clearTimeout(generationScrollTimer)
generationScrollTimer = setTimeout(() => {
nextTick(() => {
generationScrollAnchor.value = generationScrollAnchor.value === 'generation-stream-anchor-a'
? 'generation-stream-anchor-b'
: 'generation-stream-anchor-a'
generationScrollTarget.value = generationScrollAnchor.value
})
}, 80)
}
const pauseGenerationAutoScroll = () => {
generationAutoFollow.value = false
}
const resumeGenerationAutoScroll = () => {
generationAutoFollow.value = true
scrollGenerationToLatest()
}
watch(visibleStreamContent, (text) => {
if (!text) return
scrollGenerationToLatest()
})
watch(generationStatus, () => {
scrollGenerationToLatest()
})
const generationTitle = computed(() => {
if (generationStatus.value === 'failed') return '心愿暂时没有抵达'
if (visibleStreamContent.value) return '故事正在展开'
return '心愿实现中……'
})
const showThinkingDots = computed(() => {
return generationStatus.value !== 'failed' && !visibleStreamContent.value
})
const generationCopy = computed(() => {
if (generationStatus.value === 'failed') return '这次生成没有顺利完成'
if (generationStatus.value === 'verySlow') return '这次需要久一点,仍在努力连接灵感'
if (generationStatus.value === 'slow') return '还在整理,请再给我一点时间'
if (streamWriter.isWaiting.value) return '正在理解你的心愿,整理人生素材'
if (streamWriter.isDraining.value) return '故事马上完成,正在收束最后一句'
if (streamWriter.isStreaming.value) return '正在把你的心愿写成故事'
return '正在把你的心愿写成故事'
})
const generationSubcopy = computed(() => {
if (generationStatus.value === 'verySlow') return '如果网络波动,稍后会给你温和提示,不会丢掉当前心愿。'
if (generationStatus.value === 'slow') return 'AI 还没有吐出第一句话,但请求仍在进行中。'
if (streamWriter.isStreaming.value || streamWriter.isDraining.value) return '已经收到内容,正在逐字展示给你。'
return generationHints[generationHintIndex.value]
})
const generationFailureCopy = computed(() => {
return generationError.value || '可能是网络慢了,或 AI 服务暂时没有回应。你可以直接再试一次,也可以返回修改心愿。'
})
const ttsButtonText = computed(() => {
if (!currentResult.value?.id) return '生成保存后可语音播放'
return ttsPlayer.buttonText.value
})
const formatMessageTime = () => {
const date = new Date()
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
return `${hours}:${minutes}`
}
const clearGenerationFeedbackTimers = () => {
if (generationHintTimer) {
clearInterval(generationHintTimer)
generationHintTimer = null
}
if (generationSlowTimer) {
clearTimeout(generationSlowTimer)
generationSlowTimer = null
}
if (generationVerySlowTimer) {
clearTimeout(generationVerySlowTimer)
generationVerySlowTimer = null
}
}
const startGenerationFeedback = () => {
clearGenerationFeedbackTimers()
generationStatus.value = 'waiting'
generationError.value = ''
generationHintIndex.value = 0
generationHintTimer = setInterval(() => {
if (generationStatus.value === 'failed' || visibleStreamContent.value) return
generationHintIndex.value = (generationHintIndex.value + 1) % generationHints.length
}, 3600)
generationSlowTimer = setTimeout(() => {
if (generating.value && !streamContent.value && generationStatus.value !== 'failed') {
generationStatus.value = 'slow'
}
}, 8000)
generationVerySlowTimer = setTimeout(() => {
if (generating.value && !streamContent.value && generationStatus.value !== 'failed') {
generationStatus.value = 'verySlow'
}
}, 20000)
}
const markGenerationStreaming = () => {
if (generationStatus.value === 'failed') return
generationStatus.value = 'streaming'
}
const markGenerationFailed = (message) => {
clearGenerationFeedbackTimers()
generationStatus.value = 'failed'
generationError.value = message || '可能是网络慢了,或 AI 服务暂时没有回应。你可以直接再试一次,也可以返回修改心愿。'
streamWriter.fail(generationError.value)
}
const normalizeGeneratedScript = (data) => {
const script = data?.script || data || {}
const latestScript = Array.isArray(store.scripts) && store.scripts.length ? store.scripts[0] : null
const merged = script?.id ? script : { ...latestScript, ...script }
const content = merged?.content || merged?.plotJson?.fullContent || merged?.summary || ''
return {
id: merged?.id || '',
title: merged?.title || wishText.value || '我的人生剧本',
theme: merged?.theme || wishText.value,
style: merged?.style || '爽文',
length: merged?.length || 'medium',
tags: merged?.tags || [merged?.style || '爽文', '成长', '被看见'],
summary: merged?.summary || content.slice(0, 90),
content
}
}
const openScriptLibrary = () => {
analytics.track('script_my_scripts_click', {}, { eventType: 'script', pagePath })
uni.$emit('switchTab', 'mine')
}
const openSocialInsights = () => {
analytics.track('script_social_insights_click', {
confirmed_count: confirmedInsights.value.length
}, { eventType: 'social_import', pagePath })
uni.navigateTo({
url: confirmedInsights.value.length ? '/pages/social-import/insights' : '/pages/social-import/index'
})
}
const openSocialImport = () => {
analytics.track('script_social_import_entry_click', {
source: 'home_head'
}, { eventType: 'social_import', pagePath })
uni.navigateTo({ url: '/pages/social-import/index' })
}
const useRecommendation = (text) => {
analytics.track('script_inspiration_select', {
source: 'recommendation',
prompt_length: text.length
}, { eventType: 'script', pagePath })
wishText.value = text
}
const shuffleInspirations = async () => {
analytics.track('script_inspiration_refresh', {
source: 'home'
}, { eventType: 'script', pagePath })
const list = await store.fetchRandomInspirations(4)
randomRecommendations.value = list.length ? list : fallbackRecommendations
}
const startVoicePress = () => {
if (generating.value) return
if (!recorderManager) {
voiceState.value = 'error'
analytics.track('script_voice_recognize_fail', {
reason: 'recorder_unavailable'
}, { eventType: 'script', pagePath })
uni.showToast({ title: '当前环境不支持录音', icon: 'none' })
setTimeout(() => {
if (voiceState.value === 'error') voiceState.value = 'idle'
}, 1800)
return
}
recordCancelled = false
recordStartedAt = Date.now()
voiceState.value = 'pressing'
analytics.track('script_voice_press_start', {}, { eventType: 'script', pagePath })
recorderManager.start({
duration: 60000,
sampleRate: 16000,
numberOfChannels: 1,
encodeBitRate: 48000,
format: 'mp3'
})
}
const cancelVoicePress = () => {
recordCancelled = true
if (recorderManager && voiceState.value === 'pressing') {
recorderManager.stop()
} else {
voiceState.value = 'idle'
}
analytics.track('script_voice_record_cancel', {}, { eventType: 'script', pagePath })
}
const endVoicePress = async () => {
if (voiceState.value !== 'pressing') return
analytics.track('script_voice_press_end', {}, { eventType: 'script', pagePath })
voiceState.value = 'recognizing'
recorderManager?.stop()
}
const handleVoiceRecognizeSuccess = (text, durationMs) => {
wishText.value = text
voiceState.value = 'idle'
analytics.track('script_voice_recognize_success', {
text_length: text.length,
duration_ms: durationMs || 0
}, { eventType: 'script', pagePath })
uni.showToast({ title: '识别成功,可修改后发送', icon: 'none' })
}
const handleVoiceRecognizeFail = (reason, message = '语音识别失败,请重试') => {
voiceState.value = 'error'
analytics.track('script_voice_recognize_fail', {
reason
}, { eventType: 'script', pagePath })
uni.showToast({ title: message, icon: 'none' })
setTimeout(() => {
if (voiceState.value === 'error') voiceState.value = 'idle'
}, 1800)
}
const setupRecorder = () => {
if (!uni.getRecorderManager) return
recorderManager = uni.getRecorderManager()
recorderManager.onStop(async (res) => {
if (recordCancelled) {
voiceState.value = 'idle'
return
}
const duration = Date.now() - recordStartedAt
if (!res?.tempFilePath || duration < 500) {
handleVoiceRecognizeFail('record_too_short', '说话时间太短,请重试')
return
}
voiceState.value = 'recognizing'
try {
const response = await transcribeAudio(res.tempFilePath)
const text = response?.data?.text?.trim()
if (!text) {
handleVoiceRecognizeFail('empty_result', '没有识别到内容,请重试')
return
}
handleVoiceRecognizeSuccess(text, response?.data?.durationMs)
} catch (error) {
handleVoiceRecognizeFail(error?.message || error?.errMsg || 'upload_failed')
}
})
recorderManager.onError((error) => {
handleVoiceRecognizeFail(error?.errMsg || 'recorder_error', '录音失败,请检查麦克风权限')
})
}
const generateScriptByStream = async (text) => {
const profile = store.userProfile || store.registrationData || {}
const characterInfo = epicScriptService.buildCharacterInfo(profile)
const lifeEventsSummary = epicScriptService.buildLifeEventsSummary(store.events || [], profile)
const streamRes = await streamAiScene({
sceneCode: 'script_generate',
inputs: {
prompt: text,
style: style.value,
length: 'medium',
useSocialInsights: useSocialInsights.value
},
onDelta: (_delta, output) => {
streamContent.value = output
markGenerationStreaming()
streamWriter.push(output)
},
onDone: (_event, output) => {
streamWriter.finish(output)
},
onError: (message) => {
streamWriter.fail(message || 'AI 流式生成失败')
}
})
const content = streamRes.output?.trim()
if (!content) {
throw new Error('AI 流式输出为空')
}
streamWriter.finish(content)
await streamWriter.waitForDone()
const saveRes = await store.createScript({
title: text.length > 22 ? `${text.slice(0, 22)}...` : text,
theme: text,
style: style.value,
length: 'medium',
content,
plotJson: {
mode: 'inspiration',
prompt: text,
source: 'mini-program-stream',
fullContent: content
},
characterInfo,
lifeEventsSummary,
useSocialInsights: useSocialInsights.value
})
if (!saveRes.success) {
throw new Error(saveRes.error || '保存剧本失败')
}
return {
success: true,
data: {
script: saveRes.data,
remainingCount: remainingCount.value
}
}
}
const submitWish = async (source = 'text') => {
const text = wishText.value.trim()
if (!text || generating.value) return
lastSubmitSource.value = source
analytics.track('script_wish_submit', {
source,
prompt_length: text.length
}, { eventType: 'script', pagePath })
currentMessageTime.value = formatMessageTime()
generationStartedAt.value = Date.now()
generating.value = true
streamContent.value = ''
generationScrollTarget.value = ''
generationScrollAnchor.value = 'generation-stream-anchor-a'
generationAutoFollow.value = true
streamWriter.reset()
startGenerationFeedback()
ttsPlayer.reset()
viewState.value = 'generating'
analytics.track('script_generation_progress_view', {
source,
prompt_length: text.length
}, { eventType: 'script', pagePath })
let res
try {
res = await generateScriptByStream(text)
} catch (streamError) {
markGenerationFailed(streamError?.message || 'AI 流式生成失败')
analytics.track('script_generate_stream_fail', {
source,
error: streamError?.message || 'stream_failed'
}, { eventType: 'script', pagePath })
res = {
success: false,
error: streamError?.message || 'AI 流式生成失败'
}
}
generating.value = false
if (res.success) {
clearGenerationFeedbackTimers()
if (streamContent.value) streamWriter.finish(streamContent.value)
}
if (!res.success) {
analytics.track('script_generate_fail', {
source,
error: res.error || 'unknown',
duration_ms: Date.now() - generationStartedAt.value
}, { eventType: 'script', pagePath })
markGenerationFailed(res.error || '生成失败')
return
}
currentResult.value = normalizeGeneratedScript(res.data)
currentResultTime.value = formatMessageTime()
if (typeof res.data?.remainingCount === 'number') remainingCount.value = res.data.remainingCount
analytics.track('script_generate_success', {
source,
style: currentResult.value.style || '',
length: currentResult.value.length || '',
use_social_insights: useSocialInsights.value,
duration_ms: Date.now() - generationStartedAt.value
}, { eventType: 'script', pagePath })
analytics.track('script_result_view', {
script_id: currentResult.value.id || '',
style: currentResult.value.style || '',
length: currentResult.value.length || ''
}, { eventType: 'script', pagePath })
generationStatus.value = 'idle'
viewState.value = 'result'
}
const retryGeneration = () => {
if (generating.value) return
analytics.track('script_generation_retry_click', {
prompt_length: wishText.value.trim().length
}, { eventType: 'script', pagePath })
generationStatus.value = 'idle'
generationError.value = ''
submitWish(lastSubmitSource.value || 'text')
}
const returnToEdit = () => {
clearGenerationFeedbackTimers()
generating.value = false
generationStatus.value = 'idle'
generationError.value = ''
streamContent.value = ''
streamWriter.reset()
analytics.track('script_generation_back_edit_click', {
prompt_length: wishText.value.trim().length
}, { eventType: 'script', pagePath })
viewState.value = 'home'
}
const closeResult = () => {
clearGenerationFeedbackTimers()
generationStatus.value = 'idle'
viewState.value = 'home'
currentResult.value = null
ttsPlayer.reset()
}
const changeDirection = () => {
analytics.track('script_result_change_direction_click', {
script_id: currentResult.value?.id || ''
}, { eventType: 'script', pagePath })
wishText.value = `${wishText.value},换一个方向重新展开`
currentResult.value = null
ttsPlayer.reset()
viewState.value = 'home'
}
const notLikeMe = () => {
analytics.track('script_result_not_like_me_click', {
script_id: currentResult.value?.id || ''
}, { eventType: 'script', pagePath })
uni.showToast({ title: '已记录反馈,可以调整心愿后再试', icon: 'none' })
}
const trackTtsClick = () => {
analytics.track('script_result_tts_click', {
script_id: currentResult.value?.id || ''
}, { eventType: 'tts', pagePath })
ttsPlayer.playSource(currentResult.value?.id || '')
}
const loadConfirmedInsights = async () => {
try {
const res = await socialImport.listInsights('confirmed')
confirmedInsights.value = res.data || []
} catch (error) {
confirmedInsights.value = []
}
}
onMounted(() => {
analytics.track('script_home_view', {}, { eventType: 'script', pagePath })
setupRecorder()
loadConfirmedInsights()
})
onUnmounted(() => {
clearGenerationFeedbackTimers()
if (generationScrollTimer) {
clearTimeout(generationScrollTimer)
generationScrollTimer = null
}
streamWriter.dispose()
if (recorderManager && voiceState.value === 'pressing') {
recordCancelled = true
recorderManager.stop()
}
})
</script>
<style scoped>
.script-view {
position: relative;
height: 100%;
min-height: 100%;
overflow: hidden;
color: #fff;
font-family: "PingFang SC", "Noto Sans SC", sans-serif;
}
.cosmic-background {
position: absolute;
inset: -80rpx -80rpx;
z-index: 0;
pointer-events: none;
overflow: hidden;
}
.cosmic-background::after {
content: '';
position: absolute;
inset: 0;
background:
radial-gradient(circle at 50% 22%, rgba(255, 216, 107, 0.06), transparent 30%),
radial-gradient(circle at 72% 6%, rgba(209, 138, 255, 0.11), transparent 24%),
linear-gradient(180deg, rgba(12, 4, 31, 0.1), rgba(5, 2, 13, 0.2));
}
.cosmic-stars {
position: absolute;
inset: 0;
opacity: 0.48;
}
.cosmic-stars.layer-one {
background:
radial-gradient(circle at 12% 18%, rgba(255, 255, 255, 0.52) 0 2rpx, transparent 3rpx),
radial-gradient(circle at 32% 9%, rgba(255, 216, 107, 0.42) 0 2rpx, transparent 3rpx),
radial-gradient(circle at 74% 32%, rgba(255, 255, 255, 0.36) 0 2rpx, transparent 3rpx),
radial-gradient(circle at 88% 48%, rgba(209, 138, 255, 0.42) 0 2rpx, transparent 3rpx),
radial-gradient(circle at 24% 72%, rgba(255, 255, 255, 0.32) 0 2rpx, transparent 3rpx);
animation: starFloat 8s ease-in-out infinite;
}
.cosmic-stars.layer-two {
opacity: 0.32;
background:
radial-gradient(circle at 18% 42%, rgba(255, 255, 255, 0.5) 0 1rpx, transparent 2rpx),
radial-gradient(circle at 46% 28%, rgba(209, 138, 255, 0.5) 0 1rpx, transparent 2rpx),
radial-gradient(circle at 64% 70%, rgba(255, 216, 107, 0.5) 0 1rpx, transparent 2rpx),
radial-gradient(circle at 92% 18%, rgba(255, 255, 255, 0.42) 0 1rpx, transparent 2rpx);
animation: starFloat 11s ease-in-out infinite reverse;
}
.cosmic-planet {
position: absolute;
border-radius: 50%;
pointer-events: none;
}
.planet-main {
top: 118rpx;
right: -52rpx;
width: 188rpx;
height: 188rpx;
opacity: 0.38;
background:
radial-gradient(circle at 36% 28%, rgba(255, 231, 163, 0.96), rgba(209, 138, 255, 0.7) 36%, rgba(93, 38, 193, 0.78) 68%, rgba(23, 9, 56, 0.9));
box-shadow: 0 0 76rpx rgba(168, 85, 247, 0.32);
animation: planetDrift 9s ease-in-out infinite;
}
.planet-main::after {
content: '';
position: absolute;
left: -24rpx;
top: 78rpx;
width: 238rpx;
height: 34rpx;
border-radius: 50%;
border: 3rpx solid rgba(255, 216, 107, 0.18);
transform: rotate(-16deg);
}
.planet-soft {
left: -64rpx;
bottom: 180rpx;
width: 128rpx;
height: 128rpx;
opacity: 0.2;
background: radial-gradient(circle at 50% 42%, rgba(140, 68, 242, 0.78), rgba(30, 8, 76, 0.92));
box-shadow: 0 0 64rpx rgba(140, 68, 242, 0.28);
animation: planetDrift 12s ease-in-out infinite reverse;
}
.meteor {
position: absolute;
width: 132rpx;
height: 3rpx;
border-radius: 999rpx;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.9), rgba(255, 216, 107, 0.18), transparent);
opacity: 0;
transform: rotate(-22deg);
animation: meteorFall 5.6s linear infinite;
}
.meteor-one {
top: 142rpx;
left: -160rpx;
}
.meteor-two {
top: 318rpx;
left: 120rpx;
width: 104rpx;
animation-delay: 1.8s;
}
.meteor-three {
top: 520rpx;
right: -120rpx;
width: 88rpx;
animation-delay: 3.4s;
}
@keyframes starFloat {
0%, 100% {
transform: translateY(0);
opacity: 0.36;
}
50% {
transform: translateY(18rpx);
opacity: 0.62;
}
}
@keyframes planetDrift {
0%, 100% {
transform: translate3d(0, 0, 0) scale(1);
}
50% {
transform: translate3d(-14rpx, 18rpx, 0) scale(1.03);
}
}
@keyframes meteorFall {
0% {
opacity: 0;
transform: translate3d(0, 0, 0) rotate(-22deg);
}
9% {
opacity: 0.62;
}
34% {
opacity: 0;
transform: translate3d(520rpx, 210rpx, 0) rotate(-22deg);
}
100% {
opacity: 0;
transform: translate3d(520rpx, 210rpx, 0) rotate(-22deg);
}
}
.wish-home,
.generation-view,
.result-view {
position: relative;
z-index: 1;
min-height: 100%;
display: flex;
flex-direction: column;
box-sizing: border-box;
padding: 4rpx 0 24rpx;
}
.wish-home {
gap: 32rpx;
}
.result-view {
display: block;
height: 100%;
min-height: 0;
padding-bottom: 190rpx;
}
.home-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14rpx;
}
.history-button,
.script-list-btn {
height: 64rpx;
display: flex;
align-items: center;
gap: 12rpx;
padding: 0 22rpx;
border-radius: 999rpx;
color: #e8ccff;
font-size: 28rpx;
background: rgba(43, 19, 83, 0.72);
border: 1rpx solid rgba(168, 85, 247, 0.28);
box-shadow: 0 0 22rpx rgba(116, 52, 202, 0.12);
}
.head-action-row {
display: flex;
align-items: center;
gap: 12rpx;
min-width: 0;
}
.social-import-btn {
height: 64rpx;
display: flex;
align-items: center;
justify-content: center;
padding: 0 22rpx;
border-radius: 999rpx;
color: #fff;
font-size: 25rpx;
font-weight: 800;
white-space: nowrap;
background:
radial-gradient(circle at 80% 10%, rgba(255, 255, 255, 0.26), transparent 26%),
linear-gradient(135deg, #b045ff, #612eff);
box-shadow: 0 14rpx 34rpx rgba(129, 66, 255, 0.34);
}
.history-lines {
width: 28rpx;
display: flex;
flex-direction: column;
gap: 5rpx;
}
.history-lines view {
height: 3rpx;
border-radius: 999rpx;
background: currentColor;
}
.hero-copy {
margin-top: 12rpx;
margin-bottom: 14rpx;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
white-space: nowrap;
overflow: visible;
}
.hero-title {
display: inline-flex;
align-items: baseline;
flex-wrap: nowrap;
white-space: nowrap;
font-size: 56rpx;
font-weight: 800;
line-height: 1.18;
letter-spacing: 0;
}
.hero-highlight {
margin: 0 4rpx;
color: #d18aff;
text-shadow: 0 0 28rpx rgba(209, 138, 255, 0.52);
}
.orb-wrap {
position: relative;
height: 330rpx;
display: flex;
align-items: center;
justify-content: center;
}
.orb-wrap::before {
content: '';
position: absolute;
width: 420rpx;
height: 420rpx;
border-radius: 50%;
background: radial-gradient(circle, rgba(116, 41, 210, 0.42), transparent 64%);
filter: blur(6rpx);
}
.mic-orb {
position: relative;
width: 260rpx;
height: 260rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(145deg, #f1a0ff 0%, #934dff 48%, #4d1ccb 100%);
box-shadow:
0 0 72rpx rgba(169, 85, 247, 0.75),
0 0 180rpx rgba(102, 41, 201, 0.55);
transition: transform 0.18s ease, box-shadow 0.18s ease;
}
.mic-orb.pressing {
transform: scale(1.06);
box-shadow:
0 0 86rpx rgba(241, 160, 255, 0.82),
0 0 220rpx rgba(102, 41, 201, 0.68);
}
.mic-orb.recognizing {
opacity: 0.86;
}
.mic-symbol {
position: relative;
width: 88rpx;
height: 118rpx;
}
.mic-head {
width: 58rpx;
height: 78rpx;
margin: 0 auto;
border-radius: 30rpx;
border: 8rpx solid rgba(255, 255, 255, 0.92);
box-sizing: border-box;
}
.mic-stem {
width: 8rpx;
height: 34rpx;
margin: -2rpx auto 0;
border-radius: 999rpx;
background: rgba(255, 255, 255, 0.92);
}
.mic-base {
width: 58rpx;
height: 8rpx;
margin: 0 auto;
border-radius: 999rpx;
background: rgba(255, 255, 255, 0.92);
}
.voice-copy {
text-align: center;
font-size: 36rpx;
font-weight: 500;
line-height: 56rpx;
color: rgba(255, 255, 255, 0.92);
}
.wish-input-wrap {
min-height: 96rpx;
display: flex;
align-items: center;
gap: 14rpx;
padding: 12rpx 14rpx 12rpx 28rpx;
border-radius: 52rpx;
background: linear-gradient(180deg, rgba(43, 19, 83, 0.72), rgba(32, 14, 61, 0.66));
border: 1rpx solid rgba(168, 85, 247, 0.42);
box-shadow: 0 0 22rpx rgba(116, 52, 202, 0.12);
}
.wish-input {
flex: 1;
height: 72rpx;
color: #fff;
font-size: 34rpx;
}
.placeholder {
color: rgba(216, 180, 254, 0.48);
}
.send-button {
min-width: 104rpx;
height: 72rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 999rpx;
color: #fff;
font-size: 28rpx;
font-weight: 700;
background: linear-gradient(145deg, #934dff, #4d1ccb);
}
.send-button.disabled {
opacity: 0.45;
}
.profile-boost {
min-height: 88rpx;
display: flex;
align-items: center;
gap: 18rpx;
padding: 18rpx 22rpx;
border-radius: 28rpx;
border: 1rpx solid rgba(168, 85, 247, 0.26);
background: rgba(43, 19, 83, 0.44);
}
.boost-main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 6rpx;
}
.boost-title {
color: #fff;
font-size: 28rpx;
font-weight: 800;
}
.boost-copy {
color: rgba(232, 204, 255, 0.72);
font-size: 23rpx;
line-height: 1.35;
}
.boost-switch {
transform: scale(0.78);
transform-origin: right center;
}
.inspiration-section {
display: flex;
flex-direction: column;
gap: 18rpx;
}
.section-line {
display: flex;
align-items: center;
justify-content: space-between;
}
.section-title {
font-size: 44rpx;
font-weight: 700;
}
.refresh {
font-size: 30rpx;
color: #e8ccff;
}
.recommend-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14rpx;
}
.recommend-card {
min-height: 68rpx;
display: flex;
align-items: center;
min-width: 0;
padding: 0 22rpx;
border-radius: 24rpx;
background: linear-gradient(180deg, rgba(48, 24, 89, 0.78), rgba(32, 14, 62, 0.76));
border: 1rpx solid rgba(168, 85, 247, 0.22);
box-sizing: border-box;
}
.recommend-text {
width: 100%;
min-width: 0;
font-size: 26rpx;
line-height: 68rpx;
color: rgba(255, 255, 255, 0.92);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.generation-view {
flex: 1;
height: 100%;
min-height: 0;
padding: 0;
}
.generation-scroll {
width: 100%;
height: 100%;
min-height: 0;
box-sizing: border-box;
}
.generation-scroll-content {
min-height: 100%;
display: flex;
flex-direction: column;
gap: 42rpx;
box-sizing: border-box;
padding: 40rpx 0 190rpx;
}
.generation-scroll-anchor {
width: 1rpx;
height: 28rpx;
}
.conversation {
display: flex;
flex-direction: column;
gap: 20rpx;
}
.conversation.compact {
margin-bottom: 26rpx;
}
.chat-bubble {
max-width: 86%;
display: flex;
flex-direction: column;
gap: 8rpx;
padding: 24rpx 28rpx;
border-radius: 36rpx;
font-size: 34rpx;
line-height: 52rpx;
}
.chat-bubble.user {
align-self: flex-end;
background: linear-gradient(145deg, rgba(140, 68, 242, 0.86), rgba(95, 29, 184, 0.9));
color: #fff;
}
.chat-bubble.system {
align-self: flex-start;
background: rgba(255, 255, 255, 0.07);
border: 1rpx solid rgba(192, 132, 252, 0.22);
color: rgba(255, 255, 255, 0.92);
}
.thinking-dots {
display: flex;
align-items: center;
gap: 10rpx;
padding-top: 8rpx;
}
.thinking-dots view {
width: 12rpx;
height: 12rpx;
border-radius: 50%;
background: #ffd86b;
box-shadow: 0 0 18rpx rgba(255, 216, 107, 0.52);
animation: thinkingDot 1.2s ease-in-out infinite;
}
.thinking-dots view:nth-child(2) {
animation-delay: 0.18s;
background: #d18aff;
}
.thinking-dots view:nth-child(3) {
animation-delay: 0.36s;
background: #8c44f2;
}
@keyframes thinkingDot {
0%, 80%, 100% {
opacity: 0.38;
transform: translateY(0);
}
40% {
opacity: 1;
transform: translateY(-8rpx);
}
}
.stream-preview {
display: block;
color: rgba(255, 255, 255, 0.86);
font-size: 28rpx;
line-height: 44rpx;
white-space: pre-wrap;
word-break: break-all;
}
.stream-preview.typing {
padding-top: 12rpx;
}
.typing-cursor {
color: #ffd86b;
animation: cursorBlink 0.9s steps(2, start) infinite;
}
.generation-error {
display: block;
color: rgba(255, 231, 163, 0.92);
font-size: 28rpx;
line-height: 44rpx;
white-space: pre-wrap;
}
@keyframes cursorBlink {
0%, 45% {
opacity: 1;
}
46%, 100% {
opacity: 0;
}
}
.chat-bubble.done {
border-color: rgba(192, 132, 252, 0.42);
}
.bubble-time {
font-size: 24rpx;
line-height: 32rpx;
color: rgba(255, 255, 255, 0.65);
}
.loading-orbit {
position: relative;
width: 180rpx;
height: 180rpx;
align-self: center;
border-radius: 50%;
border: 3rpx solid rgba(192, 132, 252, 0.28);
box-shadow: 0 0 44rpx rgba(168, 85, 247, 0.55);
animation: orbitBreath 2.4s ease-in-out infinite;
transition: opacity 0.2s ease, filter 0.2s ease;
}
.generation-loading {
display: flex;
flex-direction: column;
align-items: center;
gap: 28rpx;
}
.loading-orbit.streaming {
filter: saturate(1.2);
}
.loading-orbit.failed {
opacity: 0.52;
filter: grayscale(0.2);
}
.loading-orbit::after {
content: '';
position: absolute;
right: 18rpx;
top: 20rpx;
width: 24rpx;
height: 24rpx;
border-radius: 50%;
background: #ffd86b;
box-shadow: 0 0 24rpx rgba(255, 216, 107, 0.72);
animation: orbitLight 1.8s linear infinite;
transform-origin: -72rpx 70rpx;
}
.orbit-ring {
position: absolute;
inset: -22rpx;
border-radius: 50%;
border: 1rpx solid rgba(255, 216, 107, 0.16);
animation: ringPulse 2.8s ease-in-out infinite;
}
.orbit-ring.inner {
inset: 18rpx;
border-color: rgba(209, 138, 255, 0.2);
animation-delay: 0.7s;
}
.orbit-core {
position: absolute;
inset: 42rpx;
border-radius: 50%;
background: radial-gradient(circle, #c084fc, #5c1bb0);
box-shadow: inset 0 0 22rpx rgba(255, 255, 255, 0.18);
}
@keyframes orbitBreath {
0%, 100% {
transform: scale(0.96);
box-shadow: 0 0 36rpx rgba(168, 85, 247, 0.42);
}
50% {
transform: scale(1.04);
box-shadow: 0 0 70rpx rgba(168, 85, 247, 0.66);
}
}
@keyframes orbitLight {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@keyframes ringPulse {
0%, 100% {
opacity: 0.2;
transform: scale(0.92);
}
50% {
opacity: 0.78;
transform: scale(1.08);
}
}
.loading-copy {
text-align: center;
font-size: 30rpx;
color: rgba(255, 255, 255, 0.75);
}
.loading-subcopy {
display: block;
max-width: 620rpx;
text-align: center;
padding: 18rpx 24rpx;
border-radius: 28rpx;
color: rgba(255, 231, 163, 0.78);
font-size: 24rpx;
line-height: 36rpx;
background: rgba(255, 216, 107, 0.07);
border: 1rpx solid rgba(255, 216, 107, 0.14);
}
.generation-actions {
display: flex;
justify-content: center;
gap: 18rpx;
}
.generation-action {
min-width: 176rpx;
height: 72rpx;
margin: 0;
padding: 0 28rpx;
border-radius: 999rpx;
font-size: 27rpx;
line-height: 72rpx;
}
.generation-action::after {
border: 0;
}
.generation-action.primary {
color: #1b0b31;
background: linear-gradient(145deg, #ffd86b, #d18aff);
box-shadow: 0 16rpx 36rpx rgba(209, 138, 255, 0.24);
}
.generation-action.secondary {
color: #e8ccff;
background: rgba(43, 19, 83, 0.68);
border: 1rpx solid rgba(168, 85, 247, 0.32);
}
.story-card {
border-radius: 52rpx;
padding: 34rpx;
background: rgba(16, 8, 34, 0.72);
border: 1rpx solid rgba(192, 132, 252, 0.55);
box-shadow: 0 0 60rpx rgba(125, 55, 205, 0.18);
}
.story-head {
display: flex;
align-items: flex-start;
gap: 20rpx;
}
.story-title-wrap {
flex: 1;
min-width: 0;
}
.story-title {
display: block;
font-size: 52rpx;
font-weight: 700;
line-height: 1.35;
}
.close-icon {
width: 64rpx;
height: 64rpx;
line-height: 60rpx;
padding: 0;
border-radius: 50%;
color: rgba(255, 255, 255, 0.9);
background: rgba(255, 255, 255, 0.07);
border: 1rpx solid rgba(192, 132, 252, 0.32);
font-size: 42rpx;
}
.close-icon::after {
border: 0;
}
.tag-row {
display: flex;
flex-wrap: wrap;
gap: 12rpx;
margin-top: 18rpx;
}
.tag {
padding: 6rpx 16rpx;
border-radius: 999rpx;
color: #d18aff;
font-size: 24rpx;
background: rgba(168, 85, 247, 0.22);
}
.story-body {
display: block;
margin-top: 28rpx;
font-size: 32rpx;
font-weight: 400;
line-height: 1.78;
color: rgba(255, 255, 255, 0.92);
white-space: pre-wrap;
}
.audio-section {
margin-top: 28rpx;
min-height: 76rpx;
padding: 0 24rpx;
border-radius: 999rpx;
display: flex;
align-items: center;
justify-content: center;
gap: 14rpx;
color: #fff;
background: linear-gradient(135deg, rgba(36, 198, 220, 0.82), rgba(127, 90, 240, 0.88));
box-shadow: 0 12rpx 30rpx rgba(36, 198, 220, 0.18);
}
.audio-icon {
width: 34rpx;
height: 34rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: rgba(5, 6, 21, 0.86);
font-size: 18rpx;
font-weight: 900;
background: rgba(255, 255, 255, 0.86);
}
.audio-unavailable {
color: rgba(255, 255, 255, 0.92);
font-size: 25rpx;
font-weight: 800;
line-height: 1.2;
text-align: center;
}
.result-actions {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14rpx;
margin-top: 26rpx;
}
.action-btn {
height: 72rpx;
min-height: 72rpx;
padding: 0 8rpx;
border-radius: 28rpx;
display: flex;
align-items: center;
justify-content: center;
color: #e8ccff;
font-size: 26rpx;
font-weight: 700;
line-height: 1.15;
text-align: center;
white-space: normal;
box-sizing: border-box;
background: rgba(88, 28, 135, 0.18);
border: 1rpx solid rgba(192, 132, 252, 0.35);
}
.action-btn.primary {
color: #fff;
background: linear-gradient(145deg, #8c44f2, #5f1db8);
}
.action-btn::after {
border: 0;
}
</style>