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
@@ -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