feat: 小程序脚本首页重构 + 社交数据导入 + TTS 播放优化
- 后端:新增社交数据导入/审批/洞察生成 API(SocialContent/SocialInsight) - 后端:优化脚本上下文服务,TTS 服务增强 - 小程序:重构脚本首页布局,新增社交导入页面 - 小程序:新增 useTtsPlayer composable,移除旧 ScriptAudioPlayer 组件 - 小程序:新增社交导入服务,优化请求服务 - SQL:新增社交数据导入建表脚本 - 文档:补充设计文档和实施计划 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -1,319 +0,0 @@
|
||||
<template>
|
||||
<view class="script-audio-player">
|
||||
<button
|
||||
class="audio-button"
|
||||
:class="{ playing, failed: task?.status === 'failed' }"
|
||||
:disabled="loading"
|
||||
@click="handleClick"
|
||||
>
|
||||
<text class="audio-icon">{{ iconText }}</text>
|
||||
<text class="audio-label">{{ buttonText }}</text>
|
||||
</button>
|
||||
<text v-if="statusText" class="audio-status">{{ statusText }}</text>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onUnmounted, ref, watch } from 'vue'
|
||||
import { createTtsTask, getTtsTask, getTtsTaskBySource } from '../services/tts.js'
|
||||
|
||||
const props = defineProps({
|
||||
scriptId: { type: String, required: true }
|
||||
})
|
||||
|
||||
const PAGE_PATH = '/pages/main/ScriptDetailView'
|
||||
const analyticsModules = import.meta.glob('../services/analytics.js', { eager: true })
|
||||
const analyticsService = analyticsModules['../services/analytics.js']?.default || analyticsModules['../services/analytics.js']
|
||||
|
||||
const task = ref(null)
|
||||
const loading = ref(false)
|
||||
const playing = ref(false)
|
||||
const statusText = ref('')
|
||||
let audio = null
|
||||
let timer = null
|
||||
|
||||
const readResponseData = (response) => response?.data ?? response ?? null
|
||||
|
||||
const safeTrack = (eventName, payload = {}) => {
|
||||
const uniAnalytics = typeof uni !== 'undefined' ? uni.$analytics : null
|
||||
const analytics = analyticsService || globalThis?.analytics || uniAnalytics
|
||||
const track = analytics?.track
|
||||
if (typeof track !== 'function') return
|
||||
|
||||
try {
|
||||
track(eventName, payload, { eventType: 'tts', pagePath: PAGE_PATH })
|
||||
} catch (error) {
|
||||
console.warn('[TTS] analytics track failed', error)
|
||||
}
|
||||
}
|
||||
|
||||
const buttonText = computed(() => {
|
||||
if (loading.value) return '正在生成'
|
||||
if (task.value?.status === 'success') return playing.value ? '暂停朗读' : '播放朗读'
|
||||
if (task.value?.status === 'failed') return '重试朗读'
|
||||
return '生成朗读'
|
||||
})
|
||||
|
||||
const iconText = computed(() => {
|
||||
if (loading.value) return '...'
|
||||
if (task.value?.status === 'success') return playing.value ? '||' : '>'
|
||||
if (task.value?.status === 'failed') return '!'
|
||||
return '+'
|
||||
})
|
||||
|
||||
const clearTimer = () => {
|
||||
if (timer) clearInterval(timer)
|
||||
timer = null
|
||||
}
|
||||
|
||||
const stopAudio = () => {
|
||||
if (!audio) return
|
||||
audio.stop()
|
||||
audio.destroy()
|
||||
audio = null
|
||||
playing.value = false
|
||||
}
|
||||
|
||||
const setTask = (nextTask) => {
|
||||
task.value = nextTask
|
||||
if (audio && nextTask?.audioUrl && audio.src !== nextTask.audioUrl) {
|
||||
stopAudio()
|
||||
}
|
||||
}
|
||||
|
||||
const markFailed = (message) => {
|
||||
loading.value = false
|
||||
statusText.value = message || '朗读暂时不可用'
|
||||
uni.showToast({ title: statusText.value, icon: 'none' })
|
||||
}
|
||||
|
||||
const pollTask = (id) => {
|
||||
clearTimer()
|
||||
timer = setInterval(async () => {
|
||||
try {
|
||||
const response = await getTtsTask(id)
|
||||
const nextTask = readResponseData(response)
|
||||
setTask(nextTask)
|
||||
|
||||
if (nextTask?.status === 'success' || nextTask?.status === 'failed') {
|
||||
loading.value = false
|
||||
clearTimer()
|
||||
statusText.value = nextTask.status === 'failed' ? (nextTask.errorMessage || '朗读生成失败') : ''
|
||||
safeTrack(nextTask.status === 'success' ? 'script_tts_success' : 'script_tts_error', {
|
||||
script_id: props.scriptId,
|
||||
task_id: id,
|
||||
error: nextTask?.errorMessage || ''
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
clearTimer()
|
||||
safeTrack('script_tts_error', {
|
||||
script_id: props.scriptId,
|
||||
task_id: id,
|
||||
error: error?.message || error?.errMsg || 'poll failed'
|
||||
})
|
||||
markFailed('朗读状态获取失败')
|
||||
}
|
||||
}, 2500)
|
||||
}
|
||||
|
||||
const generate = async () => {
|
||||
loading.value = true
|
||||
statusText.value = ''
|
||||
safeTrack('script_tts_request', { script_id: props.scriptId })
|
||||
|
||||
try {
|
||||
const response = await createTtsTask({ sourceId: props.scriptId })
|
||||
const nextTask = readResponseData(response)
|
||||
setTask(nextTask)
|
||||
|
||||
if (nextTask?.status === 'success') {
|
||||
loading.value = false
|
||||
safeTrack('script_tts_success', {
|
||||
script_id: props.scriptId,
|
||||
task_id: nextTask.id
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (nextTask?.status === 'failed') {
|
||||
safeTrack('script_tts_error', {
|
||||
script_id: props.scriptId,
|
||||
task_id: nextTask.id,
|
||||
error: nextTask.errorMessage || ''
|
||||
})
|
||||
markFailed(nextTask.errorMessage || '朗读生成失败')
|
||||
return
|
||||
}
|
||||
|
||||
if (nextTask?.id) {
|
||||
pollTask(nextTask.id)
|
||||
return
|
||||
}
|
||||
|
||||
markFailed('朗读任务创建失败')
|
||||
} catch (error) {
|
||||
safeTrack('script_tts_error', {
|
||||
script_id: props.scriptId,
|
||||
error: error?.message || error?.errMsg || 'create failed'
|
||||
})
|
||||
markFailed('朗读任务创建失败')
|
||||
}
|
||||
}
|
||||
|
||||
const play = () => {
|
||||
if (!task.value?.audioUrl) return
|
||||
|
||||
if (!audio) {
|
||||
audio = uni.createInnerAudioContext()
|
||||
audio.src = task.value.audioUrl
|
||||
audio.autoplay = false
|
||||
|
||||
audio.onPlay(() => {
|
||||
playing.value = true
|
||||
safeTrack('script_tts_play', {
|
||||
script_id: props.scriptId,
|
||||
task_id: task.value?.id
|
||||
})
|
||||
})
|
||||
|
||||
audio.onPause(() => {
|
||||
playing.value = false
|
||||
safeTrack('script_tts_pause', {
|
||||
script_id: props.scriptId,
|
||||
task_id: task.value?.id
|
||||
})
|
||||
})
|
||||
|
||||
audio.onEnded(() => {
|
||||
playing.value = false
|
||||
safeTrack('script_tts_complete', {
|
||||
script_id: props.scriptId,
|
||||
task_id: task.value?.id
|
||||
})
|
||||
})
|
||||
|
||||
audio.onError((error) => {
|
||||
playing.value = false
|
||||
safeTrack('script_tts_error', {
|
||||
script_id: props.scriptId,
|
||||
task_id: task.value?.id,
|
||||
error: error?.errMsg || 'play failed'
|
||||
})
|
||||
markFailed('音频播放失败')
|
||||
})
|
||||
}
|
||||
|
||||
if (playing.value) {
|
||||
audio.pause()
|
||||
} else {
|
||||
audio.play()
|
||||
}
|
||||
}
|
||||
|
||||
const handleClick = async () => {
|
||||
if (loading.value) return
|
||||
|
||||
if (task.value?.status === 'success') {
|
||||
play()
|
||||
return
|
||||
}
|
||||
|
||||
await generate()
|
||||
}
|
||||
|
||||
const loadExisting = async () => {
|
||||
clearTimer()
|
||||
stopAudio()
|
||||
setTask(null)
|
||||
statusText.value = ''
|
||||
|
||||
if (!props.scriptId) return
|
||||
|
||||
try {
|
||||
const response = await getTtsTaskBySource({ sourceId: props.scriptId })
|
||||
const existingTask = readResponseData(response)
|
||||
setTask(existingTask)
|
||||
|
||||
if (existingTask?.status === 'pending' || existingTask?.status === 'processing') {
|
||||
loading.value = true
|
||||
pollTask(existingTask.id)
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[TTS] existing task lookup failed', error)
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.scriptId, loadExisting, { immediate: true })
|
||||
|
||||
onUnmounted(() => {
|
||||
clearTimer()
|
||||
stopAudio()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.script-audio-player {
|
||||
margin-top: 24rpx;
|
||||
}
|
||||
|
||||
.audio-button {
|
||||
height: 76rpx;
|
||||
border-radius: 999rpx;
|
||||
padding: 0 26rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 14rpx;
|
||||
color: #fff;
|
||||
font-size: 25rpx;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
background: linear-gradient(135deg, #24c6dc, #7f5af0);
|
||||
box-shadow: 0 12rpx 30rpx rgba(36, 198, 220, 0.22);
|
||||
}
|
||||
|
||||
.audio-button::after {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.audio-button[disabled] {
|
||||
color: rgba(255, 255, 255, 0.78);
|
||||
opacity: 0.78;
|
||||
}
|
||||
|
||||
.audio-button.playing {
|
||||
background: linear-gradient(135deg, #11c97f, #24c6dc);
|
||||
}
|
||||
|
||||
.audio-button.failed {
|
||||
background: linear-gradient(135deg, #ff6b6b, #a855ff);
|
||||
}
|
||||
|
||||
.audio-icon {
|
||||
width: 34rpx;
|
||||
height: 34rpx;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: rgba(5, 6, 21, 0.86);
|
||||
font-size: 20rpx;
|
||||
font-weight: 900;
|
||||
background: rgba(255, 255, 255, 0.86);
|
||||
}
|
||||
|
||||
.audio-label {
|
||||
min-width: 104rpx;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.audio-status {
|
||||
display: block;
|
||||
margin-top: 12rpx;
|
||||
color: rgba(223, 211, 245, 0.7);
|
||||
font-size: 22rpx;
|
||||
line-height: 1.45;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,213 @@
|
||||
import { computed, onUnmounted, ref } from 'vue'
|
||||
import { createTtsTask, getTtsTask, getTtsTaskBySource } from '../services/tts.js'
|
||||
import analytics from '../services/analytics.js'
|
||||
|
||||
const readResponseData = (response) => response?.data ?? response ?? null
|
||||
|
||||
export const useTtsPlayer = ({ pagePath = '', sourceType = 'epic_script' } = {}) => {
|
||||
const task = ref(null)
|
||||
const loading = ref(false)
|
||||
const playing = ref(false)
|
||||
const statusText = ref('')
|
||||
let audio = null
|
||||
let timer = null
|
||||
|
||||
const buttonText = computed(() => {
|
||||
if (loading.value) return '正在生成朗读'
|
||||
if (task.value?.status === 'success') return playing.value ? '暂停朗读' : '播放朗读'
|
||||
if (task.value?.status === 'failed') return '重试朗读'
|
||||
return '语音播放'
|
||||
})
|
||||
|
||||
const clearTimer = () => {
|
||||
if (timer) clearInterval(timer)
|
||||
timer = null
|
||||
}
|
||||
|
||||
const stopAudio = () => {
|
||||
if (!audio) return
|
||||
audio.stop()
|
||||
audio.destroy()
|
||||
audio = null
|
||||
playing.value = false
|
||||
}
|
||||
|
||||
const setTask = (nextTask) => {
|
||||
task.value = nextTask
|
||||
if (audio && nextTask?.audioUrl && audio.src !== nextTask.audioUrl) {
|
||||
stopAudio()
|
||||
}
|
||||
}
|
||||
|
||||
const track = (eventName, sourceId, payload = {}) => {
|
||||
analytics.track(eventName, {
|
||||
source_id: sourceId || '',
|
||||
task_id: task.value?.id || '',
|
||||
...payload
|
||||
}, { eventType: 'tts', pagePath })
|
||||
}
|
||||
|
||||
const markFailed = (message) => {
|
||||
loading.value = false
|
||||
statusText.value = message || '朗读暂时不可用'
|
||||
uni.showToast({ title: statusText.value, icon: 'none' })
|
||||
}
|
||||
|
||||
const pollTask = (taskId, sourceId) => {
|
||||
clearTimer()
|
||||
timer = setInterval(async () => {
|
||||
try {
|
||||
const response = await getTtsTask(taskId)
|
||||
const nextTask = readResponseData(response)
|
||||
setTask(nextTask)
|
||||
|
||||
if (nextTask?.status === 'success') {
|
||||
loading.value = false
|
||||
statusText.value = ''
|
||||
clearTimer()
|
||||
track('script_tts_success', sourceId, { task_id: nextTask.id })
|
||||
play(sourceId)
|
||||
return
|
||||
}
|
||||
|
||||
if (nextTask?.status === 'failed') {
|
||||
clearTimer()
|
||||
track('script_tts_error', sourceId, { error: nextTask.errorMessage || '' })
|
||||
markFailed(nextTask.errorMessage || '朗读生成失败')
|
||||
}
|
||||
} catch (error) {
|
||||
clearTimer()
|
||||
track('script_tts_error', sourceId, { error: error?.message || error?.errMsg || 'poll failed' })
|
||||
markFailed('朗读状态获取失败')
|
||||
}
|
||||
}, 2500)
|
||||
}
|
||||
|
||||
const play = (sourceId) => {
|
||||
if (!task.value?.audioUrl) return
|
||||
|
||||
if (!audio) {
|
||||
audio = uni.createInnerAudioContext()
|
||||
audio.src = task.value.audioUrl
|
||||
audio.autoplay = false
|
||||
|
||||
audio.onPlay(() => {
|
||||
playing.value = true
|
||||
track('script_tts_play', sourceId)
|
||||
})
|
||||
|
||||
audio.onPause(() => {
|
||||
playing.value = false
|
||||
track('script_tts_pause', sourceId)
|
||||
})
|
||||
|
||||
audio.onEnded(() => {
|
||||
playing.value = false
|
||||
track('script_tts_complete', sourceId)
|
||||
})
|
||||
|
||||
audio.onError((error) => {
|
||||
playing.value = false
|
||||
track('script_tts_error', sourceId, { error: error?.errMsg || 'play failed' })
|
||||
markFailed('音频播放失败')
|
||||
})
|
||||
}
|
||||
|
||||
if (playing.value) {
|
||||
audio.pause()
|
||||
} else {
|
||||
audio.play()
|
||||
}
|
||||
}
|
||||
|
||||
const createAndPoll = async (sourceId) => {
|
||||
loading.value = true
|
||||
statusText.value = ''
|
||||
track('script_tts_request', sourceId)
|
||||
|
||||
try {
|
||||
const response = await createTtsTask({ sourceType, sourceId })
|
||||
const nextTask = readResponseData(response)
|
||||
setTask(nextTask)
|
||||
|
||||
if (nextTask?.status === 'success') {
|
||||
loading.value = false
|
||||
track('script_tts_success', sourceId, { task_id: nextTask.id })
|
||||
play(sourceId)
|
||||
return
|
||||
}
|
||||
|
||||
if (nextTask?.status === 'failed') {
|
||||
track('script_tts_error', sourceId, { error: nextTask.errorMessage || '' })
|
||||
markFailed(nextTask.errorMessage || '朗读生成失败')
|
||||
return
|
||||
}
|
||||
|
||||
if (nextTask?.id) {
|
||||
pollTask(nextTask.id, sourceId)
|
||||
return
|
||||
}
|
||||
|
||||
markFailed('朗读任务创建失败')
|
||||
} catch (error) {
|
||||
track('script_tts_error', sourceId, { error: error?.message || error?.errMsg || 'create failed' })
|
||||
markFailed(error?.message || '朗读任务创建失败')
|
||||
}
|
||||
}
|
||||
|
||||
const playSource = async (sourceId) => {
|
||||
if (!sourceId) {
|
||||
uni.showToast({ title: '生成保存后可播放', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (loading.value) return
|
||||
|
||||
if (task.value?.status === 'success') {
|
||||
play(sourceId)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await getTtsTaskBySource({ sourceType, sourceId })
|
||||
const existingTask = readResponseData(response)
|
||||
setTask(existingTask)
|
||||
|
||||
if (existingTask?.status === 'success') {
|
||||
play(sourceId)
|
||||
return
|
||||
}
|
||||
|
||||
if (existingTask?.status === 'pending' || existingTask?.status === 'processing') {
|
||||
loading.value = true
|
||||
pollTask(existingTask.id, sourceId)
|
||||
return
|
||||
}
|
||||
} catch (error) {
|
||||
// Missing existing task should fall through and create a new one.
|
||||
}
|
||||
|
||||
await createAndPoll(sourceId)
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
clearTimer()
|
||||
stopAudio()
|
||||
setTask(null)
|
||||
statusText.value = ''
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
onUnmounted(reset)
|
||||
|
||||
return {
|
||||
task,
|
||||
loading,
|
||||
playing,
|
||||
statusText,
|
||||
buttonText,
|
||||
playSource,
|
||||
reset
|
||||
}
|
||||
}
|
||||
|
||||
export default useTtsPlayer
|
||||
@@ -59,6 +59,28 @@
|
||||
"navigationBarTitleText": "个人中心"
|
||||
}
|
||||
}
|
||||
,
|
||||
{
|
||||
"path": "pages/social-import/index",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "社交数据导入"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/social-import/preview",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "导入预览"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/social-import/insights",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "人生素材画像"
|
||||
}
|
||||
}
|
||||
],
|
||||
"globalStyle": {
|
||||
"navigationBarTextStyle": "white",
|
||||
|
||||
@@ -19,6 +19,14 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="profile-entry" @click="openSocialImport">
|
||||
<view>
|
||||
<text class="profile-entry-title">社交数据导入</text>
|
||||
<text class="profile-entry-copy">生成并确认人生素材画像,让爽文更像你</text>
|
||||
</view>
|
||||
<text class="profile-entry-arrow">›</text>
|
||||
</view>
|
||||
|
||||
<view class="type-tabs">
|
||||
<text
|
||||
v-for="tab in typeTabs"
|
||||
@@ -297,6 +305,10 @@ const createScript = () => {
|
||||
uni.$emit('switchTab', 'script')
|
||||
}
|
||||
|
||||
const openSocialImport = () => {
|
||||
uni.navigateTo({ url: '/pages/social-import/index' })
|
||||
}
|
||||
|
||||
const openSearch = () => {
|
||||
uni.showModal({
|
||||
title: '搜索剧本',
|
||||
@@ -592,6 +604,41 @@ const mapScript = async (script) => {
|
||||
background: rgba(130, 48, 220, 0.28);
|
||||
}
|
||||
|
||||
.profile-entry {
|
||||
min-height: 92rpx;
|
||||
padding: 22rpx 24rpx;
|
||||
border-radius: 24rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18rpx;
|
||||
border: 1rpx solid rgba(105, 79, 210, 0.34);
|
||||
background: rgba(9, 12, 42, 0.72);
|
||||
}
|
||||
|
||||
.profile-entry-title,
|
||||
.profile-entry-copy {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.profile-entry-title {
|
||||
color: #fff;
|
||||
font-size: 29rpx;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.profile-entry-copy {
|
||||
margin-top: 8rpx;
|
||||
color: rgba(226, 215, 246, 0.68);
|
||||
font-size: 22rpx;
|
||||
line-height: 1.42;
|
||||
}
|
||||
|
||||
.profile-entry-arrow {
|
||||
color: rgba(226, 215, 246, 0.7);
|
||||
font-size: 46rpx;
|
||||
}
|
||||
|
||||
.script-card {
|
||||
display: grid;
|
||||
grid-template-columns: 150rpx 1fr;
|
||||
|
||||
@@ -28,7 +28,10 @@
|
||||
<text class="stat-label">字数</text>
|
||||
</view>
|
||||
</view>
|
||||
<ScriptAudioPlayer v-if="script?.id" :script-id="script.id" />
|
||||
<view class="audio-inline" @click="trackTtsClick">
|
||||
<text class="audio-inline-icon">▶</text>
|
||||
<text class="audio-inline-text">{{ detailTtsButtonText }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="tabs kos-card">
|
||||
@@ -63,7 +66,7 @@ import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useAppStore } from '../../stores/app.js'
|
||||
import Markdown from '../../components/Markdown.vue'
|
||||
import analytics from '../../services/analytics.js'
|
||||
import ScriptAudioPlayer from '../../components/ScriptAudioPlayer.vue'
|
||||
import { useTtsPlayer } from '../../composables/useTtsPlayer.js'
|
||||
|
||||
const store = useAppStore()
|
||||
const statusBarHeight = ref(20)
|
||||
@@ -71,6 +74,7 @@ const activeTab = ref('content')
|
||||
const scriptId = ref('')
|
||||
const script = ref(null)
|
||||
const pagePath = '/pages/main/ScriptDetailView'
|
||||
const ttsPlayer = useTtsPlayer({ pagePath })
|
||||
|
||||
const fullContent = computed(() => script.value?.content || '暂无正文内容。')
|
||||
const lengthText = computed(() => {
|
||||
@@ -78,6 +82,11 @@ const lengthText = computed(() => {
|
||||
return map[script.value?.length] || script.value?.length || '中篇'
|
||||
})
|
||||
|
||||
const detailTtsButtonText = computed(() => {
|
||||
if (!script.value?.id) return '生成保存后可语音播放'
|
||||
return ttsPlayer.buttonText.value
|
||||
})
|
||||
|
||||
const outline = computed(() => {
|
||||
const text = fullContent.value
|
||||
const parts = text.split(/\n{2,}/).filter(Boolean)
|
||||
@@ -102,6 +111,7 @@ const outline = computed(() => {
|
||||
|
||||
const loadScript = async () => {
|
||||
if (!scriptId.value) return
|
||||
ttsPlayer.reset()
|
||||
script.value = store.getScriptById(scriptId.value)
|
||||
if (!script.value) {
|
||||
await store.fetchScripts()
|
||||
@@ -124,6 +134,13 @@ const selectCurrent = async () => {
|
||||
uni.navigateTo({ url: '/pages/main/PathView' })
|
||||
}
|
||||
|
||||
const trackTtsClick = () => {
|
||||
analytics.track('script_detail_tts_click', {
|
||||
script_id: script.value?.id || ''
|
||||
}, { eventType: 'tts', pagePath })
|
||||
ttsPlayer.playSource(script.value?.id || '')
|
||||
}
|
||||
|
||||
const goBack = () => {
|
||||
uni.navigateBack()
|
||||
}
|
||||
@@ -143,6 +160,7 @@ onMounted(async () => {
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
ttsPlayer.reset()
|
||||
analytics.trackPageLeave(pagePath, { script_id: scriptId.value })
|
||||
})
|
||||
</script>
|
||||
@@ -254,6 +272,41 @@ onUnmounted(() => {
|
||||
margin-top: 28rpx;
|
||||
}
|
||||
|
||||
.audio-inline {
|
||||
height: 76rpx;
|
||||
margin-top: 26rpx;
|
||||
border-radius: 999rpx;
|
||||
padding: 0 26rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 14rpx;
|
||||
color: #fff;
|
||||
font-size: 25rpx;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
background: linear-gradient(135deg, #24c6dc, #7f5af0);
|
||||
box-shadow: 0 12rpx 30rpx rgba(36, 198, 220, 0.22);
|
||||
}
|
||||
|
||||
.audio-inline-icon {
|
||||
width: 34rpx;
|
||||
height: 34rpx;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: rgba(5, 6, 21, 0.86);
|
||||
font-size: 18rpx;
|
||||
font-weight: 900;
|
||||
background: rgba(255, 255, 255, 0.86);
|
||||
}
|
||||
|
||||
.audio-inline-text {
|
||||
line-height: 1.2;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat {
|
||||
padding: 18rpx 10rpx;
|
||||
border-radius: 20rpx;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,8 +9,8 @@
|
||||
<view class="safe-top" :style="{ height: safeAreaTop + 14 + 'px' }"></view>
|
||||
|
||||
<scroll-view class="content" scroll-y :enhanced="true" :show-scrollbar="false">
|
||||
<RecordView v-if="activeTab === 'record'" />
|
||||
<ScriptView v-if="activeTab === 'script'" />
|
||||
<RecordView v-if="activeTab === 'record'" />
|
||||
<MineView v-if="activeTab === 'mine'" />
|
||||
</scroll-view>
|
||||
|
||||
@@ -18,13 +18,6 @@
|
||||
|
||||
<view class="bottom-nav">
|
||||
<view class="nav-inner">
|
||||
<view class="nav-item" :class="{ active: activeTab === 'record' }" @click="switchTab('record')">
|
||||
<view class="tab-icon planet-ring-icon">
|
||||
<view class="planet-core"></view>
|
||||
<view class="planet-ring"></view>
|
||||
</view>
|
||||
<text>人生轨迹</text>
|
||||
</view>
|
||||
<view class="nav-item" :class="{ active: activeTab === 'script' }" @click="switchTab('script')">
|
||||
<view class="tab-icon book-star-icon">
|
||||
<view class="book-page left"></view>
|
||||
@@ -33,6 +26,13 @@
|
||||
</view>
|
||||
<text>爽文生成</text>
|
||||
</view>
|
||||
<view class="nav-item" :class="{ active: activeTab === 'record' }" @click="switchTab('record')">
|
||||
<view class="tab-icon planet-ring-icon">
|
||||
<view class="planet-core"></view>
|
||||
<view class="planet-ring"></view>
|
||||
</view>
|
||||
<text>人生轨迹</text>
|
||||
</view>
|
||||
<view class="nav-item" :class="{ active: activeTab === 'mine' }" @click="switchTab('mine')">
|
||||
<view class="tab-icon smile-face-icon">
|
||||
<view class="smile-eye left"></view>
|
||||
@@ -56,7 +56,7 @@ import MusicPlayer from '../../components/MusicPlayer.vue'
|
||||
import analytics from '../../services/analytics.js'
|
||||
|
||||
const store = useAppStore()
|
||||
const activeTab = ref('record')
|
||||
const activeTab = ref('script')
|
||||
const safeAreaTop = ref(uni.getStorageSync('safeAreaTop') || 20)
|
||||
const safeAreaBottom = ref(uni.getStorageSync('safeAreaBottom') || 0)
|
||||
const pagePath = '/pages/main/index'
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
<template>
|
||||
<scroll-view class="page kos-page" scroll-y :show-scrollbar="false">
|
||||
<view class="nav" :style="{ paddingTop: safeAreaTop + 'px' }">
|
||||
<text class="back" @click="goBack">‹</text>
|
||||
<text class="title">社交数据导入</text>
|
||||
<text class="manage" @click="openInsights">画像</text>
|
||||
</view>
|
||||
|
||||
<view class="hero kos-card">
|
||||
<text class="hero-title">把真实表达变成人生素材</text>
|
||||
<text class="hero-copy">仅分析你主动粘贴、上传并授权的内容。确认后的画像才会用于爽文生成。</text>
|
||||
</view>
|
||||
|
||||
<view class="platforms">
|
||||
<view
|
||||
v-for="item in platforms"
|
||||
:key="item.value"
|
||||
class="platform"
|
||||
:class="{ active: platform === item.value }"
|
||||
@click="platform = item.value"
|
||||
>
|
||||
<text>{{ item.label }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="actions">
|
||||
<view class="action kos-card" @click="startManual">
|
||||
<text class="action-title">粘贴文本</text>
|
||||
<text class="action-copy">适合小红书笔记、微博正文、朋友圈文字</text>
|
||||
</view>
|
||||
<view class="action kos-card" @click="startLink">
|
||||
<text class="action-title">保存链接</text>
|
||||
<text class="action-copy">先记录来源链接,再补充正文让 AI 分析</text>
|
||||
</view>
|
||||
<view class="action kos-card" @click="startScreenshot">
|
||||
<text class="action-title">上传截图</text>
|
||||
<text class="action-copy">服务端 OCR 未启用时会提示改用文本导入</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="consent kos-card">
|
||||
<text class="consent-title">授权边界</text>
|
||||
<text class="consent-copy">不会自动登录或抓取小红书、微博、微信等平台;不会分析未授权内容;你可以随时删除导入内容或撤销画像。</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import * as socialImport from '../../services/socialImport.js'
|
||||
import analytics from '../../services/analytics.js'
|
||||
|
||||
const pagePath = '/pages/social-import/index'
|
||||
const platform = ref('xiaohongshu')
|
||||
const safeAreaTop = ref(uni.getStorageSync('safeAreaTop') || 20)
|
||||
|
||||
const platforms = [
|
||||
{ label: '小红书', value: 'xiaohongshu' },
|
||||
{ label: '微博', value: 'weibo' },
|
||||
{ label: '微信', value: 'wechat' },
|
||||
{ label: '其他', value: 'other' }
|
||||
]
|
||||
|
||||
const goBack = () => uni.navigateBack()
|
||||
|
||||
const openInsights = () => {
|
||||
analytics.track('social_import_insights_click', {}, { eventType: 'social_import', pagePath })
|
||||
uni.navigateTo({ url: '/pages/social-import/insights' })
|
||||
}
|
||||
|
||||
const openPreview = (mode, payload = {}) => {
|
||||
uni.setStorageSync('social_import_draft', {
|
||||
mode,
|
||||
platform: platform.value,
|
||||
...payload
|
||||
})
|
||||
uni.navigateTo({ url: '/pages/social-import/preview' })
|
||||
}
|
||||
|
||||
const startManual = () => {
|
||||
analytics.track('social_import_manual_start', { platform: platform.value }, { eventType: 'social_import', pagePath })
|
||||
openPreview('manual')
|
||||
}
|
||||
|
||||
const startLink = () => {
|
||||
analytics.track('social_import_link_start', { platform: platform.value }, { eventType: 'social_import', pagePath })
|
||||
openPreview('link')
|
||||
}
|
||||
|
||||
const startScreenshot = () => {
|
||||
analytics.track('social_import_screenshot_start', { platform: platform.value }, { eventType: 'social_import', pagePath })
|
||||
uni.chooseImage({
|
||||
count: 1,
|
||||
sourceType: ['album', 'camera'],
|
||||
success: async (res) => {
|
||||
const filePath = res.tempFilePaths?.[0]
|
||||
if (!filePath) return
|
||||
uni.showLoading({ title: '上传中' })
|
||||
try {
|
||||
await socialImport.screenshotImport({ platform: platform.value, filePath })
|
||||
uni.showToast({ title: '已导入', icon: 'success' })
|
||||
} catch (error) {
|
||||
uni.showToast({ title: error.message || '请先使用文本导入', icon: 'none' })
|
||||
} finally {
|
||||
uni.hideLoading()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
height: 100vh;
|
||||
padding: 0 28rpx 48rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.nav {
|
||||
height: 72rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.back,
|
||||
.manage {
|
||||
width: 96rpx;
|
||||
color: #d9c2ff;
|
||||
font-size: 30rpx;
|
||||
}
|
||||
|
||||
.back {
|
||||
font-size: 58rpx;
|
||||
}
|
||||
|
||||
.manage {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 34rpx;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.hero {
|
||||
margin-top: 42rpx;
|
||||
padding: 34rpx;
|
||||
border-radius: 32rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18rpx;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
font-size: 54rpx;
|
||||
line-height: 1.25;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.hero-copy,
|
||||
.consent-copy,
|
||||
.action-copy {
|
||||
color: rgba(235, 225, 255, 0.72);
|
||||
font-size: 26rpx;
|
||||
line-height: 1.58;
|
||||
}
|
||||
|
||||
.platforms {
|
||||
margin-top: 36rpx;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 14rpx;
|
||||
}
|
||||
|
||||
.platform {
|
||||
height: 62rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 999rpx;
|
||||
color: rgba(235, 225, 255, 0.72);
|
||||
border: 1rpx solid rgba(178, 128, 255, 0.28);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
.platform.active {
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, #a855f7, #6d28d9);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.actions {
|
||||
margin-top: 34rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18rpx;
|
||||
}
|
||||
|
||||
.action,
|
||||
.consent {
|
||||
border-radius: 24rpx;
|
||||
padding: 26rpx;
|
||||
}
|
||||
|
||||
.action {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10rpx;
|
||||
}
|
||||
|
||||
.action-title,
|
||||
.consent-title {
|
||||
color: #fff;
|
||||
font-size: 32rpx;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.consent {
|
||||
margin-top: 34rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12rpx;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,315 @@
|
||||
<template>
|
||||
<scroll-view class="page kos-page" scroll-y :show-scrollbar="false">
|
||||
<view class="nav" :style="{ paddingTop: safeAreaTop + 'px' }">
|
||||
<text class="back" @click="goBack">‹</text>
|
||||
<text class="title">人生素材画像</text>
|
||||
<text class="add" @click="openImport">导入</text>
|
||||
</view>
|
||||
|
||||
<view class="summary kos-card">
|
||||
<text class="summary-title">{{ confirmedCount }} 个已确认画像</text>
|
||||
<text class="summary-copy">爽文生成只会使用你确认过的画像,建议项不会直接进入创作。</text>
|
||||
</view>
|
||||
|
||||
<view class="tabs">
|
||||
<text
|
||||
v-for="tab in tabs"
|
||||
:key="tab.value"
|
||||
class="tab"
|
||||
:class="{ active: activeStatus === tab.value }"
|
||||
@click="activeStatus = tab.value"
|
||||
>{{ tab.label }}</text>
|
||||
</view>
|
||||
|
||||
<view v-if="loading" class="empty kos-card">
|
||||
<text class="empty-title">正在加载画像</text>
|
||||
<text class="empty-copy">马上把你的素材卡片整理出来</text>
|
||||
</view>
|
||||
|
||||
<view v-else-if="visibleInsights.length" class="list">
|
||||
<view v-for="item in visibleInsights" :key="item.id" class="insight kos-card">
|
||||
<view class="insight-head">
|
||||
<text class="label">{{ item.label }}</text>
|
||||
<text class="status" :class="'status-' + item.status">{{ statusText(item.status) }}</text>
|
||||
</view>
|
||||
<text class="type">{{ typeText(item.insightType) }}</text>
|
||||
<text class="desc">{{ item.summary }}</text>
|
||||
<text v-if="item.evidenceExcerpt" class="evidence">“{{ item.evidenceExcerpt }}”</text>
|
||||
<view class="ops">
|
||||
<text v-if="item.status !== 'confirmed'" @click="confirmInsight(item)">确认</text>
|
||||
<text v-if="item.status !== 'rejected'" @click="rejectInsight(item)">不采用</text>
|
||||
<text @click="deleteInsight(item)">删除</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-else class="empty kos-card">
|
||||
<text class="empty-title">还没有画像</text>
|
||||
<text class="empty-copy">{{ loadError || '先导入一段社交内容,系统会生成待确认的人生素材画像。' }}</text>
|
||||
<button @click="openImport">去导入内容</button>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import * as socialImport from '../../services/socialImport.js'
|
||||
import analytics from '../../services/analytics.js'
|
||||
|
||||
const pagePath = '/pages/social-import/insights'
|
||||
const insights = ref([])
|
||||
const activeStatus = ref('all')
|
||||
const loading = ref(true)
|
||||
const loadError = ref('')
|
||||
const safeAreaTop = ref(uni.getStorageSync('safeAreaTop') || 20)
|
||||
|
||||
const tabs = [
|
||||
{ label: '全部', value: 'all' },
|
||||
{ label: '待确认', value: 'suggested' },
|
||||
{ label: '已确认', value: 'confirmed' },
|
||||
{ label: '不采用', value: 'rejected' }
|
||||
]
|
||||
|
||||
const visibleInsights = computed(() => {
|
||||
if (activeStatus.value === 'all') return insights.value
|
||||
return insights.value.filter(item => item.status === activeStatus.value)
|
||||
})
|
||||
|
||||
const confirmedCount = computed(() => insights.value.filter(item => item.status === 'confirmed').length)
|
||||
|
||||
const goBack = () => uni.navigateBack()
|
||||
const openImport = () => uni.redirectTo({ url: '/pages/social-import/index' })
|
||||
|
||||
const loadInsights = async () => {
|
||||
loading.value = true
|
||||
loadError.value = ''
|
||||
try {
|
||||
const res = await socialImport.listInsights()
|
||||
insights.value = res.data || []
|
||||
} catch (error) {
|
||||
loadError.value = error.message || '画像加载失败,请稍后重试'
|
||||
insights.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const updateStatus = async (item, status) => {
|
||||
try {
|
||||
await socialImport.updateInsight(item.id, { status })
|
||||
analytics.track('social_insight_status_update', { status }, { eventType: 'social_import', pagePath })
|
||||
await loadInsights()
|
||||
} catch (error) {
|
||||
uni.showToast({ title: error.message || '操作失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
|
||||
const confirmInsight = (item) => updateStatus(item, 'confirmed')
|
||||
const rejectInsight = (item) => updateStatus(item, 'rejected')
|
||||
|
||||
const deleteInsight = async (item) => {
|
||||
try {
|
||||
await socialImport.deleteInsight(item.id)
|
||||
await loadInsights()
|
||||
} catch (error) {
|
||||
uni.showToast({ title: error.message || '删除失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
|
||||
const statusText = (status) => {
|
||||
const map = { suggested: '待确认', confirmed: '已确认', rejected: '不采用' }
|
||||
return map[status] || status
|
||||
}
|
||||
|
||||
const typeText = (type) => {
|
||||
const map = {
|
||||
value: '价值偏好',
|
||||
interest: '兴趣倾向',
|
||||
emotion_pattern: '情绪模式',
|
||||
script_theme: '剧本主题'
|
||||
}
|
||||
return map[type] || type
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
analytics.track('social_insights_view', {}, { eventType: 'social_import', pagePath })
|
||||
loadInsights()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
height: 100vh;
|
||||
padding: 0 28rpx 48rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.nav {
|
||||
height: 72rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.back,
|
||||
.add {
|
||||
width: 96rpx;
|
||||
color: #d9c2ff;
|
||||
font-size: 30rpx;
|
||||
}
|
||||
|
||||
.back {
|
||||
font-size: 58rpx;
|
||||
}
|
||||
|
||||
.add {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 34rpx;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.summary {
|
||||
margin-top: 30rpx;
|
||||
padding: 28rpx;
|
||||
border-radius: 28rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.summary-title {
|
||||
font-size: 46rpx;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.summary-copy,
|
||||
.desc,
|
||||
.evidence,
|
||||
.type {
|
||||
color: rgba(235, 225, 255, 0.72);
|
||||
font-size: 25rpx;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
margin-top: 28rpx;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.tab {
|
||||
height: 56rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 999rpx;
|
||||
color: rgba(235, 225, 255, 0.72);
|
||||
border: 1rpx solid rgba(178, 128, 255, 0.28);
|
||||
font-size: 23rpx;
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
color: #fff;
|
||||
background: rgba(168, 85, 247, 0.3);
|
||||
}
|
||||
|
||||
.list {
|
||||
margin-top: 24rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18rpx;
|
||||
}
|
||||
|
||||
.insight {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12rpx;
|
||||
padding: 24rpx;
|
||||
border-radius: 24rpx;
|
||||
}
|
||||
|
||||
.insight-head,
|
||||
.ops {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 32rpx;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.status {
|
||||
padding: 5rpx 14rpx;
|
||||
border-radius: 999rpx;
|
||||
font-size: 22rpx;
|
||||
color: #e9d5ff;
|
||||
background: rgba(168, 85, 247, 0.22);
|
||||
}
|
||||
|
||||
.status-confirmed {
|
||||
color: #8ff0bd;
|
||||
background: rgba(34, 197, 94, 0.18);
|
||||
}
|
||||
|
||||
.status-rejected {
|
||||
color: rgba(235, 225, 255, 0.58);
|
||||
}
|
||||
|
||||
.evidence {
|
||||
padding-left: 16rpx;
|
||||
border-left: 4rpx solid rgba(168, 85, 247, 0.55);
|
||||
}
|
||||
|
||||
.ops {
|
||||
justify-content: flex-end;
|
||||
color: #d9c2ff;
|
||||
font-size: 26rpx;
|
||||
}
|
||||
|
||||
.empty {
|
||||
min-height: 420rpx;
|
||||
margin-top: 28rpx;
|
||||
padding: 40rpx 28rpx;
|
||||
border-radius: 30rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 24rpx;
|
||||
color: rgba(235, 225, 255, 0.72);
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
color: #fff;
|
||||
font-size: 34rpx;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.empty-copy {
|
||||
max-width: 560rpx;
|
||||
text-align: center;
|
||||
color: rgba(235, 225, 255, 0.68);
|
||||
font-size: 25rpx;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.empty button {
|
||||
height: 72rpx;
|
||||
padding: 0 34rpx;
|
||||
border-radius: 999rpx;
|
||||
color: #fff;
|
||||
font-size: 26rpx;
|
||||
background: linear-gradient(135deg, #a855f7, #6d28d9);
|
||||
}
|
||||
|
||||
.empty button::after {
|
||||
border: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,235 @@
|
||||
<template>
|
||||
<scroll-view class="page kos-page" scroll-y :show-scrollbar="false">
|
||||
<view class="nav" :style="{ paddingTop: safeAreaTop + 'px' }">
|
||||
<text class="back" @click="goBack">‹</text>
|
||||
<text class="title">导入预览</text>
|
||||
<text class="ghost"></text>
|
||||
</view>
|
||||
|
||||
<view class="form">
|
||||
<picker :range="platformLabels" :value="platformIndex" @change="onPlatformChange">
|
||||
<view class="field picker-field kos-card">
|
||||
<text class="label">来源平台</text>
|
||||
<text class="value">{{ platformLabel }}</text>
|
||||
</view>
|
||||
</picker>
|
||||
|
||||
<view v-if="mode === 'link'" class="field kos-card">
|
||||
<text class="label">原文链接</text>
|
||||
<input class="input" v-model="sourceUrl" placeholder="粘贴链接" placeholder-class="placeholder" />
|
||||
</view>
|
||||
|
||||
<view class="field kos-card">
|
||||
<text class="label">标题</text>
|
||||
<input class="input" v-model="titleText" placeholder="可选" placeholder-class="placeholder" />
|
||||
</view>
|
||||
|
||||
<view class="field kos-card">
|
||||
<text class="label">正文内容</text>
|
||||
<textarea class="textarea" v-model="contentText" maxlength="20000" placeholder="粘贴你希望用于分析的文字" placeholder-class="placeholder" />
|
||||
</view>
|
||||
|
||||
<view class="approval" @click="approvedForAi = !approvedForAi">
|
||||
<view class="checkbox" :class="{ checked: approvedForAi }"></view>
|
||||
<text>我授权本次内容用于生成可确认的人生素材画像</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<button class="submit kos-primary" :disabled="submitting" @click="submitImport">
|
||||
{{ submitting ? '导入中' : '导入并生成画像' }}
|
||||
</button>
|
||||
</scroll-view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import * as socialImport from '../../services/socialImport.js'
|
||||
import analytics from '../../services/analytics.js'
|
||||
|
||||
const pagePath = '/pages/social-import/preview'
|
||||
const draft = uni.getStorageSync('social_import_draft') || {}
|
||||
const mode = ref(draft.mode || 'manual')
|
||||
const platform = ref(draft.platform || 'xiaohongshu')
|
||||
const sourceUrl = ref(draft.sourceUrl || '')
|
||||
const titleText = ref(draft.title || '')
|
||||
const contentText = ref(draft.content || '')
|
||||
const approvedForAi = ref(true)
|
||||
const submitting = ref(false)
|
||||
const safeAreaTop = ref(uni.getStorageSync('safeAreaTop') || 20)
|
||||
|
||||
const platforms = [
|
||||
{ label: '小红书', value: 'xiaohongshu' },
|
||||
{ label: '微博', value: 'weibo' },
|
||||
{ label: '微信', value: 'wechat' },
|
||||
{ label: '其他', value: 'other' }
|
||||
]
|
||||
|
||||
const platformLabels = platforms.map(item => item.label)
|
||||
const platformIndex = computed(() => Math.max(0, platforms.findIndex(item => item.value === platform.value)))
|
||||
const platformLabel = computed(() => platforms[platformIndex.value]?.label || '小红书')
|
||||
|
||||
const goBack = () => uni.navigateBack()
|
||||
|
||||
const onPlatformChange = (event) => {
|
||||
platform.value = platforms[Number(event.detail.value)]?.value || 'xiaohongshu'
|
||||
}
|
||||
|
||||
const submitImport = async () => {
|
||||
const content = contentText.value.trim()
|
||||
if (!content) {
|
||||
uni.showToast({ title: '请先粘贴正文内容', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (mode.value === 'link' && !sourceUrl.value.trim()) {
|
||||
uni.showToast({ title: '请填写原文链接', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
uni.showLoading({ title: '分析中' })
|
||||
try {
|
||||
const payload = {
|
||||
platform: platform.value,
|
||||
title: titleText.value.trim(),
|
||||
content,
|
||||
approvedForAi: approvedForAi.value
|
||||
}
|
||||
const res = mode.value === 'link'
|
||||
? await socialImport.linkImport({ ...payload, sourceUrl: sourceUrl.value.trim() })
|
||||
: await socialImport.manualImport(payload)
|
||||
|
||||
if (approvedForAi.value && res.data?.id) {
|
||||
await socialImport.generateInsights([res.data.id])
|
||||
}
|
||||
analytics.track('social_import_submit_success', {
|
||||
mode: mode.value,
|
||||
platform: platform.value,
|
||||
approved_for_ai: approvedForAi.value
|
||||
}, { eventType: 'social_import', pagePath })
|
||||
uni.removeStorageSync('social_import_draft')
|
||||
uni.showToast({ title: '已生成画像', icon: 'success' })
|
||||
setTimeout(() => {
|
||||
uni.redirectTo({ url: '/pages/social-import/insights' })
|
||||
}, 400)
|
||||
} catch (error) {
|
||||
analytics.track('social_import_submit_fail', {
|
||||
mode: mode.value,
|
||||
platform: platform.value,
|
||||
error: error.message || 'unknown'
|
||||
}, { eventType: 'social_import', pagePath })
|
||||
uni.showToast({ title: error.message || '导入失败', icon: 'none' })
|
||||
} finally {
|
||||
submitting.value = false
|
||||
uni.hideLoading()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
height: 100vh;
|
||||
padding: 0 28rpx 48rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.nav {
|
||||
height: 72rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.back,
|
||||
.ghost {
|
||||
width: 96rpx;
|
||||
color: #d9c2ff;
|
||||
font-size: 58rpx;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 34rpx;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.form {
|
||||
margin-top: 34rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 22rpx;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14rpx;
|
||||
padding: 24rpx;
|
||||
border-radius: 22rpx;
|
||||
}
|
||||
|
||||
.picker-field {
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.label {
|
||||
color: rgba(235, 225, 255, 0.72);
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
.value,
|
||||
.input,
|
||||
.textarea {
|
||||
color: #fff;
|
||||
font-size: 30rpx;
|
||||
}
|
||||
|
||||
.input {
|
||||
height: 52rpx;
|
||||
}
|
||||
|
||||
.textarea {
|
||||
width: 100%;
|
||||
min-height: 340rpx;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
color: rgba(235, 225, 255, 0.42);
|
||||
}
|
||||
|
||||
.approval {
|
||||
display: flex;
|
||||
gap: 16rpx;
|
||||
align-items: center;
|
||||
color: rgba(235, 225, 255, 0.8);
|
||||
font-size: 25rpx;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
width: 34rpx;
|
||||
height: 34rpx;
|
||||
border-radius: 10rpx;
|
||||
border: 2rpx solid rgba(216, 180, 254, 0.8);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.checkbox.checked {
|
||||
background: #a855f7;
|
||||
border-color: #a855f7;
|
||||
}
|
||||
|
||||
.submit {
|
||||
margin-top: 38rpx;
|
||||
height: 88rpx;
|
||||
border-radius: 999rpx;
|
||||
color: #fff;
|
||||
font-size: 30rpx;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.submit::after {
|
||||
border: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -94,7 +94,8 @@ const transformToBackendFormat = (frontendData) => {
|
||||
isSelected,
|
||||
character,
|
||||
events,
|
||||
plotJson
|
||||
plotJson,
|
||||
useSocialInsights
|
||||
} = frontendData
|
||||
|
||||
const scriptTitle = title || theme || '我的人生剧本'
|
||||
@@ -114,7 +115,8 @@ const transformToBackendFormat = (frontendData) => {
|
||||
plotJson: plotJson || (content ? { fullContent: content } : null),
|
||||
isSelected,
|
||||
characterInfo,
|
||||
lifeEventsSummary
|
||||
lifeEventsSummary,
|
||||
useSocialInsights
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -149,10 +149,52 @@ export const del = (url, params = {}) => {
|
||||
return request({ url: fullUrl, method: 'DELETE' })
|
||||
}
|
||||
|
||||
export const upload = (url, filePath, formData = {}, name = 'file') => {
|
||||
const token = uni.getStorageSync('access_token')
|
||||
const fullUrl = `${API_BASE_URL}${url}`
|
||||
return new Promise((resolve, reject) => {
|
||||
uni.uploadFile({
|
||||
url: fullUrl,
|
||||
filePath,
|
||||
name,
|
||||
formData,
|
||||
header: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
timeout: 30000,
|
||||
success: (res) => {
|
||||
let data = res.data
|
||||
try {
|
||||
data = typeof res.data === 'string' ? JSON.parse(res.data) : res.data
|
||||
} catch (error) {
|
||||
reject(createRequestError('Upload response parse failed', { path: url, originalError: error }))
|
||||
return
|
||||
}
|
||||
if (res.statusCode >= 200 && res.statusCode < 300 && (data?.code === 200 || data?.code === 0)) {
|
||||
resolve(data)
|
||||
return
|
||||
}
|
||||
reject(createRequestError(data?.message || 'Upload failed', {
|
||||
statusCode: res.statusCode,
|
||||
code: data?.code,
|
||||
path: url,
|
||||
response: data
|
||||
}))
|
||||
},
|
||||
fail: (err) => {
|
||||
reject(createRequestError(err.errMsg || 'Upload failed', {
|
||||
path: url,
|
||||
isNetworkError: true,
|
||||
originalError: err
|
||||
}))
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export default {
|
||||
get,
|
||||
post,
|
||||
put,
|
||||
del,
|
||||
upload,
|
||||
logRuntimeEnv
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { get, post, put, del, upload } from './request.js'
|
||||
|
||||
export const manualImport = (payload) => post('/social/content/manual', payload)
|
||||
|
||||
export const linkImport = (payload) => post('/social/content/link', payload)
|
||||
|
||||
export const screenshotImport = ({ platform, filePath }) => {
|
||||
return upload('/social/content/screenshot', filePath, { platform })
|
||||
}
|
||||
|
||||
export const listContent = () => get('/social/content/list')
|
||||
|
||||
export const updateContentApproval = (id, approvedForAi) => {
|
||||
return put(`/social/content/${id}/approval`, { approvedForAi })
|
||||
}
|
||||
|
||||
export const deleteContent = (id, keepConfirmedInsights = true) => {
|
||||
return del(`/social/content/${id}`, { keepConfirmedInsights })
|
||||
}
|
||||
|
||||
export const generateInsights = (sourceItemIds = []) => {
|
||||
return post('/social/insight/generate', { sourceItemIds })
|
||||
}
|
||||
|
||||
export const listInsights = (status = '') => {
|
||||
return get('/social/insight/list', status ? { status } : {})
|
||||
}
|
||||
|
||||
export const updateInsight = (id, payload) => {
|
||||
return put(`/social/insight/${id}`, payload)
|
||||
}
|
||||
|
||||
export const deleteInsight = (id) => {
|
||||
return del(`/social/insight/${id}`)
|
||||
}
|
||||
|
||||
export default {
|
||||
manualImport,
|
||||
linkImport,
|
||||
screenshotImport,
|
||||
listContent,
|
||||
updateContentApproval,
|
||||
deleteContent,
|
||||
generateInsights,
|
||||
listInsights,
|
||||
updateInsight,
|
||||
deleteInsight
|
||||
}
|
||||
@@ -251,7 +251,7 @@ const fetchRandomInspirations = async (size = 3) => {
|
||||
}
|
||||
}
|
||||
|
||||
const generateScriptFromInspiration = async ({ prompt, style, length }) => {
|
||||
const generateScriptFromInspiration = async ({ prompt, style, length, useSocialInsights = true }) => {
|
||||
try {
|
||||
const profile = state.userProfile || state.registrationData
|
||||
const res = await epicScriptService.generateFromInspiration({
|
||||
@@ -260,6 +260,7 @@ const generateScriptFromInspiration = async ({ prompt, style, length }) => {
|
||||
length,
|
||||
characterInfo: epicScriptService.buildCharacterInfo(profile),
|
||||
lifeEventsSummary: epicScriptService.buildLifeEventsSummary(state.events, profile),
|
||||
useSocialInsights,
|
||||
source: 'mini-program'
|
||||
})
|
||||
await fetchScripts()
|
||||
|
||||
Reference in New Issue
Block a user