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:
2026-05-20 07:18:02 +08:00
parent 83cc32999b
commit ee5a6aba5d
50 changed files with 5723 additions and 1246 deletions
@@ -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
+22
View File
@@ -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",
+47
View File
@@ -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 -9
View File
@@ -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>
+4 -2
View File
@@ -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
}
}
+42
View File
@@ -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
}
+48
View File
@@ -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
}
+2 -1
View File
@@ -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()