7d19fa5898
问题:详情页 chat-bubble 卡片视图显示「已回答:family」等英文 value, 而不是中文 label(如「家人」),且不展示所有可选选项。 修复: - 新增 getAnswerLabel(msg) 函数,通过 card.options 查找 value 对应的中文 label - rebuildResultMessages 增加 selectedValue 字段,与 answer 同时存原始 value - 卡片已回答视图新增 card-options-list,渲染所有选项,已选加 .selected 样式并标 ✓ - submitClarification(实时模式)同步存 selectedValue,保持渲染逻辑一致 影响: - ScriptView.vue(chat-bubble 流程视图) - ScriptDetailView.vue(详情页只读视图) 数据基础未动,后端澄清问答 answers 仍存原始 value(语义化)。 全局约束:rpx 单位、代码注释中文、禁止 mock 数据。
4041 lines
110 KiB
Vue
4041 lines
110 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 class="meteor meteor-four"></view>
|
||
<view class="meteor meteor-five"></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>
|
||
|
||
<view class="hero-copy">
|
||
<view class="hero-title-new">
|
||
<view class="hero-title-line">
|
||
<text>今天有什么</text>
|
||
<text class="hero-highlight">心愿</text>
|
||
</view>
|
||
<text class="hero-title-line hero-title-tail">想实现</text>
|
||
</view>
|
||
<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="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 class="wish-input-wrap" :class="{ active: homeInputFocused, twoLine: homeInputLevel === 'two', expanded: homeInputLevel === 'multi' }">
|
||
<text v-if="!wishText" class="custom-input-placeholder home-input-placeholder">写下你的心愿,AI帮你重写人生</text>
|
||
<textarea
|
||
class="wish-input"
|
||
v-model="wishText"
|
||
:auto-height="homeInputLevel !== 'single'"
|
||
:show-confirm-bar="false"
|
||
confirm-type="send"
|
||
maxlength="500"
|
||
@focus="homeInputFocused = true"
|
||
@blur="homeInputFocused = false"
|
||
@confirm="submitWish('text')"
|
||
/>
|
||
<view class="send-button" :class="{ disabled: !wishText.trim() }" @click="submitWish('text')">发送</view>
|
||
</view>
|
||
</view>
|
||
|
||
<view v-else-if="viewState === 'chat'" class="chat-page">
|
||
<view class="chat-top-actions">
|
||
<view class="history-button" @click="openScriptLibrary">
|
||
<view class="history-lines">
|
||
<view></view>
|
||
<view></view>
|
||
<view></view>
|
||
</view>
|
||
<text>历史</text>
|
||
</view>
|
||
<button class="page-close-btn" @click="closeResult">×</button>
|
||
</view>
|
||
|
||
<scroll-view
|
||
class="chat-scroll"
|
||
scroll-y
|
||
:scroll-top="resultCommandScrollTop"
|
||
:scroll-into-view="resultScrollTarget"
|
||
:scroll-with-animation="true"
|
||
:enhanced="true"
|
||
:show-scrollbar="false"
|
||
@scroll="handleResultScroll"
|
||
>
|
||
<view class="chat-scroll-content">
|
||
<view id="result-scroll-top" class="result-scroll-top"></view>
|
||
|
||
<view class="conversation compact">
|
||
<view
|
||
v-for="msg in resultMessages"
|
||
:key="msg.id"
|
||
>
|
||
<view v-if="msg.role === 'user' && msg.kind === 'text'" class="chat-bubble user">
|
||
<text>{{ msg.content }}</text>
|
||
<text class="bubble-time">{{ msg.time }}</text>
|
||
</view>
|
||
|
||
<view v-else-if="msg.kind === 'card'" class="chat-bubble system">
|
||
<ClarificationCard
|
||
v-if="!msg.submitted && msg.card"
|
||
:card="msg.card"
|
||
@submit="submitClarification"
|
||
/>
|
||
<view v-else class="card-answered">
|
||
<text v-if="msg.card?.question" class="card-question-text">{{ msg.card.question }}</text>
|
||
<!-- 用户回答(中文 label):通过 options 把 value 映射回 label 显示 -->
|
||
<view class="card-answer-row">
|
||
<text class="card-answer-prefix">已回答:</text>
|
||
<text class="card-answer-label">{{ getAnswerLabel(msg) }}</text>
|
||
</view>
|
||
<!-- 选项列表:展示用户可选的所有选项,已选的高亮显示 -->
|
||
<view v-if="Array.isArray(msg.card?.options) && msg.card.options.length" class="card-options-list">
|
||
<view
|
||
v-for="opt in msg.card.options"
|
||
:key="opt.value"
|
||
class="card-option-item"
|
||
:class="{ selected: opt.value === (msg.selectedValue || msg.answer) }"
|
||
>
|
||
<text class="card-option-label">{{ opt.label || opt.value }}</text>
|
||
<text v-if="opt.description" class="card-option-desc">{{ opt.description }}</text>
|
||
<text v-if="opt.value === (msg.selectedValue || msg.answer)" class="card-option-mark">✓</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<view v-else-if="msg.kind === 'outline'" class="chat-bubble system">
|
||
<view v-if="msg.outline">
|
||
<view v-if="msg.outline.title" class="outline-title">
|
||
<text>{{ msg.outline.title }}</text>
|
||
</view>
|
||
<view v-if="msg.outline.logline" class="outline-logline">
|
||
<text>{{ msg.outline.logline }}</text>
|
||
</view>
|
||
<view v-if="Array.isArray(msg.outline.beats)" class="outline-beats">
|
||
<view v-for="(beat, idx) in msg.outline.beats" :key="idx" class="beat-item">
|
||
<text class="beat-order">{{ beat.order || idx + 1 }}</text>
|
||
<view class="beat-content">
|
||
<text class="beat-title">{{ beat.title || '' }}</text>
|
||
<text v-if="beat.summary" class="beat-summary">{{ beat.summary }}</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
<view v-if="msg.outline.ending" class="outline-ending">
|
||
<text class="ending-label">结局</text>
|
||
<text class="ending-text">{{ msg.outline.ending }}</text>
|
||
</view>
|
||
</view>
|
||
<view v-if="!msg.confirmed" class="outline-actions">
|
||
<textarea v-model="msg.feedback" placeholder="如需修改请输入意见" class="outline-feedback" auto-height />
|
||
<view class="outline-buttons">
|
||
<view class="btn-secondary" @click="modifyOutline(msg)">修改大纲</view>
|
||
<view class="btn-primary" @click="confirmOutline(msg)">确认大纲</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<view v-else-if="msg.kind === 'novel'" class="chat-bubble system">
|
||
<text class="novel-text">{{ msg.content }}<text v-if="msg.pending" class="typing-cursor">|</text></text>
|
||
</view>
|
||
|
||
<view v-else class="chat-bubble system">
|
||
<text :class="{ 'generation-error': msg.failed }">{{ msg.content }}</text>
|
||
<text class="bubble-time">{{ msg.time }}</text>
|
||
</view>
|
||
</view>
|
||
|
||
<view v-if="showChatLoading" class="chat-loading">
|
||
<view class="loading-orbit" :class="{ streaming: generationStatus === 'streaming' }">
|
||
<view class="orbit-ring outer"></view>
|
||
<view class="orbit-ring inner"></view>
|
||
<view class="orbit-core"></view>
|
||
</view>
|
||
<text class="loading-copy">{{ generationCopy }}</text>
|
||
</view>
|
||
|
||
<view v-if="generationStatus === 'failed' && resumeableSessionId" class="resume-action">
|
||
<button class="generation-action primary" @click="resumeSession">继续创作</button>
|
||
</view>
|
||
|
||
<view :id="resultScrollAnchor" class="result-scroll-anchor"></view>
|
||
</view>
|
||
</view>
|
||
</scroll-view>
|
||
|
||
<view v-if="generationPhase === 'done'" class="chat-input-bar">
|
||
<view class="result-input-shell">
|
||
<textarea
|
||
class="result-chat-input"
|
||
v-model="chatInput"
|
||
:auto-height="true"
|
||
:show-confirm-bar="false"
|
||
maxlength="500"
|
||
confirm-type="send"
|
||
placeholder="输入修改建议,或让 AI 继续生成"
|
||
@confirm="sendChat"
|
||
/>
|
||
</view>
|
||
<view class="chat-send-btn" :class="{ disabled: !chatInput.trim() || generating }" @click="sendChat">发送</view>
|
||
</view>
|
||
</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 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'
|
||
import MessageCard from '../../components/MessageCard.vue'
|
||
import ClarificationCard from '../../components/ClarificationCard.vue'
|
||
import {
|
||
startNovelStream,
|
||
followupStream
|
||
} from '../../services/shortNovel.js'
|
||
import {
|
||
listMessagesByConversation,
|
||
listMessageVersions,
|
||
switchVersion,
|
||
deleteVersion
|
||
} from '../../services/scriptChat.js'
|
||
import { createMessage } from '../../services/message.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: 32, step: 1 })
|
||
const resultChatWriter = useTypewriterStream({ interval: 32, step: 1 })
|
||
const generationStatus = ref('idle')
|
||
const generationError = ref('')
|
||
const generationHintIndex = ref(0)
|
||
const generationScrollAnchor = ref('generation-stream-anchor-a')
|
||
const generationScrollTarget = ref('')
|
||
const generationScrollTop = ref(0)
|
||
const generationAutoFollow = ref(true)
|
||
const generationDisplayText = ref('')
|
||
const resumeableSessionId = ref('')
|
||
const generationPhase = ref('idle')
|
||
const lastGenerationPrompt = ref('')
|
||
const lastSubmitSource = ref('text')
|
||
const lastGenerationRevision = ref(false)
|
||
const homeInputFocused = ref(false)
|
||
const resultInputFocused = ref(false)
|
||
const resultInputFocus = ref(false)
|
||
const storyCollapsed = ref(false)
|
||
const resultChatInput = ref('')
|
||
const resultMessages = ref([])
|
||
const collapsedMessageIds = ref({})
|
||
const resultChatting = ref(false)
|
||
const resultScrollAnchor = ref('result-scroll-anchor-a')
|
||
const resultScrollTarget = ref('')
|
||
const resultScrollTop = ref(0)
|
||
const resultCommandScrollTop = ref(0)
|
||
const resultScrollHeight = ref(0)
|
||
const resultViewportHeight = ref(0)
|
||
const activeAssistantMessageId = ref('')
|
||
const revisionIntent = ref('')
|
||
const currentConversationId = ref('')
|
||
const remainingCount = ref(3)
|
||
const style = ref('career')
|
||
const randomRecommendations = ref([])
|
||
const useSocialInsights = ref(true)
|
||
const viewMode = ref('chat')
|
||
const scriptId = ref('')
|
||
const conversationId = ref('')
|
||
const currentVersionMessageId = ref('')
|
||
// 短篇小说 SSE 流式生成新增字段
|
||
const novelSessionId = ref('')
|
||
const firstQuery = ref('') // 首次发送的原始心愿,用于保存剧本时记录
|
||
const novelFullText = ref('')
|
||
const novelOutline = ref(null)
|
||
const clarificationCard = ref(null)
|
||
const currentStreamTask = ref(null)
|
||
const answeringClarification = ref(false)
|
||
// 等待下一个 assistant 响应(包括 followupStream 的所有响应)
|
||
const pendingNextResponse = ref(false)
|
||
const outlineFeedback = ref('')
|
||
const messages = ref([])
|
||
const versions = ref([])
|
||
const chatInput = 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
|
||
let resultScrollTargetTimer = null
|
||
let pendingOpenTimer = null
|
||
|
||
const generationHints = [
|
||
'正在从你的心愿里寻找故事的起点',
|
||
'正在整理人生素材和情绪线索',
|
||
'正在安排一次更爽的命运转弯',
|
||
'正在让故事慢慢靠近你'
|
||
]
|
||
|
||
watch(() => currentResult.value?.id, (sourceId, previousSourceId) => {
|
||
if (sourceId && sourceId !== previousSourceId) {
|
||
ttsPlayer.prewarmSource(sourceId)
|
||
}
|
||
})
|
||
|
||
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 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 getInputLevel = (text, singleThreshold = 18, twoLineThreshold = 42) => {
|
||
const value = String(text || '')
|
||
const trimmedLength = value.trim().length
|
||
const explicitLines = value.split('\n').length
|
||
if (!trimmedLength) return 'single'
|
||
if (explicitLines >= 3 || trimmedLength > twoLineThreshold) return 'multi'
|
||
if (explicitLines === 2 || trimmedLength > singleThreshold) return 'two'
|
||
return 'single'
|
||
}
|
||
|
||
const homeInputLevel = computed(() => getInputLevel(wishText.value, 20, 46))
|
||
const resultInputLevel = computed(() => getInputLevel(resultChatInput.value, 18, 42))
|
||
|
||
const displayedResultContent = computed(() => {
|
||
return resultContent.value || ''
|
||
})
|
||
|
||
const ASSISTANT_MESSAGE_PREVIEW_LENGTH = 120
|
||
|
||
const isAssistantMessage = (message) => {
|
||
return message?.role === 'assistant' && !message.pending
|
||
}
|
||
|
||
const isMessageCollapsed = (message) => {
|
||
if (!isAssistantMessage(message)) return false
|
||
return Boolean(collapsedMessageIds.value[message.id])
|
||
}
|
||
|
||
const getMessageDisplayContent = (message) => {
|
||
const content = String(message?.content || '')
|
||
if (!isMessageCollapsed(message)) return content
|
||
if (content.length <= ASSISTANT_MESSAGE_PREVIEW_LENGTH) return content
|
||
return `${content.slice(0, ASSISTANT_MESSAGE_PREVIEW_LENGTH)}...`
|
||
}
|
||
|
||
const toggleMessageCollapse = (message) => {
|
||
if (!isAssistantMessage(message)) return
|
||
collapsedMessageIds.value = {
|
||
...collapsedMessageIds.value,
|
||
[message.id]: !isMessageCollapsed(message)
|
||
}
|
||
nextTick(measureResultViewport)
|
||
analytics.track('script_message_collapse_toggle', {
|
||
message_id: message?.id || '',
|
||
collapsed: collapsedMessageIds.value[message.id]
|
||
}, { eventType: 'script', pagePath })
|
||
}
|
||
|
||
const copyMessageContent = (message) => {
|
||
const content = String(message?.content || '')
|
||
if (!content.trim()) {
|
||
uni.showToast({ title: '暂无可复制内容', icon: 'none' })
|
||
return
|
||
}
|
||
uni.setClipboardData({
|
||
data: content,
|
||
success: () => {
|
||
uni.showToast({ title: '已复制', icon: 'success' })
|
||
}
|
||
})
|
||
analytics.track('script_message_copy_click', {
|
||
message_id: message?.id || '',
|
||
content_length: content.length
|
||
}, { eventType: 'script', pagePath })
|
||
}
|
||
|
||
const playMessageTts = (message) => {
|
||
const scriptId = currentResult.value?.id || ''
|
||
ttsPlayer.playSource(scriptId)
|
||
analytics.track('script_message_tts_click', {
|
||
message_id: message?.id || '',
|
||
script_id: scriptId
|
||
}, { eventType: 'script', pagePath })
|
||
}
|
||
|
||
const visibleStreamContent = computed(() => streamWriter.visibleText.value)
|
||
const resultCanScroll = computed(() => resultScrollHeight.value > resultViewportHeight.value + 24)
|
||
const resultAtTop = computed(() => resultScrollTop.value <= 24)
|
||
const resultAtBottom = computed(() => {
|
||
if (!resultCanScroll.value) return true
|
||
return resultScrollTop.value + resultViewportHeight.value >= resultScrollHeight.value - 36
|
||
})
|
||
const showScrollTopBtn = computed(() => resultCanScroll.value && !resultAtTop.value)
|
||
const showScrollBottomBtn = computed(() => resultCanScroll.value && !resultAtBottom.value)
|
||
const resultHasScriptReplies = computed(() => {
|
||
return resultMessages.value.some(item => item.role === 'assistant' && item.kind === 'script')
|
||
})
|
||
|
||
const currentScriptContent = computed(() => {
|
||
const targetId = currentVersionMessageId.value
|
||
const targetMessage = messages.value.find(m => m.id === targetId && m.type === 'script')
|
||
let result
|
||
if (!targetMessage || !targetMessage.metadata) {
|
||
result = {
|
||
title: currentResult.value?.title || '',
|
||
plotIntro: currentResult.value?.plotIntro || '',
|
||
plotTurning: currentResult.value?.plotTurning || '',
|
||
plotClimax: currentResult.value?.plotClimax || '',
|
||
plotEnding: currentResult.value?.plotEnding || ''
|
||
}
|
||
} else {
|
||
try {
|
||
result = JSON.parse(targetMessage.metadata)
|
||
} catch (e) {
|
||
result = { title: '', plotIntro: targetMessage.metadata || '' }
|
||
}
|
||
}
|
||
|
||
// 章节字段全空时,用 plotJson.fullContent 作为 fallback
|
||
const hasChapter = result.plotIntro || result.plotTurning || result.plotClimax || result.plotEnding
|
||
if (!hasChapter) {
|
||
const fullContent = currentResult.value?.plotJson?.fullContent || ''
|
||
if (fullContent) {
|
||
result.plotIntro = fullContent
|
||
}
|
||
}
|
||
|
||
return result
|
||
})
|
||
|
||
const displayMessages = computed(() => messages.value)
|
||
|
||
const loadMessages = async () => {
|
||
if (!conversationId.value) return
|
||
|
||
try {
|
||
const res = await listMessagesByConversation({ conversationId: conversationId.value, includeVersions: false })
|
||
let loaded = res.data || []
|
||
|
||
// 历史剧本兼容:若 API 返回空且 plotJson 有内容,自动初始化首条 AI 消息
|
||
if (loaded.length === 0 && currentResult.value?.plotJson?.fullContent) {
|
||
await initializeFirstAIMessage()
|
||
const retryRes = await listMessagesByConversation({ conversationId: conversationId.value, includeVersions: false })
|
||
loaded = retryRes.data || []
|
||
}
|
||
|
||
messages.value = loaded
|
||
const scriptMessages = messages.value.filter(m => m.type === 'script')
|
||
if (scriptMessages.length > 0) {
|
||
const rootMessage = scriptMessages[0]
|
||
const versionRes = await listMessageVersions(rootMessage.id)
|
||
versions.value = versionRes.data || []
|
||
}
|
||
} catch (error) {
|
||
uni.showToast({ title: error.message || '加载消息失败', icon: 'none' })
|
||
}
|
||
}
|
||
|
||
const getCurrentUserId = () => {
|
||
try {
|
||
const token = uni.getStorageSync('access_token')
|
||
if (!token) return ''
|
||
const payload = token.split('.')[1]
|
||
const decoded = JSON.parse(decodeURIComponent(Array.from(atob(payload), c => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2)).join('')))
|
||
return decoded.sub || ''
|
||
} catch (e) {
|
||
return ''
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 历史剧本兼容:基于 plotJson 初始化首条 AI 剧本消息
|
||
* 触发条件:loadMessages 返回空列表,且 currentResult.plotJson.fullContent 存在
|
||
*/
|
||
const initializeFirstAIMessage = async () => {
|
||
const content = currentResult.value?.plotJson?.fullContent || ''
|
||
if (!content) return
|
||
|
||
try {
|
||
const userId = getCurrentUserId()
|
||
// 先保存用户心愿消息(theme)
|
||
const theme = currentResult.value?.theme || currentResult.value?.title || ''
|
||
if (theme) {
|
||
await createMessage({
|
||
conversationId: conversationId.value,
|
||
userId,
|
||
content: theme,
|
||
senderType: 'user',
|
||
contentType: 'chat'
|
||
})
|
||
}
|
||
|
||
// 再保存 AI 剧本消息
|
||
await createMessage({
|
||
conversationId: conversationId.value,
|
||
userId,
|
||
content,
|
||
senderType: 'assistant',
|
||
contentType: 'script',
|
||
versionNumber: 1
|
||
})
|
||
} catch (error) {
|
||
// 初始化失败不阻塞页面加载,仅日志
|
||
console.warn('[ScriptView] initializeFirstAIMessage failed:', error)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 格式化版本标签,显示在 MessageCard 上
|
||
*/
|
||
const formatVersionLabel = (message) => {
|
||
if (message.type !== 'script') return ''
|
||
const versionNum = message.versionNumber || 1
|
||
const isCurrent = message.id === currentVersionMessageId.value
|
||
return isCurrent ? `V${versionNum} (当前)` : `V${versionNum}`
|
||
}
|
||
|
||
/**
|
||
* 判断消息是否有子版本
|
||
*/
|
||
const messageHasChildren = (message) => {
|
||
if (!message || message.type !== 'script') return false
|
||
// 通过 versions 列表判断是否有子版本
|
||
return versions.value.some(v => v.parentMessageId === message.id)
|
||
}
|
||
|
||
/**
|
||
* 查看消息的版本历史
|
||
*/
|
||
const viewMessageVersions = async (message) => {
|
||
try {
|
||
const res = await listMessageVersions(message.id)
|
||
const versionList = res.data || []
|
||
if (versionList.length === 0) {
|
||
uni.showToast({ title: '暂无历史版本', icon: 'none' })
|
||
return
|
||
}
|
||
|
||
const items = versionList.map(v => {
|
||
const isCurrent = v.id === currentVersionMessageId.value
|
||
return `${isCurrent ? '★ ' : ''}V${v.versionNumber || '-'}`
|
||
})
|
||
|
||
uni.showActionSheet({
|
||
itemList: items,
|
||
success: async (actionRes) => {
|
||
const selected = versionList[actionRes.tapIndex]
|
||
if (!selected) return
|
||
if (selected.id === currentVersionMessageId.value) {
|
||
uni.showToast({ title: '已是当前版本', icon: 'none' })
|
||
return
|
||
}
|
||
try {
|
||
await switchVersion({ scriptId: scriptId.value, messageId: selected.id })
|
||
currentVersionMessageId.value = selected.id
|
||
await loadMessages()
|
||
uni.showToast({ title: `已切换到 V${selected.versionNumber}`, icon: 'success' })
|
||
} catch (switchErr) {
|
||
uni.showToast({ title: switchErr.message || '切换失败', icon: 'none' })
|
||
}
|
||
}
|
||
})
|
||
} catch (error) {
|
||
uni.showToast({ title: error.message || '加载版本失败', icon: 'none' })
|
||
}
|
||
}
|
||
|
||
const sendChat = async () => {
|
||
if (!chatInput.value.trim() || generating.value) return
|
||
const content = chatInput.value.trim()
|
||
chatInput.value = ''
|
||
|
||
// 仅在 generationPhase === 'done' 时处理;生成流程中底部输入栏不显示,但防御性 short-circuit
|
||
if (generationPhase.value !== 'done') return
|
||
|
||
const messageId = currentVersionMessageId.value
|
||
|
||
const userMessageRes = await createMessage({
|
||
conversationId: conversationId.value,
|
||
userId: getCurrentUserId(),
|
||
content,
|
||
senderType: 'user',
|
||
contentType: 'chat'
|
||
})
|
||
const userMessageId = userMessageRes.data?.id
|
||
|
||
// 用户消息追加到统一对话流 resultMessages
|
||
resultMessages.value.push({
|
||
id: userMessageId || createMessageId(),
|
||
role: 'user',
|
||
kind: 'text',
|
||
content,
|
||
pending: false,
|
||
time: formatMessageTime(),
|
||
submitted: false,
|
||
confirmed: false
|
||
})
|
||
keepResultAtBottom()
|
||
|
||
generating.value = true
|
||
generationStatus.value = 'streaming'
|
||
await streamScriptChat({
|
||
operationType: 'chat',
|
||
conversationId: conversationId.value,
|
||
messageId,
|
||
userMessageId,
|
||
content,
|
||
scriptId: scriptId.value,
|
||
onDelta: (delta) => {
|
||
// 往最后一条 AI 小说消息追加 delta
|
||
const lastAssistant = [...resultMessages.value].reverse().find(m => m.role === 'assistant' && m.kind === 'novel')
|
||
if (lastAssistant) {
|
||
lastAssistant.content += delta
|
||
}
|
||
},
|
||
onDone: () => {
|
||
generating.value = false
|
||
generationStatus.value = 'idle'
|
||
loadMessages()
|
||
},
|
||
onError: (error) => {
|
||
generating.value = false
|
||
generationStatus.value = 'idle'
|
||
uni.showToast({ title: error || '生成失败', icon: 'none' })
|
||
}
|
||
})
|
||
}
|
||
|
||
const rewriteMessage = (msg) => {
|
||
chatInput.value = ''
|
||
uni.showModal({
|
||
title: '改写意图',
|
||
editable: true,
|
||
placeholderText: '例如:把结局改得更积极',
|
||
success: async (res) => {
|
||
if (res.confirm && res.content) {
|
||
await sendRewrite(msg.id, res.content)
|
||
}
|
||
}
|
||
})
|
||
}
|
||
|
||
const sendRewrite = async (targetMessageId, content) => {
|
||
const userMessageRes = await createMessage({
|
||
conversationId: conversationId.value,
|
||
userId: getCurrentUserId(),
|
||
content,
|
||
senderType: 'user',
|
||
contentType: 'chat'
|
||
})
|
||
const userMessageId = userMessageRes.data?.id
|
||
|
||
messages.value.push({
|
||
id: userMessageId,
|
||
conversationId: conversationId.value,
|
||
content,
|
||
sender: 'user',
|
||
type: 'chat'
|
||
})
|
||
|
||
generating.value = true
|
||
await streamScriptChat({
|
||
operationType: 'rewrite',
|
||
conversationId: conversationId.value,
|
||
messageId: targetMessageId,
|
||
userMessageId,
|
||
content,
|
||
scriptId: scriptId.value,
|
||
onDelta: () => {},
|
||
onDone: () => {
|
||
generating.value = false
|
||
loadMessages()
|
||
},
|
||
onError: (error) => {
|
||
generating.value = false
|
||
uni.showToast({ title: error || '改写失败', icon: 'none' })
|
||
}
|
||
})
|
||
}
|
||
|
||
const continueMessage = (msg) => {
|
||
chatInput.value = '继续生成后续内容'
|
||
sendChatAsContinue(msg.id)
|
||
}
|
||
|
||
const sendChatAsContinue = async (targetMessageId) => {
|
||
const content = '继续生成后续内容'
|
||
const userMessageRes = await createMessage({
|
||
conversationId: conversationId.value,
|
||
content,
|
||
sender: 'user',
|
||
type: 'chat'
|
||
})
|
||
const userMessageId = userMessageRes.data?.id
|
||
|
||
generating.value = true
|
||
await streamScriptChat({
|
||
operationType: 'continue',
|
||
conversationId: conversationId.value,
|
||
messageId: targetMessageId,
|
||
userMessageId,
|
||
content,
|
||
scriptId: scriptId.value,
|
||
onDelta: () => {},
|
||
onDone: () => {
|
||
generating.value = false
|
||
loadMessages()
|
||
},
|
||
onError: (error) => {
|
||
generating.value = false
|
||
uni.showToast({ title: error || '续写失败', icon: 'none' })
|
||
}
|
||
})
|
||
}
|
||
|
||
const showVersions = async (msg) => {
|
||
const res = await listMessageVersions(msg.id)
|
||
const list = res.data || []
|
||
const items = list.map(v => `V${v.versionNumber} ${v.id === currentVersionMessageId.value ? '(当前)' : ''}`)
|
||
uni.showActionSheet({
|
||
itemList: items,
|
||
success: async (res) => {
|
||
const selected = list[res.tapIndex]
|
||
// 切换到选中的版本
|
||
try {
|
||
await switchVersion({ scriptId: scriptId.value, messageId: selected.id })
|
||
currentVersionMessageId.value = selected.id
|
||
await loadMessages()
|
||
uni.showToast({ title: `已切换到 V${selected.versionNumber}`, icon: 'success' })
|
||
} catch (error) {
|
||
uni.showToast({ title: error.message || '切换失败', icon: 'none' })
|
||
}
|
||
}
|
||
})
|
||
}
|
||
|
||
const removeVersion = async (msg) => {
|
||
if (msg.id === currentVersionMessageId.value) {
|
||
uni.showToast({ title: '不能删除当前生效版本', icon: 'none' })
|
||
return
|
||
}
|
||
uni.showModal({
|
||
title: '确认删除',
|
||
content: `确定删除 V${msg.versionNumber} 吗?`,
|
||
success: async (res) => {
|
||
if (res.confirm) {
|
||
await deleteVersion(msg.id)
|
||
await loadMessages()
|
||
}
|
||
}
|
||
})
|
||
}
|
||
|
||
const measureResultViewport = () => {
|
||
nextTick(() => {
|
||
const query = uni.createSelectorQuery()
|
||
query.select('.result-view').boundingClientRect()
|
||
query.select('.result-scroll-content').boundingClientRect()
|
||
query.exec((rects = []) => {
|
||
const viewport = rects[0]
|
||
const content = rects[1]
|
||
if (viewport?.height) resultViewportHeight.value = viewport.height
|
||
if (content?.height) resultScrollHeight.value = content.height
|
||
})
|
||
})
|
||
}
|
||
|
||
const handleResultScroll = (event) => {
|
||
const detail = event?.detail || {}
|
||
resultScrollTop.value = Number(detail.scrollTop || 0)
|
||
resultScrollHeight.value = Number(detail.scrollHeight || resultScrollHeight.value || 0)
|
||
if (!resultViewportHeight.value) measureResultViewport()
|
||
}
|
||
|
||
const setResultScrollTarget = (target) => {
|
||
if (!target) return
|
||
if (resultScrollTargetTimer) {
|
||
clearTimeout(resultScrollTargetTimer)
|
||
resultScrollTargetTimer = null
|
||
}
|
||
resultScrollTarget.value = ''
|
||
nextTick(() => {
|
||
resultScrollTarget.value = target
|
||
resultScrollTargetTimer = setTimeout(() => {
|
||
if (resultScrollTarget.value === target) resultScrollTarget.value = ''
|
||
resultScrollTargetTimer = null
|
||
}, 480)
|
||
})
|
||
}
|
||
|
||
const pushResultScrollCommand = (top) => {
|
||
const nextTop = Number(top || 0)
|
||
resultCommandScrollTop.value = resultCommandScrollTop.value === nextTop
|
||
? nextTop + 1
|
||
: nextTop
|
||
}
|
||
|
||
const scrollGenerationToLatest = () => {
|
||
if (viewState.value !== 'generating') return
|
||
if (!generationAutoFollow.value) return
|
||
if (generationScrollTimer) return
|
||
generationScrollTimer = setTimeout(() => {
|
||
generationScrollTimer = null
|
||
if (viewState.value !== 'generating' || !generationAutoFollow.value) return
|
||
nextTick(() => {
|
||
generationScrollTop.value = generationScrollTop.value >= 100000000
|
||
? 100000
|
||
: generationScrollTop.value + 100000
|
||
generationScrollTarget.value = ''
|
||
generationScrollAnchor.value = generationScrollAnchor.value === 'generation-stream-anchor-a'
|
||
? 'generation-stream-anchor-b'
|
||
: 'generation-stream-anchor-a'
|
||
nextTick(() => {
|
||
generationScrollTarget.value = generationScrollAnchor.value
|
||
})
|
||
})
|
||
}, 48)
|
||
}
|
||
|
||
const keepGenerationAtBottom = () => {
|
||
;[0, 60, 140, 260, 420].forEach((delay) => {
|
||
setTimeout(scrollGenerationToLatest, delay)
|
||
})
|
||
}
|
||
|
||
const scrollResultToLatest = () => {
|
||
// 统一对话页:viewState 只有 home/chat(result 已合并到 chat)
|
||
if (viewState.value !== 'chat') return
|
||
nextTick(() => {
|
||
measureResultViewport()
|
||
pushResultScrollCommand(1000000)
|
||
resultScrollAnchor.value = resultScrollAnchor.value === 'result-scroll-anchor-a'
|
||
? 'result-scroll-anchor-b'
|
||
: 'result-scroll-anchor-a'
|
||
setResultScrollTarget(resultScrollAnchor.value)
|
||
})
|
||
}
|
||
|
||
const scrollResultToBottom = () => {
|
||
resultScrollTop.value = Math.max(0, resultScrollHeight.value - resultViewportHeight.value)
|
||
scrollResultToLatest()
|
||
}
|
||
|
||
const keepResultAtBottom = () => {
|
||
;[0, 80, 180, 360, 720].forEach((delay) => {
|
||
setTimeout(scrollResultToLatest, delay)
|
||
})
|
||
}
|
||
|
||
const scrollResultToTop = () => {
|
||
if (viewState.value !== 'chat') return
|
||
resultScrollTop.value = 0
|
||
resultCommandScrollTop.value = 1
|
||
nextTick(() => {
|
||
resultCommandScrollTop.value = 0
|
||
setResultScrollTarget('result-scroll-top')
|
||
})
|
||
}
|
||
|
||
watch(visibleStreamContent, (text) => {
|
||
if (!text) return
|
||
scrollGenerationToLatest()
|
||
})
|
||
|
||
watch(generationStatus, () => {
|
||
scrollGenerationToLatest()
|
||
})
|
||
|
||
watch(viewState, (state) => {
|
||
if (state !== 'chat') return
|
||
measureResultViewport()
|
||
})
|
||
|
||
watch(resultChatWriter.visibleText, (text) => {
|
||
if (!activeAssistantMessageId.value) return
|
||
const message = resultMessages.value.find(item => item.id === activeAssistantMessageId.value)
|
||
if (!message) return
|
||
message.content = text
|
||
keepResultAtBottom()
|
||
})
|
||
|
||
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 '正在把你的心愿写成故事'
|
||
})
|
||
|
||
// chat 视图 loading 显示条件:等待首个 assistant 响应期间
|
||
// - generationPhase === 'generating':生成流程未完成
|
||
// - generationStatus !== 'failed':非失败状态
|
||
// - pendingNextResponse:用户刚发完请求(心愿或澄清答案),等待下一个 assistant 响应
|
||
const showChatLoading = computed(() => {
|
||
if (generationPhase.value !== 'generating') return false
|
||
if (generationStatus.value === 'failed') return false
|
||
return pendingNextResponse.value === true
|
||
})
|
||
|
||
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 ttsActionText = computed(() => {
|
||
if (!currentResult.value?.id) return '播放'
|
||
if (ttsPlayer.loading.value) return '生成中'
|
||
return ttsPlayer.playing.value ? '暂停' : '播放'
|
||
})
|
||
|
||
const ttsActionIcon = computed(() => {
|
||
return ttsPlayer.playing.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 plotJson = merged?.plotJson || {}
|
||
const content = normalizeGeneratedContent(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: normalizeGeneratedContent(merged?.summary || content.slice(0, 90)),
|
||
content,
|
||
plotJson,
|
||
conversationId: merged?.conversationId || plotJson.conversationId || '',
|
||
parentScriptId: merged?.parentScriptId || plotJson.parentScriptId || '',
|
||
revisionIndex: Number(merged?.revisionIndex || plotJson.revisionIndex || 1),
|
||
revisionOf: merged?.revisionOf || plotJson.revisionOf || ''
|
||
}
|
||
}
|
||
|
||
const openScriptLibrary = () => {
|
||
analytics.track('script_history_click', {}, { eventType: 'script', pagePath })
|
||
uni.$emit('switchTab', 'library')
|
||
}
|
||
|
||
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) => {
|
||
// 统一对话页:在 chat 视图填充底部输入栏,在 home 视图填充心愿输入框
|
||
if (viewState.value === 'chat') {
|
||
chatInput.value = text
|
||
} else {
|
||
wishText.value = text
|
||
homeInputFocused.value = true
|
||
}
|
||
voiceState.value = 'idle'
|
||
analytics.track('script_voice_recognize_success', {
|
||
text_length: text.length,
|
||
duration_ms: durationMs || 0
|
||
}, { eventType: 'script', pagePath })
|
||
uni.showToast({ title: viewState.value === 'chat' ? '识别成功,可继续修改建议' : '识别成功,可修改后发送', 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
|
||
try {
|
||
recorderManager = uni.getRecorderManager()
|
||
if (!recorderManager) return
|
||
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', '录音失败,请检查麦克风权限')
|
||
})
|
||
} catch {
|
||
recorderManager = null
|
||
}
|
||
}
|
||
|
||
const SCRIPT_CHAT_HISTORY_PREFIX = 'script_conversation_history_'
|
||
const LEGACY_SCRIPT_CHAT_HISTORY_PREFIX = 'script_chat_history_'
|
||
const PENDING_SCRIPT_CHAT_KEY = 'pending_open_script_chat'
|
||
|
||
const createMessageId = () => `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||
const createConversationId = () => `conv-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||
|
||
/**
|
||
* 把已回答卡片的 value(如 "family")映射为对应的中文 label(如 "家人")
|
||
* 若选项中找不到匹配的 value,则原样返回 answer(兜底)
|
||
* 若 msg.card 不存在则返回 "未回答"
|
||
*/
|
||
const getAnswerLabel = (msg) => {
|
||
if (!msg || !msg.card) return '未回答'
|
||
const raw = msg.selectedValue || msg.answer || ''
|
||
if (!raw) return '未回答'
|
||
const options = Array.isArray(msg.card.options) ? msg.card.options : []
|
||
const match = options.find(opt => opt && opt.value === raw)
|
||
return match?.label || raw
|
||
}
|
||
|
||
const getCurrentScriptId = () => String(currentResult.value?.id || '')
|
||
const getCurrentConversationId = () => String(currentConversationId.value || currentResult.value?.conversationId || '')
|
||
|
||
const ensureConversationId = (script = currentResult.value) => {
|
||
const existing = String(script?.conversationId || script?.plotJson?.conversationId || currentConversationId.value || '')
|
||
currentConversationId.value = existing || createConversationId()
|
||
return currentConversationId.value
|
||
}
|
||
|
||
const getScriptChatHistoryKey = (conversationId = getCurrentConversationId()) => {
|
||
return conversationId ? `${SCRIPT_CHAT_HISTORY_PREFIX}${conversationId}` : ''
|
||
}
|
||
|
||
const readStoredMessages = (key) => {
|
||
// localStorage 不再作为主要消息存储
|
||
return []
|
||
}
|
||
|
||
const readScriptChatHistory = ({ conversationId, scriptId } = {}) => {
|
||
// localStorage 不再作为主要消息存储
|
||
return []
|
||
}
|
||
|
||
const persistResultMessages = () => {
|
||
// localStorage 不再作为主要消息存储,消息通过后端 API 管理
|
||
}
|
||
|
||
const hydrateResultMessages = ({ conversationId, scriptId } = {}) => {
|
||
// localStorage 不再作为主要消息存储,消息通过后端 API 管理
|
||
resultMessages.value = []
|
||
collapsedMessageIds.value = {}
|
||
}
|
||
|
||
const addResultMessage = ({ role, content, pending = false, kind = 'text', card = null, outline = null, answer = '', feedback = '', failed = false }) => {
|
||
const message = {
|
||
id: createMessageId(),
|
||
role,
|
||
kind,
|
||
content,
|
||
pending,
|
||
time: formatMessageTime(),
|
||
card,
|
||
outline,
|
||
answer,
|
||
feedback,
|
||
failed,
|
||
submitted: false,
|
||
confirmed: false
|
||
}
|
||
resultMessages.value.push(message)
|
||
if (!pending) persistResultMessages()
|
||
keepResultAtBottom()
|
||
return message
|
||
}
|
||
|
||
const getScriptVersions = (script = currentResult.value) => {
|
||
const versions = script?.plotJson?.versions
|
||
return Array.isArray(versions) ? versions : []
|
||
}
|
||
|
||
const createScriptVersion = ({ script, content, revisionIndex, prompt, source }) => ({
|
||
id: `version-${revisionIndex}-${Date.now()}`,
|
||
revisionIndex,
|
||
title: script?.title || wishText.value || '我的人生剧本',
|
||
content,
|
||
summary: normalizeGeneratedContent(content).slice(0, 90),
|
||
prompt: prompt || '',
|
||
source: source || 'mini-program-stream',
|
||
createdAt: new Date().toISOString()
|
||
})
|
||
|
||
const buildVersionedPlotJson = ({ baseScript, content, conversationId, revisionIndex, prompt, source }) => {
|
||
const existingPlotJson = baseScript?.plotJson || {}
|
||
const existingVersions = Array.isArray(existingPlotJson.versions) ? existingPlotJson.versions : []
|
||
const nextVersion = createScriptVersion({
|
||
script: baseScript,
|
||
content,
|
||
revisionIndex,
|
||
prompt,
|
||
source
|
||
})
|
||
return {
|
||
...existingPlotJson,
|
||
mode: existingPlotJson.mode || 'inspiration',
|
||
prompt,
|
||
source,
|
||
fullContent: content,
|
||
conversationId,
|
||
currentRevisionIndex: revisionIndex,
|
||
versions: [...existingVersions, nextVersion]
|
||
}
|
||
}
|
||
|
||
|
||
const buildRevisionPrompt = (suggestion) => {
|
||
const history = resultMessages.value
|
||
.filter(item => item.role === 'user' || item.role === 'assistant')
|
||
.map(item => `${item.role === 'user' ? '用户' : 'AI'}:${item.content}`)
|
||
.slice(-10)
|
||
.join('\n')
|
||
return [
|
||
'请基于同一个对话中的上一版小说和用户最新修改需求,重新生成一篇完整中文爽文小说。',
|
||
'要求:保留原始心愿的核心情绪,沿用上一版有效设定,只针对用户要求进行调衡和改写,输出完整正文,不要输出解释说明。',
|
||
`对话ID:${getCurrentConversationId()}`,
|
||
`原始心愿:${wishText.value || currentResult.value?.theme || ''}`,
|
||
`上一版小说标题:${currentResult.value?.title || '我的人生剧本'}`,
|
||
`上一版小说正文:${resultContent.value || ''}`,
|
||
history ? `最近对话历史:\n${history}` : '',
|
||
`用户修改需求:${suggestion}`
|
||
].filter(Boolean).join('\n\n')
|
||
}
|
||
|
||
const buildChatPrompt = (message) => {
|
||
return [
|
||
'你是如愿星球里帮助用户修改爽文小说的中文创作 agent。',
|
||
'请围绕当前小说回应用户的修改意见,先帮助用户确认方向、给出简短建议,不要直接重写全文。',
|
||
'如果用户表达已确认要重新生成,请提醒用户发送“确认生成”或给出更明确的修改方向。',
|
||
`原始心愿:${wishText.value || ''}`,
|
||
`对话ID:${getCurrentConversationId()}`,
|
||
`当前小说标题:${currentResult.value?.title || '我的人生剧本'}`,
|
||
`当前小说正文:${resultContent.value || ''}`,
|
||
`用户消息:${message}`
|
||
].join('\n\n')
|
||
}
|
||
|
||
const normalizeCommandText = (message) => {
|
||
return String(message || '')
|
||
.replace(/\s+/g, '')
|
||
.replace(/[,。!?、;:,.!?;:"'“”‘’]/g, '')
|
||
}
|
||
|
||
const shouldRegenerateFromMessage = (message) => {
|
||
const text = normalizeCommandText(message)
|
||
if (!text) return false
|
||
const hasRegenerateAction = /(\u751f\u6210|\u91cd\u5199|\u91cd\u65b0)/u.test(text)
|
||
const hasNegation = /(\u4e0d|\u522b|\u4e0d\u8981|\u5148\u4e0d|\u6682\u4e0d).*(\u751f\u6210|\u91cd\u5199|\u91cd\u65b0)/u.test(text)
|
||
if (hasRegenerateAction && hasNegation) return false
|
||
const exactCommands = new Set([
|
||
'\u786e\u8ba4\u751f\u6210',
|
||
'\u786e\u8ba4\u91cd\u5199',
|
||
'\u5f00\u59cb\u751f\u6210',
|
||
'\u5f00\u59cb\u91cd\u5199',
|
||
'\u91cd\u65b0\u751f\u6210',
|
||
'\u76f4\u63a5\u751f\u6210',
|
||
'\u5c31\u8fd9\u6837\u751f\u6210',
|
||
'\u5c31\u8fd9\u6837',
|
||
'\u751f\u6210',
|
||
'\u91cd\u5199'
|
||
])
|
||
return exactCommands.has(text)
|
||
|| /\u786e\u8ba4.*(\u751f\u6210|\u91cd\u5199|\u91cd\u65b0)/u.test(text)
|
||
|| /(\u5f00\u59cb|\u76f4\u63a5|\u91cd\u65b0).*(\u751f\u6210|\u91cd\u5199)/u.test(text)
|
||
}
|
||
|
||
const removeLeadingRepeatedPrefix = (text) => {
|
||
let output = String(text || '')
|
||
let changed = true
|
||
while (changed) {
|
||
changed = false
|
||
const maxPrefixLength = Math.floor(output.length / 2)
|
||
for (let size = maxPrefixLength; size >= 80; size -= 1) {
|
||
const prefix = output.slice(0, size)
|
||
if (output.slice(size).startsWith(prefix)) {
|
||
output = output.slice(size).trimStart()
|
||
changed = true
|
||
break
|
||
}
|
||
}
|
||
}
|
||
return output
|
||
}
|
||
|
||
const normalizeParagraphForCompare = (paragraph) => {
|
||
return String(paragraph || '')
|
||
.replace(/[#>*_`~\-【】「」『』“”"',。!?、;:,.!?;:\s]/g, '')
|
||
.trim()
|
||
}
|
||
|
||
const similarityRatio = (left, right) => {
|
||
if (!left || !right) return 0
|
||
const short = left.length <= right.length ? left : right
|
||
const long = left.length > right.length ? left : right
|
||
if (!short) return 0
|
||
if (long.includes(short)) return short.length / long.length
|
||
let same = 0
|
||
const max = Math.min(left.length, right.length)
|
||
for (let index = 0; index < max; index += 1) {
|
||
if (left[index] === right[index]) same += 1
|
||
}
|
||
return same / Math.max(left.length, right.length)
|
||
}
|
||
|
||
const isRepeatedParagraph = (current, previous) => {
|
||
const currentKey = normalizeParagraphForCompare(current)
|
||
const previousKey = normalizeParagraphForCompare(previous)
|
||
if (currentKey.length < 24 || previousKey.length < 24) return false
|
||
if (currentKey === previousKey) return true
|
||
return similarityRatio(currentKey, previousKey) >= 0.92
|
||
}
|
||
|
||
const removeRepeatedParagraphs = (text) => {
|
||
const paragraphs = String(text || '').split(/\n{2,}/)
|
||
const kept = []
|
||
const recent = []
|
||
paragraphs.forEach((paragraph) => {
|
||
const trimmed = paragraph.trim()
|
||
if (!trimmed) return
|
||
const repeated = recent.some((item) => isRepeatedParagraph(trimmed, item))
|
||
if (!repeated) {
|
||
kept.push(trimmed)
|
||
recent.push(trimmed)
|
||
if (recent.length > 8) recent.shift()
|
||
}
|
||
})
|
||
return kept.join('\n\n')
|
||
}
|
||
|
||
const normalizeGeneratedContent = (text) => {
|
||
return removeRepeatedParagraphs(removeLeadingRepeatedPrefix(String(text || '').trim()))
|
||
}
|
||
|
||
const openScriptChat = async (payload = {}) => {
|
||
uni.removeStorageSync(PENDING_SCRIPT_CHAT_KEY)
|
||
const scriptIdParam = String(payload?.id || payload?.scriptId || '')
|
||
let script = payload?.script || (payload?.id ? payload : null)
|
||
if (!script?.id && scriptIdParam) {
|
||
script = store.getScriptById(scriptIdParam)
|
||
if (!script) {
|
||
await store.fetchScripts()
|
||
script = store.getScriptById(scriptIdParam)
|
||
}
|
||
}
|
||
if (!script?.id) return
|
||
|
||
clearGenerationFeedbackTimers()
|
||
generating.value = false
|
||
generationStatus.value = 'idle'
|
||
generationError.value = ''
|
||
streamContent.value = ''
|
||
streamWriter.reset()
|
||
resultChatWriter.reset()
|
||
activeAssistantMessageId.value = ''
|
||
resultChatting.value = false
|
||
revisionIntent.value = ''
|
||
resultChatInput.value = ''
|
||
storyCollapsed.value = false
|
||
ttsPlayer.reset()
|
||
|
||
currentResult.value = normalizeGeneratedScript({ script })
|
||
scriptId.value = script.id
|
||
conversationId.value = script.conversationId || ''
|
||
currentVersionMessageId.value = script.currentVersionMessageId || ''
|
||
wishText.value = script.theme || script.prompt || script.title || '继续修改这个人生剧本'
|
||
currentMessageTime.value = formatMessageTime()
|
||
currentResultTime.value = script.updateTime || script.updatedAt || script.createTime || script.createdAt || formatMessageTime()
|
||
viewMode.value = 'chat'
|
||
// 切换到统一对话页(result 视图已合并到 chat)
|
||
viewState.value = 'chat'
|
||
generationPhase.value = 'done'
|
||
// 清空旧的统一对话消息,准备从历史记录重建
|
||
resultMessages.value = []
|
||
|
||
if (conversationId.value) {
|
||
await loadMessages()
|
||
}
|
||
|
||
// 从后端 messages 重建统一对话消息流,复刻生成时的 chat-bubble 展示
|
||
// 不再重复 push wishTheme/novelContent(messages 中已包含)
|
||
const messagesList = messages.value || []
|
||
messagesList.forEach(m => {
|
||
if (m.type === 'clarification_question') {
|
||
let card = {}
|
||
try { card = JSON.parse(m.content || '{}') } catch (e) { card = {} }
|
||
resultMessages.value.push({
|
||
id: m.id || createMessageId(),
|
||
role: 'assistant',
|
||
kind: 'card',
|
||
content: '',
|
||
card,
|
||
answer: '',
|
||
pending: false,
|
||
time: formatMessageTime(),
|
||
submitted: true,
|
||
confirmed: true
|
||
})
|
||
} else if (m.type === 'clarification_answer') {
|
||
// 把答案合并到上一条 card 消息:answer 存用户原始 value,selectedValue 也存 value 用于渲染时映射 label
|
||
const lastCard = [...resultMessages.value].reverse().find(x => x.kind === 'card')
|
||
if (lastCard) {
|
||
const rawValue = m.content || ''
|
||
lastCard.answer = rawValue // 原始 value(用于查找 label)
|
||
lastCard.selectedValue = rawValue // 选中的 value(渲染时通过 options 映射成 label)
|
||
lastCard.submitted = true
|
||
} else {
|
||
// 无对应 question,作为独立 user 消息
|
||
resultMessages.value.push({
|
||
id: m.id || createMessageId(),
|
||
role: 'user',
|
||
kind: 'text',
|
||
content: m.content || '',
|
||
pending: false,
|
||
time: formatMessageTime(),
|
||
submitted: false,
|
||
confirmed: false
|
||
})
|
||
}
|
||
} else if (m.type === 'outline') {
|
||
let outline = {}
|
||
try { outline = JSON.parse(m.content || '{}') } catch (e) { outline = {} }
|
||
resultMessages.value.push({
|
||
id: m.id || createMessageId(),
|
||
role: 'assistant',
|
||
kind: 'outline',
|
||
content: '',
|
||
outline,
|
||
pending: false,
|
||
time: formatMessageTime(),
|
||
submitted: false,
|
||
confirmed: true
|
||
})
|
||
} else if (m.type === 'script') {
|
||
resultMessages.value.push({
|
||
id: m.id || createMessageId(),
|
||
role: 'assistant',
|
||
kind: 'novel',
|
||
content: m.content || '',
|
||
pending: false,
|
||
time: formatMessageTime(),
|
||
submitted: false,
|
||
confirmed: true
|
||
})
|
||
} else if (m.type === 'chat' && m.sender === 'user') {
|
||
resultMessages.value.push({
|
||
id: m.id || createMessageId(),
|
||
role: 'user',
|
||
kind: 'text',
|
||
content: m.content || '',
|
||
pending: false,
|
||
time: formatMessageTime(),
|
||
submitted: false,
|
||
confirmed: false
|
||
})
|
||
}
|
||
// 跳过 system 类型(不展示)
|
||
})
|
||
|
||
analytics.track('script_chat_open_from_library', {
|
||
script_id: script.id,
|
||
has_history: messagesList.length > 0
|
||
}, { eventType: 'script', pagePath })
|
||
scrollResultToLatest()
|
||
}
|
||
|
||
const openPendingScriptChat = async () => {
|
||
const pending = uni.getStorageSync(PENDING_SCRIPT_CHAT_KEY)
|
||
if (!pending) return
|
||
uni.removeStorageSync(PENDING_SCRIPT_CHAT_KEY)
|
||
await openScriptChat(pending)
|
||
}
|
||
|
||
const handleOpenScriptChat = (payload) => {
|
||
openScriptChat(payload)
|
||
}
|
||
|
||
const saveGeneratedScriptContent = async ({
|
||
prompt,
|
||
content,
|
||
saveTheme,
|
||
conversationId,
|
||
revisionIndex = 1,
|
||
updateScriptId = '',
|
||
parentScriptId = '',
|
||
revisionOf = ''
|
||
}) => {
|
||
const profile = store.userProfile || store.registrationData || {}
|
||
const characterInfo = epicScriptService.buildCharacterInfo(profile)
|
||
const lifeEventsSummary = epicScriptService.buildLifeEventsSummary(store.events || [], profile)
|
||
const theme = saveTheme || prompt
|
||
const baseScript = updateScriptId ? currentResult.value : {
|
||
title: theme.length > 22 ? `${theme.slice(0, 22)}...` : theme,
|
||
theme,
|
||
style: style.value,
|
||
length: 'medium',
|
||
plotJson: {}
|
||
}
|
||
const title = baseScript?.title || (theme.length > 22 ? `${theme.slice(0, 22)}...` : theme)
|
||
const plotJson = buildVersionedPlotJson({
|
||
baseScript,
|
||
content,
|
||
conversationId,
|
||
revisionIndex,
|
||
prompt,
|
||
source: 'mini-program-stream'
|
||
})
|
||
const payload = {
|
||
id: updateScriptId || undefined,
|
||
title,
|
||
theme,
|
||
style: baseScript?.style || style.value,
|
||
length: baseScript?.length || 'medium',
|
||
content,
|
||
plotJson,
|
||
conversationId,
|
||
parentScriptId,
|
||
revisionIndex,
|
||
revisionOf,
|
||
characterInfo,
|
||
lifeEventsSummary,
|
||
useSocialInsights: useSocialInsights.value
|
||
}
|
||
const saveRes = updateScriptId
|
||
? await store.updateScript(payload)
|
||
: await store.createScript(payload)
|
||
if (!saveRes.success) {
|
||
throw new Error(saveRes.error || (updateScriptId ? '更新剧本失败' : '保存剧本失败'))
|
||
}
|
||
return {
|
||
success: true,
|
||
data: {
|
||
script: saveRes.data,
|
||
remainingCount: remainingCount.value
|
||
}
|
||
}
|
||
}
|
||
|
||
const generateScriptByStream = async (text, options = {}) => {
|
||
const saveTheme = options.saveTheme || text
|
||
const conversationId = options.conversationId || ensureConversationId()
|
||
const revisionIndex = options.revisionIndex || 1
|
||
const updateScriptId = options.updateScriptId || ''
|
||
const streamRes = await streamAiScene({
|
||
sceneCode: 'script_generate',
|
||
inputs: {
|
||
prompt: text,
|
||
style: style.value,
|
||
length: 'medium',
|
||
useSocialInsights: useSocialInsights.value
|
||
},
|
||
onDelta: (_delta, output) => {
|
||
const preview = normalizeGeneratedContent(output)
|
||
streamContent.value = preview
|
||
markGenerationStreaming()
|
||
streamWriter.push(preview)
|
||
scrollGenerationToLatest()
|
||
},
|
||
onDone: (_event, output) => {
|
||
streamWriter.finish(normalizeGeneratedContent(output))
|
||
keepGenerationAtBottom()
|
||
},
|
||
onError: (message) => {
|
||
streamWriter.fail(message || 'AI 流式生成失败')
|
||
}
|
||
})
|
||
const content = normalizeGeneratedContent(streamRes.output)
|
||
if (!content) {
|
||
throw new Error('AI 流式输出为空')
|
||
}
|
||
streamWriter.finish(content)
|
||
await streamWriter.waitForDone()
|
||
return await saveGeneratedScriptContent({
|
||
prompt: text,
|
||
content,
|
||
saveTheme,
|
||
conversationId,
|
||
revisionIndex,
|
||
parentScriptId: options.parentScriptId || '',
|
||
revisionOf: options.revisionOf || '',
|
||
updateScriptId
|
||
})
|
||
}
|
||
|
||
const runGeneration = async ({ prompt, displayText, source = 'text', saveTheme }) => {
|
||
const text = String(prompt || '').trim()
|
||
const display = String(displayText || text).trim()
|
||
if (!text || generating.value) return
|
||
|
||
analytics.track('script_wish_submit', {
|
||
source,
|
||
prompt_length: display.length
|
||
}, { eventType: 'script', pagePath })
|
||
|
||
currentMessageTime.value = formatMessageTime()
|
||
generationStartedAt.value = Date.now()
|
||
generating.value = true
|
||
streamContent.value = ''
|
||
novelFullText.value = ''
|
||
novelOutline.value = null
|
||
clarificationCard.value = null
|
||
novelSessionId.value = ''
|
||
firstQuery.value = text // 记住首次心愿,用于后续 followup 保存剧本
|
||
console.log('[ScriptView] 设置 firstQuery:', { length: text.length })
|
||
|
||
// 把原始心愿保存到 store,供 followup 时透传给后端(避免 originalQuery 丢失导致不保存)
|
||
if (store.setSessionMeta) {
|
||
store.setSessionMeta({
|
||
sessionId: '', // 后续在 status 事件中更新
|
||
scriptId: '',
|
||
conversationId: '',
|
||
originalQuery: text,
|
||
style: 'career',
|
||
length: 'short'
|
||
})
|
||
}
|
||
|
||
pendingNextResponse.value = true // 标记等待下一个 assistant 响应
|
||
resumeableSessionId.value = ''
|
||
outlineFeedback.value = ''
|
||
generationDisplayText.value = display
|
||
generationScrollTarget.value = ''
|
||
generationScrollTop.value = 0
|
||
generationScrollAnchor.value = 'generation-stream-anchor-a'
|
||
generationAutoFollow.value = true
|
||
streamWriter.reset()
|
||
startGenerationFeedback()
|
||
ttsPlayer.reset()
|
||
viewState.value = 'chat'
|
||
generationPhase.value = 'generating'
|
||
resultMessages.value = []
|
||
addResultMessage({ role: 'user', kind: 'text', content: display })
|
||
keepResultAtBottom()
|
||
|
||
try {
|
||
currentStreamTask.value = startNovelStream({
|
||
query: text,
|
||
onEvent: handleShortNovelEvent,
|
||
onError: (msg) => {
|
||
markGenerationFailed(msg)
|
||
analytics.track('script_generate_fail', {
|
||
source,
|
||
error: msg
|
||
}, { eventType: 'script', pagePath })
|
||
}
|
||
})
|
||
} catch (error) {
|
||
markGenerationFailed(error.message || '生成失败')
|
||
analytics.track('script_generate_fail', {
|
||
source,
|
||
error: error.message || 'unknown'
|
||
}, { eventType: 'script', pagePath })
|
||
} finally {
|
||
generating.value = false
|
||
}
|
||
}
|
||
|
||
const handleShortNovelEvent = async (event) => {
|
||
const { type, session_id, payload = {} } = event
|
||
const timestamp = Date.now()
|
||
|
||
console.log('[ScriptView] 收到事件:', { type, session_id, timestamp })
|
||
|
||
if (session_id) {
|
||
novelSessionId.value = session_id
|
||
// 把外部 short-novel-service 返回的 session_id 同步到 store,供 followup 透传
|
||
if (store.setSessionMeta && store.getSessionMeta) {
|
||
const meta = store.getSessionMeta()
|
||
if (meta) {
|
||
meta.sessionId = session_id
|
||
store.setSessionMeta(meta)
|
||
}
|
||
}
|
||
}
|
||
|
||
switch (type) {
|
||
case 'status':
|
||
// 仅更新加载提示,不追加独立消息
|
||
generationStatus.value = payload.stage || 'processing'
|
||
break
|
||
case 'clarification_card':
|
||
addResultMessage({ role: 'assistant', kind: 'card', card: payload.card || null })
|
||
generationStatus.value = 'idle'
|
||
pendingNextResponse.value = false
|
||
break
|
||
case 'outline_created':
|
||
addResultMessage({ role: 'assistant', kind: 'outline', outline: payload.outline || null })
|
||
generationStatus.value = 'idle'
|
||
pendingNextResponse.value = false
|
||
break
|
||
case 'novel_start':
|
||
addResultMessage({ role: 'assistant', kind: 'novel', content: '', pending: true })
|
||
generationStatus.value = 'streaming'
|
||
pendingNextResponse.value = false
|
||
break
|
||
case 'novel_delta': {
|
||
// 往最后一条 pending novel 消息直接累加 delta,保证流式显示
|
||
const lastNovel = [...resultMessages.value].reverse().find(m => m.kind === 'novel' && m.pending)
|
||
if (lastNovel) {
|
||
const delta = payload.delta || ''
|
||
console.log('[ScriptView] novel_delta:', {
|
||
deltaLength: delta.length,
|
||
currentLength: lastNovel.content.length,
|
||
timestamp
|
||
})
|
||
lastNovel.content += delta
|
||
}
|
||
keepResultAtBottom()
|
||
break
|
||
}
|
||
case 'novel_done': {
|
||
// 标记最后一条 novel 消息完成
|
||
console.log('[ScriptView] novel_done:', {
|
||
scriptId: payload.scriptId,
|
||
hasFullText: !!payload.full_text,
|
||
timestamp
|
||
})
|
||
const lastNovel = [...resultMessages.value].reverse().find(m => m.kind === 'novel')
|
||
if (lastNovel) {
|
||
if (payload.full_text) lastNovel.content = payload.full_text
|
||
lastNovel.pending = false
|
||
}
|
||
scriptId.value = payload.scriptId || ''
|
||
conversationId.value = payload.conversationId || ''
|
||
currentVersionMessageId.value = payload.currentVersionMessageId || ''
|
||
generationPhase.value = 'done'
|
||
generationStatus.value = 'idle'
|
||
pendingNextResponse.value = false
|
||
persistResultMessages()
|
||
|
||
// 把 scriptId/conversationId 同步到 store,便于详情页/历史页查最新剧本
|
||
if (store.setSessionMeta && store.getSessionMeta) {
|
||
const meta = store.getSessionMeta()
|
||
if (meta) {
|
||
meta.scriptId = scriptId.value
|
||
meta.conversationId = conversationId.value
|
||
store.setSessionMeta(meta)
|
||
}
|
||
}
|
||
|
||
// 可靠刷新剧本列表:使用 await 确保后端返回后再继续,避免列表中看不到最新剧本
|
||
try {
|
||
await store.fetchScripts()
|
||
console.log('[ScriptView] novel_done 后剧本列表刷新成功')
|
||
} catch (e) {
|
||
console.error('[ScriptView] novel_done 后剧本列表刷新失败:', e)
|
||
uni.showToast({ title: '列表刷新失败,请手动刷新', icon: 'none' })
|
||
}
|
||
break
|
||
}
|
||
case 'error':
|
||
// 识别每日限制错误,保存 session_id 供"继续创作"使用
|
||
if (payload.code === 'DAILY_GENERATION_IN_PROGRESS' && session_id) {
|
||
resumeableSessionId.value = session_id
|
||
markGenerationFailed(payload.message || '今天已有未完成的创作,可点击继续创作恢复')
|
||
} else {
|
||
markGenerationFailed(payload.message || '生成失败')
|
||
addResultMessage({ role: 'assistant', kind: 'text', content: payload.message || '生成失败', failed: true })
|
||
}
|
||
break
|
||
}
|
||
}
|
||
|
||
const submitClarification = (answer) => {
|
||
if (answeringClarification.value) return
|
||
// 找最后一张未提交的卡片消息(mp-weixin 模板不支持内联箭头函数传 msg,改为自己查找)
|
||
const card = [...resultMessages.value].reverse().find(m => m.kind === 'card' && !m.submitted)
|
||
if (card) {
|
||
card.submitted = true
|
||
// answer 存用户提交的 value,渲染时通过 card.options 映射成中文 label(与历史回放逻辑一致)
|
||
card.answer = answer
|
||
card.selectedValue = answer
|
||
}
|
||
pendingNextResponse.value = true // 标记等待下一个 assistant 响应
|
||
answeringClarification.value = true
|
||
|
||
// originalQuery 兜底:优先 firstQuery,缺失时从 store 获取,避免 followup 时 originalQuery 为空导致不保存
|
||
const effectiveOriginalQuery = firstQuery.value || (store.getSessionMeta && store.getSessionMeta()?.originalQuery) || ''
|
||
if (!effectiveOriginalQuery) {
|
||
uni.showToast({ title: '原始心愿已丢失,请重新开始', icon: 'none' })
|
||
answeringClarification.value = false
|
||
return
|
||
}
|
||
|
||
console.log('[ScriptView] submitClarification:', {
|
||
sessionId: novelSessionId.value,
|
||
originalQueryLength: effectiveOriginalQuery.length
|
||
})
|
||
currentStreamTask.value = followupStream({
|
||
sessionId: novelSessionId.value,
|
||
action: 'answer_clarification',
|
||
payload: { answer }, // 后端仍收 value
|
||
originalQuery: effectiveOriginalQuery, // 首次心愿文本,用于后续 novel_done 保存
|
||
onEvent: handleShortNovelEvent,
|
||
onError: (errMsg) => markGenerationFailed(errMsg)
|
||
})
|
||
setTimeout(() => { answeringClarification.value = false }, 200)
|
||
}
|
||
|
||
const confirmOutline = (msg) => {
|
||
if (msg) msg.confirmed = true
|
||
pendingNextResponse.value = true
|
||
addResultMessage({ role: 'user', kind: 'text', content: '确认大纲' })
|
||
console.log('[ScriptView] confirmOutline:', {
|
||
sessionId: novelSessionId.value,
|
||
originalQueryLength: firstQuery.value?.length
|
||
})
|
||
currentStreamTask.value = followupStream({
|
||
sessionId: novelSessionId.value,
|
||
action: 'confirm_outline',
|
||
payload: null,
|
||
originalQuery: firstQuery.value,
|
||
onEvent: handleShortNovelEvent,
|
||
onError: (errMsg) => markGenerationFailed(errMsg)
|
||
})
|
||
}
|
||
|
||
const modifyOutline = (msg) => {
|
||
const feedback = msg?.feedback || ''
|
||
if (!feedback.trim()) {
|
||
uni.showToast({ title: '请先填写修改意见', icon: 'none' })
|
||
return
|
||
}
|
||
if (msg) msg.confirmed = true
|
||
pendingNextResponse.value = true
|
||
addResultMessage({ role: 'user', kind: 'text', content: feedback })
|
||
console.log('[ScriptView] modifyOutline:', {
|
||
sessionId: novelSessionId.value,
|
||
originalQueryLength: firstQuery.value?.length
|
||
})
|
||
currentStreamTask.value = followupStream({
|
||
sessionId: novelSessionId.value,
|
||
action: 'modify_outline',
|
||
payload: { feedback },
|
||
originalQuery: firstQuery.value,
|
||
onEvent: handleShortNovelEvent,
|
||
onError: (errMsg) => markGenerationFailed(errMsg)
|
||
})
|
||
}
|
||
|
||
const resumeSession = () => {
|
||
if (!resumeableSessionId.value || generating.value) return
|
||
novelSessionId.value = resumeableSessionId.value
|
||
resumeableSessionId.value = ''
|
||
generating.value = true
|
||
generationPhase.value = 'generating'
|
||
generationStatus.value = 'waiting'
|
||
generationError.value = ''
|
||
addResultMessage({ role: 'user', kind: 'text', content: '继续之前的创作' })
|
||
streamWriter.reset()
|
||
startGenerationFeedback()
|
||
keepResultAtBottom()
|
||
|
||
try {
|
||
console.log('[ScriptView] resumeSession:', {
|
||
sessionId: novelSessionId.value,
|
||
originalQueryLength: firstQuery.value?.length
|
||
})
|
||
currentStreamTask.value = followupStream({
|
||
sessionId: novelSessionId.value,
|
||
action: 'retry',
|
||
payload: null,
|
||
originalQuery: firstQuery.value,
|
||
onEvent: handleShortNovelEvent,
|
||
onError: (errMsg) => {
|
||
markGenerationFailed(errMsg)
|
||
analytics.track('script_generate_fail', {
|
||
source: 'resume',
|
||
error: errMsg
|
||
}, { eventType: 'script', pagePath })
|
||
}
|
||
})
|
||
} catch (error) {
|
||
markGenerationFailed(error.message || '恢复失败')
|
||
} finally {
|
||
generating.value = false
|
||
}
|
||
}
|
||
|
||
const submitWish = async (source = 'text') => {
|
||
const text = wishText.value.trim()
|
||
if (!text || generating.value) return
|
||
await runGeneration({ prompt: text, displayText: text, source, saveTheme: text })
|
||
}
|
||
|
||
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 = ''
|
||
runGeneration({
|
||
prompt: lastGenerationPrompt.value || wishText.value.trim(),
|
||
displayText: wishText.value.trim(),
|
||
source: lastSubmitSource.value || 'text',
|
||
saveTheme: wishText.value.trim(),
|
||
revision: lastGenerationRevision.value
|
||
})
|
||
}
|
||
|
||
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'
|
||
generationPhase.value = 'idle'
|
||
viewState.value = 'home'
|
||
currentResult.value = null
|
||
currentConversationId.value = ''
|
||
resultMessages.value = []
|
||
collapsedMessageIds.value = {}
|
||
resultChatInput.value = ''
|
||
revisionIntent.value = ''
|
||
storyCollapsed.value = false
|
||
ttsPlayer.reset()
|
||
}
|
||
|
||
const toggleStoryCollapse = () => {
|
||
storyCollapsed.value = !storyCollapsed.value
|
||
analytics.track('script_result_story_collapse_toggle', {
|
||
script_id: currentResult.value?.id || '',
|
||
collapsed: storyCollapsed.value
|
||
}, { eventType: 'script', pagePath })
|
||
}
|
||
|
||
const copyResultContent = () => {
|
||
const content = resultContent.value || ''
|
||
if (!content.trim()) {
|
||
uni.showToast({ title: '暂无可复制内容', icon: 'none' })
|
||
return
|
||
}
|
||
uni.setClipboardData({
|
||
data: content,
|
||
success: () => {
|
||
uni.showToast({ title: '已复制全文', icon: 'success' })
|
||
}
|
||
})
|
||
analytics.track('script_result_copy_click', {
|
||
script_id: currentResult.value?.id || '',
|
||
content_length: content.length
|
||
}, { eventType: 'script', pagePath })
|
||
}
|
||
|
||
const continueInChat = () => {
|
||
storyCollapsed.value = true
|
||
resultInputFocus.value = true
|
||
setTimeout(() => {
|
||
resultInputFocus.value = false
|
||
}, 300)
|
||
scrollResultToLatest()
|
||
analytics.track('script_result_continue_click', {
|
||
script_id: currentResult.value?.id || ''
|
||
}, { eventType: 'script', pagePath })
|
||
}
|
||
|
||
const enterRevisionConfirm = (intent, copy) => {
|
||
revisionIntent.value = intent
|
||
resultChatInput.value = ''
|
||
addResultMessage({
|
||
role: 'assistant',
|
||
content: copy
|
||
})
|
||
scrollResultToLatest()
|
||
}
|
||
|
||
const changeDirection = () => {
|
||
analytics.track('script_result_change_direction_click', {
|
||
script_id: currentResult.value?.id || ''
|
||
}, { eventType: 'script', pagePath })
|
||
enterRevisionConfirm('change_direction', '我先不直接重写。你想换成更事业逆袭、更情感治愈,还是更强反转的方向?告诉我一句,我确认后再重新生成。')
|
||
}
|
||
|
||
const notLikeMe = () => {
|
||
analytics.track('script_result_not_like_me_click', {
|
||
script_id: currentResult.value?.id || ''
|
||
}, { eventType: 'script', pagePath })
|
||
enterRevisionConfirm('not_like_me', '收到,我会先帮你确认“不像你”的地方。你可以说哪里不贴近你,比如性格、经历、情绪或结局,我再基于当前故事重写。')
|
||
}
|
||
|
||
const regenerateFromRevision = async (message, source = 'result_chat') => {
|
||
const conversationId = ensureConversationId()
|
||
const prompt = buildRevisionPrompt(message)
|
||
const parentScriptId = getCurrentScriptId()
|
||
const revisionIndex = getScriptVersions().length + 1
|
||
revisionIntent.value = ''
|
||
resultChatInput.value = ''
|
||
resultChatting.value = true
|
||
storyCollapsed.value = false
|
||
resultChatWriter.reset()
|
||
const assistant = addResultMessage({
|
||
role: 'assistant',
|
||
kind: 'script',
|
||
content: '',
|
||
pending: true
|
||
})
|
||
activeAssistantMessageId.value = assistant.id
|
||
|
||
let generatedContent = ''
|
||
try {
|
||
const response = await streamAiScene({
|
||
sceneCode: 'script_generate',
|
||
inputs: {
|
||
prompt,
|
||
style: style.value,
|
||
length: 'medium',
|
||
useSocialInsights: useSocialInsights.value
|
||
},
|
||
onDelta: (_delta, output) => {
|
||
const normalized = normalizeGeneratedContent(output)
|
||
// 避免内容回退时重置导致的闪烁;只推进增量内容
|
||
if (normalized.length >= resultChatWriter.visibleText.value.length) {
|
||
resultChatWriter.push(normalized)
|
||
}
|
||
keepResultAtBottom()
|
||
},
|
||
onDone: (_event, output) => {
|
||
resultChatWriter.finish(normalizeGeneratedContent(output))
|
||
keepResultAtBottom()
|
||
},
|
||
onError: (errorMessage) => {
|
||
resultChatWriter.fail(errorMessage || 'AI 流式生成失败')
|
||
}
|
||
})
|
||
generatedContent = normalizeGeneratedContent(response.output)
|
||
if (!generatedContent) {
|
||
throw new Error('AI 流式输出为空')
|
||
}
|
||
resultChatWriter.finish(generatedContent)
|
||
await resultChatWriter.waitForDone()
|
||
const current = resultMessages.value.find(item => item.id === assistant.id)
|
||
if (current) current.content = generatedContent
|
||
|
||
const saveRes = await saveGeneratedScriptContent({
|
||
prompt,
|
||
content: generatedContent,
|
||
saveTheme: wishText.value || currentResult.value?.theme || currentResult.value?.title || '根据当前故事继续修改',
|
||
conversationId,
|
||
revisionIndex,
|
||
updateScriptId: parentScriptId,
|
||
parentScriptId,
|
||
revisionOf: parentScriptId
|
||
})
|
||
currentResult.value = normalizeGeneratedScript(saveRes.data)
|
||
currentConversationId.value = ensureConversationId(currentResult.value)
|
||
currentResultTime.value = formatMessageTime()
|
||
if (typeof saveRes.data?.remainingCount === 'number') remainingCount.value = saveRes.data.remainingCount
|
||
analytics.track('script_generate_success', {
|
||
source,
|
||
style: currentResult.value.style || '',
|
||
length: currentResult.value.length || '',
|
||
use_social_insights: useSocialInsights.value,
|
||
duration_ms: 0
|
||
}, { eventType: 'script', pagePath })
|
||
} catch (error) {
|
||
resultChatWriter.fail(error?.message || '这次生成没有成功,可以稍后再试。')
|
||
const current = resultMessages.value.find(item => item.id === assistant.id)
|
||
if (current && !current.content) {
|
||
current.content = error?.message || '这次生成没有成功,可以稍后再试。'
|
||
}
|
||
analytics.track('script_generate_fail', {
|
||
source,
|
||
error: error?.message || 'unknown'
|
||
}, { eventType: 'script', pagePath })
|
||
} finally {
|
||
const current = resultMessages.value.find(item => item.id === assistant.id)
|
||
if (current) {
|
||
current.pending = false
|
||
current.kind = generatedContent ? 'script' : ''
|
||
if (!current.content && resultChatWriter.visibleText.value) current.content = resultChatWriter.visibleText.value
|
||
}
|
||
activeAssistantMessageId.value = ''
|
||
resultChatting.value = false
|
||
persistResultMessages()
|
||
keepResultAtBottom()
|
||
}
|
||
}
|
||
|
||
const sendResultChat = async (source = 'text') => {
|
||
const message = resultChatInput.value.trim()
|
||
if (!message || resultChatting.value || generating.value) return
|
||
resultChatInput.value = ''
|
||
addResultMessage({ role: 'user', content: message })
|
||
analytics.track('script_result_chat_send', {
|
||
source,
|
||
script_id: currentResult.value?.id || '',
|
||
has_revision_intent: Boolean(revisionIntent.value),
|
||
message_length: message.length
|
||
}, { eventType: 'script', pagePath })
|
||
|
||
if (revisionIntent.value || shouldRegenerateFromMessage(message)) {
|
||
await regenerateFromRevision(message, source)
|
||
return
|
||
}
|
||
|
||
resultChatting.value = true
|
||
resultChatWriter.reset()
|
||
const assistant = addResultMessage({
|
||
role: 'assistant',
|
||
content: '',
|
||
pending: true
|
||
})
|
||
activeAssistantMessageId.value = assistant.id
|
||
|
||
try {
|
||
const response = await streamAiScene({
|
||
sceneCode: 'script_generate',
|
||
inputs: {
|
||
prompt: buildChatPrompt(message),
|
||
style: style.value,
|
||
length: 'short',
|
||
useSocialInsights: false
|
||
},
|
||
onDelta: (_delta, output) => {
|
||
const text = String(output || '')
|
||
if (text.length >= resultChatWriter.visibleText.value.length) {
|
||
resultChatWriter.push(text)
|
||
}
|
||
keepResultAtBottom()
|
||
},
|
||
onDone: (_event, output) => {
|
||
resultChatWriter.finish(String(output || ''))
|
||
keepResultAtBottom()
|
||
},
|
||
onError: (errorMessage) => {
|
||
resultChatWriter.fail(errorMessage || '暂时没有收到回复')
|
||
}
|
||
})
|
||
const output = response.output?.trim() || '我收到了。你可以继续补充,也可以直接说“确认生成”。'
|
||
resultChatWriter.finish(output)
|
||
await resultChatWriter.waitForDone()
|
||
const current = resultMessages.value.find(item => item.id === assistant.id)
|
||
if (current) current.content = output
|
||
} catch (error) {
|
||
resultChatWriter.fail(error?.message || '这次回复没有成功,可以稍后再试。')
|
||
const current = resultMessages.value.find(item => item.id === assistant.id)
|
||
if (current && !current.content) {
|
||
current.content = error?.message || '这次回复没有成功,可以稍后再试。'
|
||
}
|
||
} finally {
|
||
const current = resultMessages.value.find(item => item.id === assistant.id)
|
||
if (current) {
|
||
current.pending = false
|
||
if (!current.content && resultChatWriter.visibleText.value) {
|
||
current.content = resultChatWriter.visibleText.value
|
||
}
|
||
}
|
||
activeAssistantMessageId.value = ''
|
||
resultChatting.value = false
|
||
persistResultMessages()
|
||
keepResultAtBottom()
|
||
}
|
||
}
|
||
|
||
const trackTtsClick = () => {
|
||
analytics.track('script_result_tts_click', {
|
||
script_id: currentResult.value?.id || ''
|
||
}, { eventType: 'tts', pagePath })
|
||
ttsPlayer.playSource(currentResult.value?.id || '')
|
||
}
|
||
|
||
onMounted(() => {
|
||
analytics.track('script_home_view', {}, { eventType: 'script', pagePath })
|
||
setupRecorder()
|
||
uni.$on('openScriptChat', handleOpenScriptChat)
|
||
pendingOpenTimer = setTimeout(openPendingScriptChat, 300)
|
||
})
|
||
|
||
onUnmounted(() => {
|
||
clearGenerationFeedbackTimers()
|
||
if (generationScrollTimer) {
|
||
clearTimeout(generationScrollTimer)
|
||
generationScrollTimer = null
|
||
}
|
||
if (resultScrollTargetTimer) {
|
||
clearTimeout(resultScrollTargetTimer)
|
||
resultScrollTargetTimer = null
|
||
}
|
||
if (pendingOpenTimer) {
|
||
clearTimeout(pendingOpenTimer)
|
||
pendingOpenTimer = null
|
||
}
|
||
streamWriter.dispose()
|
||
resultChatWriter.dispose()
|
||
uni.$off('openScriptChat', handleOpenScriptChat)
|
||
if (recorderManager && voiceState.value === 'pressing') {
|
||
recordCancelled = true
|
||
recorderManager.stop()
|
||
}
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
.clarifying-view {
|
||
min-height: 100vh;
|
||
padding: 40rpx 32rpx;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.clarifying-scroll {
|
||
height: calc(100vh - 80rpx);
|
||
}
|
||
|
||
.clarifying-content {
|
||
padding: 40rpx 0;
|
||
}
|
||
|
||
.clarifying-intro {
|
||
margin-bottom: 32rpx;
|
||
}
|
||
|
||
.intro-title {
|
||
color: #ffffff;
|
||
font-size: 36rpx;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.outlining-view {
|
||
min-height: 100vh;
|
||
padding: 40rpx 32rpx;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.outlining-scroll {
|
||
height: calc(100vh - 200rpx);
|
||
}
|
||
|
||
.outline-card {
|
||
background: rgba(255, 255, 255, 0.08);
|
||
border-radius: 16rpx;
|
||
padding: 32rpx 28rpx;
|
||
margin-bottom: 32rpx;
|
||
}
|
||
|
||
.outline-title {
|
||
color: #ffffff;
|
||
font-size: 40rpx;
|
||
font-weight: 700;
|
||
margin-bottom: 16rpx;
|
||
}
|
||
|
||
.outline-logline {
|
||
color: rgba(255, 255, 255, 0.85);
|
||
font-size: 28rpx;
|
||
line-height: 1.6;
|
||
margin-bottom: 24rpx;
|
||
}
|
||
|
||
.outline-beats {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 20rpx;
|
||
margin-bottom: 24rpx;
|
||
}
|
||
|
||
.beat-item {
|
||
display: flex;
|
||
gap: 20rpx;
|
||
align-items: flex-start;
|
||
}
|
||
|
||
.beat-order {
|
||
flex-shrink: 0;
|
||
width: 56rpx;
|
||
height: 56rpx;
|
||
background: #087e8b;
|
||
border-radius: 50%;
|
||
color: #ffffff;
|
||
font-size: 28rpx;
|
||
font-weight: 600;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.beat-content {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.beat-title {
|
||
color: #ffffff;
|
||
font-size: 28rpx;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.beat-summary {
|
||
color: rgba(255, 255, 255, 0.7);
|
||
font-size: 26rpx;
|
||
margin-top: 8rpx;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.outline-ending {
|
||
margin-top: 24rpx;
|
||
padding-top: 24rpx;
|
||
border-top: 2rpx solid rgba(255, 255, 255, 0.1);
|
||
}
|
||
|
||
.ending-label {
|
||
color: rgba(255, 255, 255, 0.6);
|
||
font-size: 24rpx;
|
||
display: block;
|
||
margin-bottom: 8rpx;
|
||
}
|
||
|
||
.ending-text {
|
||
color: #ffffff;
|
||
font-size: 28rpx;
|
||
line-height: 1.6;
|
||
}
|
||
|
||
.outline-actions {
|
||
margin-top: 32rpx;
|
||
}
|
||
|
||
.outline-feedback {
|
||
width: 100%;
|
||
min-height: 64rpx;
|
||
max-height: 120rpx;
|
||
padding: 16rpx 20rpx;
|
||
background: rgba(255, 255, 255, 0.05);
|
||
border-radius: 12rpx;
|
||
color: #ffffff;
|
||
font-size: 28rpx;
|
||
line-height: 1.5;
|
||
margin-bottom: 24rpx;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.outline-buttons {
|
||
display: flex;
|
||
gap: 16rpx;
|
||
justify-content: flex-end;
|
||
}
|
||
|
||
.btn-secondary {
|
||
padding: 16rpx 32rpx;
|
||
background: rgba(255, 255, 255, 0.1);
|
||
border-radius: 32rpx;
|
||
color: #ffffff;
|
||
font-size: 28rpx;
|
||
}
|
||
|
||
.btn-primary {
|
||
padding: 16rpx 32rpx;
|
||
background: #087e8b;
|
||
border-radius: 32rpx;
|
||
color: #ffffff;
|
||
font-size: 28rpx;
|
||
}
|
||
.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: -120rpx -140rpx;
|
||
z-index: 0;
|
||
pointer-events: none;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.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: 210rpx;
|
||
right: -120rpx;
|
||
width: 260rpx;
|
||
height: 260rpx;
|
||
opacity: 0.18;
|
||
background:
|
||
radial-gradient(circle at 42% 42%, rgba(209, 138, 255, 0.5), rgba(93, 38, 193, 0.22) 42%, transparent 72%);
|
||
filter: blur(2rpx);
|
||
box-shadow: 0 0 120rpx rgba(168, 85, 247, 0.22);
|
||
animation: planetDrift 9s ease-in-out infinite;
|
||
}
|
||
|
||
.planet-main::after {
|
||
content: '';
|
||
position: absolute;
|
||
left: 14rpx;
|
||
top: 120rpx;
|
||
width: 228rpx;
|
||
height: 30rpx;
|
||
border-radius: 50%;
|
||
border: 3rpx solid rgba(255, 216, 107, 0.08);
|
||
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: 160rpx;
|
||
height: 4rpx;
|
||
border-radius: 999rpx;
|
||
background: linear-gradient(90deg, transparent 0%, rgba(132, 92, 255, 0.12) 18%, rgba(255, 216, 107, 0.34) 58%, rgba(255, 255, 255, 0.95) 100%);
|
||
opacity: 0;
|
||
transform: rotate(22deg);
|
||
animation: meteorFall 5.6s cubic-bezier(0.18, 0.65, 0.42, 1) infinite;
|
||
box-shadow: 0 0 18rpx rgba(255, 216, 107, 0.14);
|
||
}
|
||
|
||
.meteor::after {
|
||
content: '';
|
||
position: absolute;
|
||
right: -5rpx;
|
||
top: 50%;
|
||
width: 10rpx;
|
||
height: 10rpx;
|
||
border-radius: 50%;
|
||
background: rgba(255, 255, 255, 0.96);
|
||
box-shadow:
|
||
0 0 14rpx rgba(255, 255, 255, 0.74),
|
||
0 0 28rpx rgba(255, 216, 107, 0.34);
|
||
transform: translateY(-50%);
|
||
}
|
||
|
||
.meteor-one {
|
||
top: 120rpx;
|
||
left: -190rpx;
|
||
}
|
||
|
||
.meteor-two {
|
||
top: 260rpx;
|
||
left: 46rpx;
|
||
width: 126rpx;
|
||
animation-delay: 1.8s;
|
||
}
|
||
|
||
.meteor-three {
|
||
top: 440rpx;
|
||
left: 420rpx;
|
||
width: 118rpx;
|
||
animation-delay: 3.4s;
|
||
}
|
||
|
||
.meteor-four {
|
||
top: 360rpx;
|
||
left: -210rpx;
|
||
width: 210rpx;
|
||
height: 5rpx;
|
||
opacity: 0;
|
||
animation-duration: 7.2s;
|
||
animation-delay: 4.6s;
|
||
}
|
||
|
||
.meteor-five {
|
||
top: 720rpx;
|
||
left: -180rpx;
|
||
width: 148rpx;
|
||
animation-duration: 6.4s;
|
||
animation-delay: 2.8s;
|
||
}
|
||
|
||
@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);
|
||
}
|
||
|
||
8% {
|
||
opacity: 0.76;
|
||
}
|
||
|
||
32% {
|
||
opacity: 0.18;
|
||
}
|
||
|
||
38% {
|
||
opacity: 0;
|
||
transform: translate3d(660rpx, 270rpx, 0) rotate(22deg);
|
||
}
|
||
|
||
100% {
|
||
opacity: 0;
|
||
transform: translate3d(660rpx, 270rpx, 0) rotate(22deg);
|
||
}
|
||
}
|
||
|
||
@keyframes micPulse {
|
||
0%, 100% {
|
||
opacity: 0.22;
|
||
transform: scale(0.92);
|
||
}
|
||
|
||
50% {
|
||
opacity: 0.48;
|
||
transform: scale(1.02);
|
||
}
|
||
}
|
||
|
||
@keyframes micHaloBreath {
|
||
0%, 100% {
|
||
opacity: 0.76;
|
||
transform: scale(0.94);
|
||
}
|
||
|
||
50% {
|
||
opacity: 1;
|
||
transform: scale(1.04);
|
||
}
|
||
}
|
||
|
||
@keyframes micIdleBreath {
|
||
0%, 100% {
|
||
transform: translateY(0) scale(1);
|
||
}
|
||
|
||
50% {
|
||
transform: translateY(-4rpx) scale(1.015);
|
||
}
|
||
}
|
||
|
||
@keyframes micPressBreath {
|
||
0%, 100% {
|
||
transform: scale(1.055);
|
||
}
|
||
|
||
50% {
|
||
transform: scale(1.085);
|
||
}
|
||
}
|
||
|
||
@keyframes micWavePress {
|
||
0% {
|
||
opacity: 0.58;
|
||
transform: scale(0.82);
|
||
}
|
||
|
||
100% {
|
||
opacity: 0;
|
||
transform: scale(1.22);
|
||
}
|
||
}
|
||
|
||
@keyframes micScan {
|
||
0% {
|
||
background-position: -180rpx 0, 0 0, 0 0;
|
||
}
|
||
|
||
100% {
|
||
background-position: 180rpx 0, 0 0, 0 0;
|
||
}
|
||
}
|
||
|
||
@keyframes micHighlightFloat {
|
||
0%, 100% {
|
||
opacity: 0.24;
|
||
transform: translateY(0);
|
||
}
|
||
|
||
50% {
|
||
opacity: 0.42;
|
||
transform: translateY(8rpx);
|
||
}
|
||
}
|
||
|
||
.wish-home,
|
||
.generation-view,
|
||
.result-page {
|
||
position: relative;
|
||
z-index: 1;
|
||
min-height: 100%;
|
||
display: flex;
|
||
flex-direction: column;
|
||
box-sizing: border-box;
|
||
padding: 4rpx 28rpx 24rpx;
|
||
}
|
||
|
||
.wish-home {
|
||
gap: 36rpx;
|
||
padding-bottom: 28rpx;
|
||
}
|
||
|
||
.result-page {
|
||
height: 100%;
|
||
min-height: 0;
|
||
}
|
||
|
||
.result-view {
|
||
position: relative;
|
||
z-index: 1;
|
||
flex: 1;
|
||
height: 100%;
|
||
min-height: 0;
|
||
}
|
||
|
||
.result-scroll-content {
|
||
min-height: 100%;
|
||
box-sizing: border-box;
|
||
padding: 0 0 220rpx;
|
||
}
|
||
|
||
.result-scroll-top {
|
||
width: 1rpx;
|
||
height: 1rpx;
|
||
}
|
||
|
||
.result-top-actions {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
margin-bottom: 18rpx;
|
||
}
|
||
|
||
.page-close-btn {
|
||
width: 64rpx;
|
||
height: 64rpx;
|
||
line-height: 60rpx;
|
||
padding: 0;
|
||
border-radius: 50%;
|
||
color: rgba(255, 255, 255, 0.92);
|
||
background: rgba(255, 255, 255, 0.06);
|
||
border: 1rpx solid rgba(192, 132, 252, 0.36);
|
||
font-size: 42rpx;
|
||
backdrop-filter: blur(18rpx);
|
||
-webkit-backdrop-filter: blur(18rpx);
|
||
}
|
||
|
||
.page-close-btn::after {
|
||
border: 0;
|
||
}
|
||
|
||
.home-head {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 14rpx;
|
||
}
|
||
|
||
.history-button {
|
||
height: 58rpx;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 12rpx;
|
||
padding: 0 20rpx;
|
||
border-radius: 999rpx;
|
||
color: rgba(232, 204, 255, 0.92);
|
||
font-size: 24rpx;
|
||
font-weight: 800;
|
||
background: rgba(43, 19, 83, 0.46);
|
||
border: 1rpx solid rgba(168, 85, 247, 0.24);
|
||
}
|
||
|
||
.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: 26rpx;
|
||
margin-bottom: 30rpx;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 8rpx;
|
||
text-align: center;
|
||
overflow: visible;
|
||
}
|
||
|
||
.hero-title {
|
||
display: none;
|
||
}
|
||
|
||
.hero-title-new {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 8rpx;
|
||
}
|
||
|
||
.hero-title-line {
|
||
display: flex;
|
||
align-items: baseline;
|
||
justify-content: center;
|
||
white-space: nowrap;
|
||
font-size: 60rpx;
|
||
font-weight: 800;
|
||
line-height: 1.18;
|
||
letter-spacing: 0;
|
||
}
|
||
|
||
.hero-title-tail {
|
||
font-size: 58rpx;
|
||
}
|
||
|
||
.hero-highlight {
|
||
margin: 0 6rpx;
|
||
color: #d18aff;
|
||
text-shadow: 0 0 28rpx rgba(209, 138, 255, 0.52);
|
||
}
|
||
|
||
.orb-wrap {
|
||
position: relative;
|
||
height: 300rpx;
|
||
margin-top: 44rpx;
|
||
margin-bottom: 0;
|
||
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);
|
||
animation: micHaloBreath 3.8s ease-in-out infinite;
|
||
}
|
||
|
||
.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;
|
||
overflow: hidden;
|
||
animation: micIdleBreath 3.2s ease-in-out infinite;
|
||
}
|
||
|
||
.mic-orb::before {
|
||
content: '';
|
||
position: absolute;
|
||
inset: 18rpx;
|
||
border-radius: 50%;
|
||
background:
|
||
radial-gradient(circle at 35% 26%, rgba(255, 255, 255, 0.28), transparent 18%),
|
||
radial-gradient(circle at 50% 62%, rgba(39, 16, 98, 0.18), transparent 48%);
|
||
border: 1rpx solid rgba(255, 255, 255, 0.1);
|
||
}
|
||
|
||
.mic-orb::after {
|
||
content: '';
|
||
position: absolute;
|
||
inset: -18rpx;
|
||
border-radius: 50%;
|
||
border: 3rpx solid rgba(216, 180, 254, 0.12);
|
||
animation: micPulse 2.8s ease-in-out infinite;
|
||
}
|
||
|
||
.mic-orb.pressing {
|
||
animation: micPressBreath 1.1s ease-in-out infinite;
|
||
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.pressing::after {
|
||
border-color: rgba(255, 216, 107, 0.26);
|
||
animation: micWavePress 0.95s ease-out infinite;
|
||
}
|
||
|
||
.mic-orb.recognizing {
|
||
opacity: 0.86;
|
||
}
|
||
|
||
.mic-orb.recognizing::before {
|
||
background:
|
||
linear-gradient(110deg, transparent 0 34%, rgba(255, 255, 255, 0.24) 44%, transparent 56%),
|
||
radial-gradient(circle at 35% 26%, rgba(255, 255, 255, 0.28), transparent 18%),
|
||
radial-gradient(circle at 50% 62%, rgba(39, 16, 98, 0.18), transparent 48%);
|
||
animation: micScan 1.35s linear infinite;
|
||
}
|
||
|
||
.mic-symbol {
|
||
position: relative;
|
||
width: 118rpx;
|
||
height: 156rpx;
|
||
z-index: 2;
|
||
}
|
||
|
||
.mic-head {
|
||
position: relative;
|
||
width: 66rpx;
|
||
height: 106rpx;
|
||
margin: 0 auto;
|
||
border-radius: 999rpx;
|
||
border: 10rpx solid rgba(255, 255, 255, 0.96);
|
||
box-sizing: border-box;
|
||
background: transparent;
|
||
box-shadow:
|
||
inset 0 0 14rpx rgba(255, 255, 255, 0.12),
|
||
0 0 16rpx rgba(255, 255, 255, 0.22);
|
||
}
|
||
|
||
.mic-head::before {
|
||
content: '';
|
||
position: absolute;
|
||
left: 9rpx;
|
||
top: 16rpx;
|
||
width: 8rpx;
|
||
height: 34rpx;
|
||
border-radius: 999rpx;
|
||
background: rgba(255, 255, 255, 0.18);
|
||
animation: micHighlightFloat 2.6s ease-in-out infinite;
|
||
}
|
||
|
||
.mic-symbol::after {
|
||
content: '';
|
||
position: absolute;
|
||
left: 8rpx;
|
||
top: 56rpx;
|
||
width: 102rpx;
|
||
height: 70rpx;
|
||
box-sizing: border-box;
|
||
border-left: 10rpx solid rgba(255, 255, 255, 0.95);
|
||
border-right: 10rpx solid rgba(255, 255, 255, 0.95);
|
||
border-bottom: 10rpx solid rgba(255, 255, 255, 0.95);
|
||
border-radius: 0 0 64rpx 64rpx;
|
||
box-shadow: 0 0 14rpx rgba(255, 255, 255, 0.18);
|
||
}
|
||
|
||
.mic-stem {
|
||
width: 12rpx;
|
||
height: 34rpx;
|
||
margin: -4rpx auto 0;
|
||
border-radius: 999rpx;
|
||
background: rgba(255, 255, 255, 0.96);
|
||
box-shadow: 0 0 12rpx rgba(255, 255, 255, 0.2);
|
||
}
|
||
|
||
.mic-base {
|
||
width: 76rpx;
|
||
height: 12rpx;
|
||
margin: 0 auto;
|
||
border-radius: 999rpx;
|
||
background: rgba(255, 255, 255, 0.96);
|
||
box-shadow: 0 0 12rpx rgba(255, 255, 255, 0.2);
|
||
}
|
||
|
||
.voice-copy {
|
||
margin-top: -2rpx;
|
||
margin-bottom: 46rpx;
|
||
text-align: center;
|
||
font-size: 34rpx;
|
||
font-weight: 500;
|
||
line-height: 56rpx;
|
||
color: rgba(255, 255, 255, 0.92);
|
||
}
|
||
|
||
.wish-input-wrap {
|
||
position: relative;
|
||
min-height: 92rpx;
|
||
margin-top: 0;
|
||
margin-bottom: 6rpx;
|
||
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);
|
||
transition: min-height 0.22s ease, border-radius 0.22s ease, border-color 0.22s ease, box-shadow 0.22s ease;
|
||
}
|
||
|
||
.wish-input-wrap.active {
|
||
border-color: rgba(209, 138, 255, 0.62);
|
||
box-shadow: 0 0 28rpx rgba(168, 85, 247, 0.18);
|
||
}
|
||
|
||
.wish-input-wrap.twoLine {
|
||
min-height: 126rpx;
|
||
align-items: flex-end;
|
||
border-radius: 42rpx;
|
||
}
|
||
|
||
.wish-input-wrap.expanded {
|
||
min-height: 158rpx;
|
||
align-items: flex-end;
|
||
border-radius: 36rpx;
|
||
}
|
||
|
||
.custom-input-placeholder {
|
||
position: absolute;
|
||
z-index: 1;
|
||
color: rgba(216, 180, 254, 0.48);
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
pointer-events: none;
|
||
}
|
||
|
||
.home-input-placeholder {
|
||
left: 28rpx;
|
||
right: 138rpx;
|
||
top: 50%;
|
||
transform: translateY(-50%);
|
||
font-size: 34rpx;
|
||
line-height: 1;
|
||
}
|
||
|
||
.wish-input {
|
||
position: relative;
|
||
z-index: 2;
|
||
flex: 1;
|
||
height: 72rpx;
|
||
min-height: 72rpx;
|
||
max-height: 150rpx;
|
||
padding: 0;
|
||
box-sizing: border-box;
|
||
color: #fff;
|
||
font-size: 34rpx;
|
||
line-height: 72rpx;
|
||
}
|
||
|
||
.wish-input-wrap.twoLine .wish-input,
|
||
.wish-input-wrap.expanded .wish-input {
|
||
height: auto;
|
||
padding: 8rpx 0;
|
||
line-height: 48rpx;
|
||
}
|
||
|
||
.send-button {
|
||
position: relative;
|
||
z-index: 2;
|
||
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;
|
||
}
|
||
|
||
.inspiration-section {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 24rpx;
|
||
margin-top: 4rpx;
|
||
margin-bottom: 8rpx;
|
||
}
|
||
|
||
.section-line {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
}
|
||
|
||
.section-title {
|
||
font-size: 30rpx;
|
||
font-weight: 800;
|
||
}
|
||
|
||
.refresh {
|
||
font-size: 30rpx;
|
||
font-weight: 800;
|
||
color: #e8ccff;
|
||
}
|
||
|
||
.recommend-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
gap: 22rpx 24rpx;
|
||
}
|
||
|
||
.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.72), rgba(95, 29, 184, 0.64));
|
||
color: #fff;
|
||
border: 1rpx solid rgba(209, 138, 255, 0.16);
|
||
backdrop-filter: blur(10rpx);
|
||
-webkit-backdrop-filter: blur(10rpx);
|
||
}
|
||
|
||
.chat-bubble.system {
|
||
align-self: flex-start;
|
||
background: rgba(16, 8, 34, 0.28);
|
||
border: 1rpx solid rgba(192, 132, 252, 0.3);
|
||
color: rgba(255, 255, 255, 0.92);
|
||
backdrop-filter: blur(8rpx);
|
||
-webkit-backdrop-filter: blur(8rpx);
|
||
}
|
||
|
||
.chat-bubble.pending {
|
||
border-color: rgba(255, 216, 107, 0.28);
|
||
}
|
||
|
||
.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);
|
||
}
|
||
|
||
.result-chat-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 18rpx;
|
||
margin-top: 24rpx;
|
||
}
|
||
|
||
.result-scroll-anchor {
|
||
width: 1rpx;
|
||
height: 28rpx;
|
||
}
|
||
|
||
.scroll-jump-group {
|
||
position: absolute;
|
||
right: 22rpx;
|
||
bottom: 258rpx;
|
||
z-index: 13;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12rpx;
|
||
}
|
||
|
||
.scroll-jump-btn {
|
||
width: 86rpx;
|
||
height: 58rpx;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
border-radius: 999rpx;
|
||
color: #e8ccff;
|
||
font-size: 23rpx;
|
||
font-weight: 900;
|
||
background: rgba(43, 19, 83, 0.72);
|
||
border: 1rpx solid rgba(192, 132, 252, 0.34);
|
||
box-shadow: 0 12rpx 34rpx rgba(0, 0, 0, 0.28);
|
||
backdrop-filter: blur(18rpx);
|
||
-webkit-backdrop-filter: blur(18rpx);
|
||
}
|
||
|
||
.result-chat-bar {
|
||
position: absolute;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
z-index: 12;
|
||
min-height: 104rpx;
|
||
display: flex;
|
||
align-items: flex-end;
|
||
gap: 12rpx;
|
||
padding: 14rpx 24rpx 22rpx;
|
||
box-sizing: border-box;
|
||
background: linear-gradient(180deg, rgba(5, 2, 13, 0), rgba(5, 2, 13, 0.9) 30%, rgba(5, 2, 13, 0.96));
|
||
}
|
||
|
||
.chat-voice-btn {
|
||
width: 72rpx;
|
||
height: 68rpx;
|
||
flex-shrink: 0;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
border-radius: 22rpx;
|
||
color: #e8ccff;
|
||
font-size: 24rpx;
|
||
font-weight: 800;
|
||
background: rgba(88, 28, 135, 0.32);
|
||
border: 1rpx solid rgba(192, 132, 252, 0.3);
|
||
}
|
||
|
||
.chat-voice-btn.pressing,
|
||
.chat-voice-btn.recognizing {
|
||
color: #fff;
|
||
background: linear-gradient(145deg, #934dff, #4d1ccb);
|
||
box-shadow: 0 0 30rpx rgba(168, 85, 247, 0.38);
|
||
}
|
||
|
||
.voice-icon {
|
||
font-size: 24rpx;
|
||
line-height: 1;
|
||
}
|
||
|
||
.result-input-shell {
|
||
position: relative;
|
||
flex: 1;
|
||
height: 76rpx;
|
||
min-height: 76rpx;
|
||
max-height: 146rpx;
|
||
box-sizing: border-box;
|
||
border-radius: 28rpx;
|
||
background: rgba(43, 19, 83, 0.72);
|
||
border: 1rpx solid rgba(168, 85, 247, 0.36);
|
||
overflow: hidden;
|
||
}
|
||
|
||
.result-input-shell.twoLine {
|
||
height: auto;
|
||
min-height: 110rpx;
|
||
border-radius: 30rpx;
|
||
}
|
||
|
||
.result-input-shell.expanded {
|
||
height: auto;
|
||
min-height: 144rpx;
|
||
border-radius: 30rpx;
|
||
}
|
||
|
||
.result-input-placeholder {
|
||
left: 22rpx;
|
||
right: 22rpx;
|
||
top: 50%;
|
||
transform: translateY(-50%);
|
||
font-size: 28rpx;
|
||
line-height: 1;
|
||
}
|
||
|
||
.result-chat-input {
|
||
position: relative;
|
||
z-index: 2;
|
||
width: 100%;
|
||
height: 76rpx;
|
||
min-height: 76rpx;
|
||
max-height: 146rpx;
|
||
padding: 0 22rpx;
|
||
box-sizing: border-box;
|
||
color: #fff;
|
||
font-size: 28rpx;
|
||
line-height: 76rpx;
|
||
background: transparent;
|
||
}
|
||
|
||
.result-input-shell.twoLine .result-chat-input,
|
||
.result-input-shell.expanded .result-chat-input {
|
||
height: auto;
|
||
min-height: 78rpx;
|
||
padding: 16rpx 22rpx;
|
||
line-height: 40rpx;
|
||
}
|
||
|
||
.chat-send-btn {
|
||
width: 92rpx;
|
||
height: 76rpx;
|
||
flex-shrink: 0;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
border-radius: 26rpx;
|
||
color: #fff;
|
||
font-size: 26rpx;
|
||
font-weight: 800;
|
||
background: linear-gradient(145deg, #934dff, #4d1ccb);
|
||
}
|
||
|
||
.chat-send-btn.disabled {
|
||
opacity: 0.45;
|
||
}
|
||
|
||
.version-bar {
|
||
display: flex;
|
||
gap: 16rpx;
|
||
margin-bottom: 24rpx;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.version-tag {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8rpx;
|
||
padding: 10rpx 20rpx;
|
||
border-radius: 999rpx;
|
||
background: rgba(43, 19, 83, 0.46);
|
||
border: 1rpx solid rgba(168, 85, 247, 0.24);
|
||
color: rgba(232, 204, 255, 0.72);
|
||
font-size: 26rpx;
|
||
}
|
||
|
||
.version-tag.active {
|
||
background: rgba(140, 68, 242, 0.52);
|
||
border-color: rgba(209, 138, 255, 0.52);
|
||
color: #fff;
|
||
}
|
||
|
||
.version-tag.current {
|
||
border-color: rgba(255, 216, 107, 0.42);
|
||
}
|
||
|
||
.version-current-label {
|
||
font-size: 22rpx;
|
||
color: #ffd86b;
|
||
}
|
||
|
||
.script-read-content {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 28rpx;
|
||
margin-bottom: 32rpx;
|
||
}
|
||
|
||
.script-title {
|
||
font-size: 40rpx;
|
||
font-weight: 800;
|
||
color: #fff;
|
||
text-align: center;
|
||
margin-bottom: 8rpx;
|
||
}
|
||
|
||
.script-section {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12rpx;
|
||
padding: 24rpx;
|
||
border-radius: 28rpx;
|
||
background: rgba(16, 8, 34, 0.28);
|
||
border: 1rpx solid rgba(192, 132, 252, 0.2);
|
||
}
|
||
|
||
.script-section-title {
|
||
font-size: 30rpx;
|
||
font-weight: 700;
|
||
color: #d18aff;
|
||
}
|
||
|
||
.script-section-body {
|
||
font-size: 30rpx;
|
||
line-height: 48rpx;
|
||
color: rgba(255, 255, 255, 0.88);
|
||
white-space: pre-wrap;
|
||
word-break: break-all;
|
||
}
|
||
|
||
.read-action-bar {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 16rpx;
|
||
margin-bottom: 24rpx;
|
||
}
|
||
|
||
.read-action-btn {
|
||
flex: 1;
|
||
min-width: 176rpx;
|
||
height: 72rpx;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
border-radius: 999rpx;
|
||
font-size: 27rpx;
|
||
font-weight: 700;
|
||
color: #1b0b31;
|
||
background: linear-gradient(145deg, #ffd86b, #d18aff);
|
||
box-shadow: 0 16rpx 36rpx rgba(209, 138, 255, 0.24);
|
||
}
|
||
|
||
.read-action-btn.secondary {
|
||
color: #e8ccff;
|
||
background: rgba(43, 19, 83, 0.68);
|
||
border: 1rpx solid rgba(168, 85, 247, 0.32);
|
||
box-shadow: none;
|
||
}
|
||
|
||
.read-action-btn.primary {
|
||
color: #1b0b31;
|
||
background: linear-gradient(145deg, #934dff, #4d1ccb);
|
||
box-shadow: 0 16rpx 36rpx rgba(168, 85, 247, 0.24);
|
||
}
|
||
|
||
.chat-page {
|
||
height: 100%;
|
||
min-height: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
background: #13091f;
|
||
box-sizing: border-box;
|
||
padding-bottom: env(safe-area-inset-bottom);
|
||
}
|
||
|
||
.chat-top-actions {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: 24rpx 32rpx;
|
||
}
|
||
|
||
.chat-scroll {
|
||
position: relative;
|
||
z-index: 1;
|
||
flex: 1;
|
||
height: 100%;
|
||
min-height: 0;
|
||
/* 参考 .result-view:UniApp <scroll-view> 必须显式 height: 100% + min-height: 0 才能正确滚动 */
|
||
/* 仅 flex: 1 在真机上无法让原生 scroll-view 组件计算可滚动区域 */
|
||
}
|
||
|
||
.chat-scroll-content {
|
||
padding: 0 24rpx 40rpx;
|
||
}
|
||
|
||
.card-question-text {
|
||
color: #ffffff;
|
||
font-size: 32rpx;
|
||
font-weight: 600;
|
||
margin-bottom: 16rpx;
|
||
line-height: 1.5;
|
||
display: block;
|
||
}
|
||
|
||
.card-answered {
|
||
color: rgba(255, 255, 255, 0.7);
|
||
font-size: 26rpx;
|
||
padding: 16rpx 0;
|
||
}
|
||
.card-answer-row {
|
||
display: flex;
|
||
align-items: baseline;
|
||
gap: 8rpx;
|
||
margin-bottom: 12rpx;
|
||
}
|
||
.card-answer-prefix {
|
||
font-size: 26rpx;
|
||
color: rgba(255, 255, 255, 0.6);
|
||
}
|
||
.card-answer-label {
|
||
font-size: 28rpx;
|
||
color: rgba(193, 134, 255, 0.95);
|
||
font-weight: 600;
|
||
}
|
||
.card-options-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 10rpx;
|
||
margin-top: 8rpx;
|
||
}
|
||
.card-option-item {
|
||
position: relative;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4rpx;
|
||
padding: 14rpx 18rpx;
|
||
border-radius: 14rpx;
|
||
background: rgba(255, 255, 255, 0.04);
|
||
border: 1rpx solid rgba(255, 255, 255, 0.08);
|
||
}
|
||
.card-option-item.selected {
|
||
background: rgba(168, 85, 247, 0.16);
|
||
border-color: rgba(168, 85, 247, 0.45);
|
||
}
|
||
.card-option-label {
|
||
font-size: 26rpx;
|
||
color: rgba(255, 255, 255, 0.92);
|
||
font-weight: 500;
|
||
}
|
||
.card-option-item.selected .card-option-label {
|
||
color: rgba(220, 195, 255, 1);
|
||
}
|
||
.card-option-desc {
|
||
font-size: 22rpx;
|
||
color: rgba(255, 255, 255, 0.55);
|
||
line-height: 1.5;
|
||
}
|
||
.card-option-mark {
|
||
position: absolute;
|
||
right: 14rpx;
|
||
top: 50%;
|
||
transform: translateY(-50%);
|
||
font-size: 30rpx;
|
||
color: rgba(220, 195, 255, 1);
|
||
font-weight: 700;
|
||
}
|
||
|
||
.outline-title {
|
||
color: #ffffff;
|
||
font-size: 40rpx;
|
||
font-weight: 700;
|
||
margin-bottom: 16rpx;
|
||
}
|
||
|
||
.outline-logline {
|
||
color: rgba(255, 255, 255, 0.85);
|
||
font-size: 28rpx;
|
||
line-height: 1.6;
|
||
margin-bottom: 24rpx;
|
||
}
|
||
|
||
.outline-beats {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 20rpx;
|
||
margin-bottom: 24rpx;
|
||
}
|
||
|
||
.beat-item {
|
||
display: flex;
|
||
gap: 20rpx;
|
||
align-items: flex-start;
|
||
}
|
||
|
||
.beat-order {
|
||
flex-shrink: 0;
|
||
width: 56rpx;
|
||
height: 56rpx;
|
||
background: #087e8b;
|
||
border-radius: 50%;
|
||
color: #ffffff;
|
||
font-size: 28rpx;
|
||
font-weight: 600;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.beat-content {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.beat-title {
|
||
color: #ffffff;
|
||
font-size: 28rpx;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.beat-summary {
|
||
color: rgba(255, 255, 255, 0.7);
|
||
font-size: 26rpx;
|
||
margin-top: 8rpx;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.outline-ending {
|
||
margin-top: 24rpx;
|
||
padding-top: 24rpx;
|
||
border-top: 2rpx solid rgba(255, 255, 255, 0.1);
|
||
}
|
||
|
||
.ending-label {
|
||
color: rgba(255, 255, 255, 0.6);
|
||
font-size: 24rpx;
|
||
display: block;
|
||
margin-bottom: 8rpx;
|
||
}
|
||
|
||
.ending-text {
|
||
color: #ffffff;
|
||
font-size: 28rpx;
|
||
line-height: 1.6;
|
||
}
|
||
|
||
.outline-actions {
|
||
margin-top: 24rpx;
|
||
}
|
||
|
||
.outline-feedback {
|
||
width: 100%;
|
||
min-height: 64rpx;
|
||
max-height: 120rpx;
|
||
padding: 16rpx 20rpx;
|
||
background: rgba(255, 255, 255, 0.05);
|
||
border-radius: 12rpx;
|
||
color: #ffffff;
|
||
font-size: 28rpx;
|
||
line-height: 1.5;
|
||
margin-bottom: 16rpx;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.outline-buttons {
|
||
display: flex;
|
||
gap: 16rpx;
|
||
justify-content: flex-end;
|
||
}
|
||
|
||
.novel-text {
|
||
color: #ffffff;
|
||
font-size: 28rpx;
|
||
line-height: 1.8;
|
||
white-space: pre-wrap;
|
||
}
|
||
|
||
.typing-cursor {
|
||
color: #087e8b;
|
||
animation: blink 900ms steps(1) infinite;
|
||
}
|
||
|
||
.chat-loading {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 16rpx;
|
||
padding: 24rpx 0;
|
||
}
|
||
|
||
.resume-action {
|
||
padding: 24rpx 0;
|
||
display: flex;
|
||
justify-content: center;
|
||
}
|
||
|
||
.chat-input-bar {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 16rpx;
|
||
padding: 16rpx 24rpx;
|
||
background: rgba(255, 255, 255, 0.04);
|
||
border-top: 2rpx solid rgba(255, 255, 255, 0.1);
|
||
}
|
||
|
||
.result-input-shell {
|
||
flex: 1;
|
||
background: rgba(255, 255, 255, 0.06);
|
||
border-radius: 12rpx;
|
||
padding: 8rpx 16rpx;
|
||
}
|
||
|
||
.result-chat-input {
|
||
width: 100%;
|
||
min-height: 60rpx;
|
||
padding: 12rpx 0;
|
||
color: #ffffff;
|
||
font-size: 28rpx;
|
||
}
|
||
|
||
.chat-send-btn {
|
||
height: 60rpx;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 0 32rpx;
|
||
background: #087e8b;
|
||
border-radius: 32rpx;
|
||
color: #ffffff;
|
||
font-size: 28rpx;
|
||
}
|
||
|
||
.chat-send-btn.disabled {
|
||
opacity: 0.5;
|
||
}
|
||
|
||
@keyframes blink {
|
||
50% { opacity: 0; }
|
||
}
|
||
|
||
</style>
|