feat:ScriptDetailView 改造为 chat-bubble 流程视图复刻生成时展示
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user