64476eee6d
- life-script: 新增 aiRuntime 打字机流式服务,PathView/ScriptView/TimelineView 接入打字机效果 - mini-program: ScriptView 重构为打字机输出 + 卡片式灵感列表,主页布局优化 - web: aiRuntime 服务新增流式输出支持 - chat store: 消息状态管理和打字机流式渲染支持 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1691 lines
43 KiB
Vue
1691 lines
43 KiB
Vue
<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>
|