Files
happy-life-star/mini-program/src/pages/main/PathView.vue
T

383 lines
10 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view class="path-view" :style="{ paddingTop: capsuleTopReservePx + 'px' }">
<text class="page-title">实现路径</text>
<view v-if="!selectedScript" class="empty-state glass-card">
<text class="empty-icon">🗺</text>
<text class="empty-text">先生成剧本方能洞察路径</text>
<button class="btn-secondary empty-btn" @click="goToScript">去生成剧本</button>
</view>
<view v-else-if="!currentPath" class="empty-state glass-card">
<text class="empty-icon">🎯</text>
<text class="empty-text">{{ pathEmptyText }}</text>
</view>
<view v-else class="path-content">
<view class="target-card glass-card-gold">
<text class="target-label">目标{{ currentPath.title }}</text>
<text class="target-summary">{{ (currentPath.description || currentPath.summary || currentPath.content || '').slice(0, 80) }}...</text>
</view>
<view class="timeline">
<view class="timeline-line"></view>
<view
v-for="(step, index) in currentPath.steps"
:key="index"
class="timeline-item"
>
<view class="timeline-node" :class="step.done ? 'completed' : 'pending'"></view>
<view class="step-card glass-card" :class="{ done: step.done }">
<text class="step-phase">节点 {{ index + 1 }}</text>
<text class="step-task">{{ step.task }}</text>
<text class="step-desc">{{ step.desc }}</text>
</view>
</view>
</view>
</view>
</view>
</template>
<script setup>
import { computed, ref, onMounted, onUnmounted, watch } from 'vue'
import { useAppStore } from '../../stores/app.js'
import { useTypewriterStream } from '../../composables/useTypewriterStream.js'
import { useMenuButtonSafeArea } from '../../composables/useMenuButtonSafeArea.js'
import * as lifePathService from '../../services/lifePath.js'
import { streamAiScene } from '../../services/aiRuntime.js'
const store = useAppStore()
const { capsuleTopReservePx } = useMenuButtonSafeArea({ extraTopPx: 10 })
const pathData = ref(null)
const pathGenerating = ref(false)
const pathWriter = useTypewriterStream({ interval: 32, step: 1 })
const selectedScript = computed(() => {
return store.scripts.find(s => s.isSelected)
})
const currentPath = computed(() => {
if (pathData.value) return pathData.value
if (store.currentPath) return store.currentPath
return null
})
const pathEmptyText = computed(() => {
if (pathWriter.isWaiting.value) return '正在分析剧本,拆解可执行路径...'
if (pathWriter.isStreaming.value || pathWriter.isDraining.value) return '正在生成路径节点...'
return '等待开启人生导航...'
})
const buildStreamPath = (scriptId, title, output) => ({
id: `stream-${scriptId}`,
scriptId,
title,
description: output,
steps: output.split('\n').filter(Boolean).slice(0, 6).map((line, index) => ({
phase: `阶段${index + 1}`,
task: line.replace(/^\d+[.、]\s*/, '').slice(0, 28),
desc: line,
content: line,
done: index === 0
})),
progress: 8,
status: 'active'
})
watch(() => pathWriter.visibleText.value, (text) => {
if (!(pathGenerating.value || pathWriter.isStreaming.value || pathWriter.isDraining.value) || !selectedScript.value?.id || !text) return
const title = selectedScript.value.title ? `${selectedScript.value.title} · 实现路径` : '我的实现路径'
pathData.value = buildStreamPath(selectedScript.value.id, title, text)
})
const loadPath = async (scriptId) => {
if (!scriptId) return
try {
const res = await lifePathService.getPathByScriptId(scriptId)
pathData.value = res.data || await createPlaceholderPath(scriptId)
store.setCurrentPath(pathData.value)
} catch (error) {
pathData.value = await createPlaceholderPath(scriptId)
store.setCurrentPath(pathData.value)
}
}
const createPlaceholderPath = async (scriptId) => {
const script = selectedScript.value || {}
const title = script.title ? `${script.title} · 实现路径` : '我的实现路径'
let generatedText = ''
pathGenerating.value = true
pathWriter.reset()
try {
const streamRes = await streamAiScene({
sceneCode: 'life_healing',
inputs: {
mode: 'path_generate',
prompt: `请把下面的人生剧本拆解成现实中可执行的路径,按阶段输出。\n\n${script.content || script.summary || ''}`,
script: script.content || script.summary || ''
},
onDelta: (_delta, output) => {
generatedText = output
pathWriter.push(output)
},
onDone: (_event, output) => {
pathWriter.finish(output)
},
onError: (message) => {
pathWriter.fail(message || '路径生成失败')
}
})
generatedText = streamRes.output || generatedText
pathWriter.finish(generatedText)
await pathWriter.waitForDone()
} catch (error) {
pathWriter.fail(error?.message || '路径生成失败')
generatedText = ''
} finally {
pathGenerating.value = false
}
const steps = [
{ phase: '阶段1', task: '整理目标', desc: '把剧本中的关键目标拆成可以执行的小目标。', content: '把剧本中的关键目标拆成可以执行的小目标。', done: true },
{ phase: '阶段2', task: '建立习惯', desc: '选择一个最小行动,每天稳定推进。', content: '选择一个最小行动,每天稳定推进。', done: false },
{ phase: '阶段3', task: '复盘迭代', desc: '每周回看进展,根据现实反馈调整路径。', content: '每周回看进展,根据现实反馈调整路径。', done: false }
]
if (generatedText) {
const generatedSteps = generatedText.split('\n').filter(Boolean).slice(0, 6).map((line, index) => ({
phase: `阶段${index + 1}`,
task: line.replace(/^\d+[.、]\s*/, '').slice(0, 28),
desc: line,
content: line,
done: index === 0
}))
if (generatedSteps.length) {
steps.splice(0, steps.length, ...generatedSteps)
}
}
try {
const res = await lifePathService.createPath({
scriptId,
title,
description: generatedText || script.summary || '根据选中的人生剧本生成的占位实现路径,后续可接入AI生成更细的行动计划。',
steps,
progress: 8,
status: 'active'
})
return lifePathService.transformToFrontendFormat(res.data)
} catch (error) {
return {
id: `local-${scriptId}`,
scriptId,
title,
description: script.summary || '占位实现路径',
steps,
progress: 8,
status: 'active'
}
}
}
const goToScript = () => {
uni.$emit('switchTab', 'script')
}
watch(selectedScript, (val) => {
if (val?.id) {
loadPath(val.id)
} else {
pathData.value = null
}
})
onMounted(() => {
if (selectedScript.value?.id) {
loadPath(selectedScript.value.id)
}
})
onUnmounted(() => {
pathWriter.dispose()
})
</script>
<style scoped>
.path-view {
display: flex;
flex-direction: column;
gap: 32rpx;
min-height: 100%;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
.page-title {
font-size: 36rpx;
font-weight: 400;
color: rgba(255, 255, 255, 0.9);
margin-bottom: 8rpx;
letter-spacing: 4rpx;
font-family: 'Cinzel', 'Inter', serif;
}
.empty-state {
padding: 120rpx 64rpx;
display: flex;
flex-direction: column;
align-items: center;
gap: 32rpx;
}
.empty-icon {
font-size: 80rpx;
opacity: 0.3;
}
.empty-text {
font-size: 24rpx;
color: rgba(192, 132, 252, 0.5);
font-style: italic;
text-align: center;
line-height: 1.6;
}
.empty-btn {
font-size: 22rpx;
padding: 18rpx 36rpx;
}
.path-content {
display: flex;
flex-direction: column;
gap: 48rpx;
}
.target-card {
padding: 40rpx;
margin-bottom: 16rpx;
/* 金色玻璃态 + 左侧紫色边框 */
border-left: 4rpx solid #C084FC;
box-shadow: inset 0 0 20rpx rgba(168, 85, 247, 0.05),
0 4rpx 16rpx rgba(168, 85, 247, 0.1);
}
.target-label {
display: block;
font-size: 18rpx;
color: #C084FC;
font-weight: 600;
letter-spacing: 4rpx;
text-transform: uppercase;
margin-bottom: 16rpx;
}
.target-summary {
display: block;
font-size: 28rpx;
color: rgba(255, 255, 255, 0.9);
line-height: 1.6;
}
.timeline {
position: relative;
padding-left: 60rpx;
display: flex;
flex-direction: column;
gap: 48rpx;
}
.timeline-line {
position: absolute;
left: 30rpx;
top: 30rpx;
bottom: 30rpx;
width: 4rpx;
background: linear-gradient(180deg,
rgba(168, 85, 247, 0.4) 0%,
rgba(168, 85, 247, 0.2) 50%,
rgba(168, 85, 247, 0.05) 100%
);
border-radius: 4rpx;
box-shadow: 0 0 15px rgba(168, 85, 247, 0.2);
}
.timeline-item {
position: relative;
}
.timeline-node {
position: absolute;
left: -46rpx;
top: 24rpx;
width: 48rpx;
height: 48rpx;
border-radius: 50%;
background: linear-gradient(135deg, #A855F7 0%, #9333EA 100%);
border: 4rpx solid #C084FC;
/* 原型标准:节点发光 */
box-shadow: 0 0 15px rgba(168, 85, 247, 0.4),
inset 0 0 10rpx rgba(255, 255, 255, 0.2);
z-index: 2;
display: flex;
align-items: center;
justify-content: center;
}
.timeline-node.completed {
animation: node-pulse 2s ease-in-out infinite;
}
@keyframes node-pulse {
0%, 100% {
box-shadow: 0 0 15px rgba(168, 85, 247, 0.4),
inset 0 0 10rpx rgba(255, 255, 255, 0.2);
transform: scale(1);
}
50% {
box-shadow: 0 0 25px rgba(168, 85, 247, 0.6),
inset 0 0 15rpx rgba(255, 255, 255, 0.3);
transform: scale(1.05);
}
}
.timeline-node.pending {
background: #0F071A;
border-color: rgba(255, 255, 255, 0.1);
box-shadow: 0 0 10px rgba(168, 85, 247, 0.1);
}
.step-card {
padding: 32rpx;
opacity: 0.6;
}
.step-card.done {
opacity: 1;
border-color: rgba(192, 132, 252, 0.3);
}
.step-phase {
display: block;
font-size: 18rpx;
color: #C084FC;
font-weight: 600;
letter-spacing: 4rpx;
margin-bottom: 12rpx;
}
.step-task {
display: block;
font-size: 30rpx;
font-weight: 500;
color: rgba(255, 255, 255, 0.95);
margin-bottom: 12rpx;
}
.step-desc {
display: block;
font-size: 22rpx;
color: rgba(255, 255, 255, 0.5);
line-height: 1.5;
}
</style>