feat:ScriptDetailView 改造为 chat-bubble 流程视图复刻生成时展示

This commit is contained in:
2026-07-26 15:31:01 +08:00
parent d2a051a938
commit 95a3864250
+204 -252
View File
@@ -10,55 +10,57 @@
</view>
<scroll-view class="scroll" scroll-y :show-scrollbar="false">
<view class="hero-card kos-card">
<text class="eyebrow">PARALLEL LIFE</text>
<text class="script-title">{{ script?.title || '未命名剧本' }}</text>
<text class="script-summary">{{ script?.summary || '命运正在整理章节。' }}</text>
<view v-if="userWish" class="wish-block">
<text class="wish-label">💭 用户心愿</text>
<text class="wish-text">{{ userWish }}</text>
</view>
<view class="stats">
<view class="stat">
<text class="stat-value">{{ script?.style || '爽文' }}</text>
<text class="stat-label">风格</text>
<view class="detail-flow">
<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>
</view>
<view class="stat">
<text class="stat-value">{{ lengthText }}</text>
<text class="stat-label">篇幅</text>
<!-- 澄清问答卡片只读已回答状态 -->
<view v-else-if="msg.kind === 'card'" class="chat-bubble system">
<view class="card-answered">
<text v-if="msg.card?.question" class="card-question-text">{{ msg.card.question }}</text>
<text class="card-answer-text">已回答{{ msg.answer || '未回答' }}</text>
</view>
</view>
<view class="stat">
<text class="stat-value">{{ script?.wordCount || 0 }}</text>
<text class="stat-label">字数</text>
<!-- 大纲beats 列表只读 -->
<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>
<!-- 小说正文Markdown 渲染 -->
<view v-else-if="msg.kind === 'novel'" class="chat-bubble system novel-bubble">
<Markdown :content="msg.content" />
</view>
</view>
</view>
<view class="tabs kos-card">
<view class="tab" :class="{ active: activeTab === 'content' }" @click="activeTab = 'content'">正文</view>
<view class="tab" :class="{ active: activeTab === 'outline' }" @click="activeTab = 'outline'">大纲</view>
</view>
<view v-if="activeTab === 'content'" class="article-card kos-card">
<Markdown :content="fullContent" />
</view>
<view v-else-if="activeTab === 'outline'" class="outline-list">
<!-- 优先展示从后端保存的真实大纲来自 conversation.message.metadata -->
<view v-if="outlineFromMeta && outlineFromMeta.length" v-for="(item, index) in outlineFromMeta" :key="'meta-' + index" class="outline-card kos-card">
<view class="outline-node">{{ index + 1 }}</view>
<view class="outline-body">
<text class="outline-title">{{ item.title }}</text>
<text class="outline-text">{{ item.text }}</text>
</view>
</view>
<!-- fallback 到本地解析 -->
<view v-else v-for="(item, index) in outline" :key="'local-' + index" class="outline-card kos-card">
<view class="outline-node">{{ index + 1 }}</view>
<view class="outline-body">
<text class="outline-title">{{ item.title }}</text>
<text class="outline-text">{{ item.text }}</text>
</view>
<view v-if="resultMessages.length === 0" class="empty-flow">
<text>暂无剧本内容</text>
</view>
</view>
</scroll-view>
@@ -81,49 +83,21 @@ import Markdown from '../../components/Markdown.vue'
import analytics from '../../services/analytics.js'
import { useTtsPlayer } from '../../composables/useTtsPlayer.js'
import { useMenuButtonSafeArea } from '../../composables/useMenuButtonSafeArea.js'
import { listMessagesByConversation } from '../../services/scriptChat.js'
const store = useAppStore()
const activeTab = ref('content')
const scriptId = ref('')
const script = ref(null)
const resultMessages = ref([])
const pagePath = '/pages/main/ScriptDetailView'
const ttsPlayer = useTtsPlayer({ pagePath })
const { capsuleTopReservePx, topbarStyle } = useMenuButtonSafeArea({ extraTopPx: 10 })
const fullContent = computed(() => script.value?.content || '暂无正文内容。')
const lengthText = computed(() => {
const map = { short: '短篇', medium: '中篇', long: '长篇' }
return map[script.value?.length] || script.value?.length || '中篇'
})
/**
* 用户心愿原文(从 EpicScript.theme 字段读取)
*/
const userWish = computed(() => script.value?.theme || '')
/**
* 大纲(从 plotJson.stages.outline 读取后端真实保存的 outline JSON
* 后端 ShortNovelServiceImpl 在 novel_done 时会把 outline_created 事件累积到 metadata.stages.outline
*/
const outlineFromMeta = computed(() => {
const stages = script.value?.plotJson?.stages
if (!stages || !stages.outline) return []
const outline = stages.outline
const items = []
if (outline.title) items.push({ title: '标题', text: outline.title })
if (outline.logline) items.push({ title: '简介', text: outline.logline })
if (Array.isArray(outline.beats)) {
outline.beats.forEach((beat, i) => {
items.push({
title: beat.title || `章节 ${i + 1}`,
text: beat.text || beat.description || JSON.stringify(beat)
})
})
}
if (outline.ending) items.push({ title: '结局', text: outline.ending })
return items
})
const detailTtsActionText = computed(() => {
if (!script.value?.id) return '播放'
if (ttsPlayer.loading.value) return '生成中'
@@ -134,39 +108,63 @@ const detailTtsIcon = computed(() => {
return ttsPlayer.playing.value ? 'Ⅱ' : '▶'
})
const outline = computed(() => {
const text = fullContent.value
const parts = text.split(/\n{2,}/).filter(Boolean)
if (parts.length > 1) {
return parts.slice(0, 8).map((part, index) => {
const clean = part.replace(/[#>*_`]/g, '').trim()
const lines = clean.split('\n').filter(Boolean)
return {
title: lines[0]?.replace(/[【】]/g, '') || `章节 ${index + 1}`,
text: lines.slice(1).join(' ').slice(0, 120) || clean.slice(0, 120)
}
/**
* 从后端 message 列表重建统一对话消息流
* 复刻 ScriptView 生成时的 chat-bubble 展示结构
*/
const rebuildResultMessages = (scriptData, messages) => {
const result = []
// 1. 用户心愿作为首条 user 消息(如果 messages 中没有 user 心愿消息则用 theme 补)
const hasUserWishInMessages = messages.some(m => m.sender === 'user' && m.type === 'chat')
if (!hasUserWishInMessages && scriptData?.theme) {
result.push({
id: `wish-${scriptData.id}`,
role: 'user',
kind: 'text',
content: scriptData.theme
})
}
return [
{ title: '起点', text: script.value?.theme || '从真实的人生经验出发。' },
{ title: '转折', text: '关键机会出现,旧有困境开始松动。' },
{ title: '高光', text: '主角完成一次真正的选择,并看见新的自我。' },
{ title: '回响', text: '剧本落回现实,转化为可执行路径。' }
]
})
// 2. 按 messageOrder 遍历 messages,转换为对应 kind
messages.forEach(m => {
if (m.type === 'clarification_question') {
let card = {}
try { card = JSON.parse(m.content || '{}') } catch (e) { card = {} }
result.push({ id: m.id, role: 'assistant', kind: 'card', card, answer: '', submitted: true })
} else if (m.type === 'clarification_answer') {
const lastCard = [...result].reverse().find(x => x.kind === 'card')
if (lastCard) {
lastCard.answer = m.content || '未回答'
} else {
// 无对应 question,作为独立 user 消息
result.push({ id: m.id, role: 'user', kind: 'text', content: m.content || '' })
}
} else if (m.type === 'outline') {
let outline = {}
try { outline = JSON.parse(m.content || '{}') } catch (e) { outline = {} }
result.push({ id: m.id, role: 'assistant', kind: 'outline', outline })
} else if (m.type === 'script') {
result.push({ id: m.id, role: 'assistant', kind: 'novel', content: m.content || '' })
} else if (m.type === 'system') {
// 跳过系统欢迎消息,不展示
} else if (m.type === 'chat' && m.sender === 'user') {
result.push({ id: m.id, role: 'user', kind: 'text', content: m.content || '' })
}
})
const loadScript = async () => {
if (!scriptId.value) return
ttsPlayer.reset()
script.value = store.getScriptById(scriptId.value)
if (!script.value) {
await store.fetchScripts()
script.value = store.getScriptById(scriptId.value)
}
if (script.value?.id) {
ttsPlayer.prewarmSource(script.value.id)
// 3. 如果 messages 中没有 script 类型消息,fallback 到 plotJson.fullContent
const hasNovel = result.some(m => m.kind === 'novel')
if (!hasNovel && scriptData?.plotJson?.fullContent) {
result.push({
id: `fallback-novel-${scriptData.id}`,
role: 'assistant',
kind: 'novel',
content: scriptData.plotJson.fullContent
})
}
return result
}
const continueCurrent = () => {
@@ -194,9 +192,32 @@ const goBack = () => {
}
onMounted(async () => {
// scriptId 从路由参数获取
const pages = getCurrentPages()
scriptId.value = pages[pages.length - 1]?.options?.id || ''
await loadScript()
const currentPage = pages[pages.length - 1]
scriptId.value = currentPage?.options?.id || currentPage?.$page?.options?.id || ''
// 1. 加载 script 数据
await store.fetchScripts()
script.value = store.getScriptById(scriptId.value)
// 2. 加载 conversation messages
const conversationId = script.value?.conversationId
if (conversationId) {
try {
const res = await listMessagesByConversation({ conversationId, includeVersions: false })
const messages = Array.isArray(res?.data) ? res.data : []
resultMessages.value = rebuildResultMessages(script.value, messages)
} catch (e) {
console.error('[ScriptDetailView] 加载 messages 失败:', e)
// 降级:仅展示心愿 + 正文
resultMessages.value = rebuildResultMessages(script.value, [])
}
} else {
// 无 conversationId,降级展示
resultMessages.value = rebuildResultMessages(script.value, [])
}
analytics.trackPageView(pagePath, { script_id: scriptId.value })
analytics.track('script_detail_view', {
script_id: scriptId.value,
@@ -283,168 +304,99 @@ onUnmounted(() => {
padding: 0 30rpx;
}
.hero-card {
border-radius: 32rpx;
padding: 36rpx 30rpx;
/* chat-bubble 流程视图样式 */
.detail-flow {
padding: 20rpx 0 160rpx;
}
.eyebrow {
display: block;
color: #c186ff;
font-size: 18rpx;
letter-spacing: 6rpx;
.chat-bubble {
margin-bottom: 24rpx;
padding: 24rpx;
border-radius: 24rpx;
}
.script-title {
display: block;
margin-top: 14rpx;
color: #fff;
font-size: 44rpx;
line-height: 1.18;
font-weight: 900;
.chat-bubble.user {
background: rgba(168, 85, 247, 0.12);
align-self: flex-end;
margin-left: 80rpx;
}
.script-summary {
display: block;
margin-top: 18rpx;
color: rgba(226, 216, 246, 0.7);
font-size: 25rpx;
line-height: 1.62;
}
.wish-block {
margin-top: 24rpx;
padding: 20rpx 22rpx;
border-radius: 22rpx;
background: linear-gradient(135deg, rgba(168, 85, 247, 0.16), rgba(232, 121, 249, 0.08));
border: 1rpx solid rgba(168, 85, 247, 0.24);
}
.wish-label {
display: block;
font-size: 22rpx;
color: #c186ff;
font-weight: 700;
letter-spacing: 2rpx;
margin-bottom: 8rpx;
}
.wish-text {
display: block;
font-size: 26rpx;
line-height: 1.62;
color: rgba(255, 255, 255, 0.92);
}
.stats {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16rpx;
margin-top: 28rpx;
}
.stat {
padding: 18rpx 10rpx;
border-radius: 20rpx;
text-align: center;
.chat-bubble.system {
background: rgba(255, 255, 255, 0.06);
margin-right: 40rpx;
}
.stat-value,
.stat-label {
display: block;
}
.stat-value {
color: #fff;
font-size: 24rpx;
font-weight: 800;
}
.stat-label {
margin-top: 6rpx;
color: rgba(219, 207, 243, 0.58);
font-size: 19rpx;
}
.tabs {
height: 76rpx;
margin-top: 24rpx;
border-radius: 999rpx;
padding: 6rpx;
display: grid;
grid-template-columns: 1fr 1fr;
}
.tab {
border-radius: 999rpx;
display: flex;
align-items: center;
justify-content: center;
color: rgba(224, 214, 243, 0.64);
font-size: 26rpx;
font-weight: 700;
}
.tab.active {
color: #fff;
background: linear-gradient(135deg, #a63cff, #6530ff);
box-shadow: 0 0 26rpx rgba(162, 71, 255, 0.48);
}
.article-card {
margin: 24rpx 0 34rpx;
border-radius: 30rpx;
padding: 30rpx;
}
.outline-list {
.card-answered {
display: flex;
flex-direction: column;
gap: 18rpx;
margin: 24rpx 0 34rpx;
gap: 12rpx;
}
.outline-card {
border-radius: 26rpx;
padding: 26rpx;
display: flex;
gap: 20rpx;
.card-question-text {
font-size: 28rpx;
color: rgba(255, 255, 255, 0.9);
font-weight: 500;
}
.outline-node {
width: 48rpx;
height: 48rpx;
flex-shrink: 0;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 22rpx;
font-weight: 900;
background: linear-gradient(135deg, #a855ff, #4f46e5);
box-shadow: 0 0 22rpx rgba(168, 85, 255, 0.48);
.card-answer-text {
font-size: 26rpx;
color: rgba(193, 134, 255, 0.88);
}
.outline-body {
flex: 1;
min-width: 0;
}
.outline-title {
display: block;
font-size: 32rpx;
font-weight: 600;
color: #fff;
font-size: 27rpx;
font-weight: 900;
margin-bottom: 12rpx;
}
.outline-text {
display: block;
margin-top: 10rpx;
color: rgba(223, 211, 245, 0.7);
.outline-logline {
font-size: 26rpx;
color: rgba(255, 255, 255, 0.7);
margin-bottom: 16rpx;
}
.outline-beats {
display: flex;
flex-direction: column;
gap: 16rpx;
}
.beat-item {
display: flex;
gap: 16rpx;
}
.beat-order {
font-size: 24rpx;
line-height: 1.6;
color: rgba(193, 134, 255, 0.88);
width: 40rpx;
}
.beat-content {
flex: 1;
display: flex;
flex-direction: column;
gap: 6rpx;
}
.beat-title {
font-size: 28rpx;
color: rgba(255, 255, 255, 0.9);
}
.beat-summary {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.6);
}
.outline-ending {
margin-top: 16rpx;
padding-top: 16rpx;
border-top: 1rpx solid rgba(255, 255, 255, 0.1);
}
.ending-label {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.5);
margin-right: 12rpx;
}
.ending-text {
font-size: 26rpx;
color: rgba(255, 255, 255, 0.8);
}
.novel-bubble {
padding: 32rpx;
}
.empty-flow {
padding: 80rpx 0;
text-align: center;
color: rgba(255, 255, 255, 0.4);
font-size: 28rpx;
}
.bottom-actions {