diff --git a/frontend/app/web-gold/src/views/trends/Forecast.vue b/frontend/app/web-gold/src/views/trends/Forecast.vue index 46367a49d8..9fd2b08d83 100644 --- a/frontend/app/web-gold/src/views/trends/Forecast.vue +++ b/frontend/app/web-gold/src/views/trends/Forecast.vue @@ -6,48 +6,32 @@ import { CommonService } from '@/api/common' import { UserPromptApi } from '@/api/userPrompt' import { useUserStore } from '@/stores/user' import GradientButton from '@/components/GradientButton.vue' +import { getVoiceText } from '@gold/hooks/web/useVoiceText' -// 定义组件名称 -defineOptions({ - name: 'ForecastView' -}) +defineOptions({ name: 'ForecastView' }) -// 平台列表 -// const platforms = [ -// { id: 'douyin', name: '抖音', color: '#FE2C55' } -// ] - -// const activePlatform = ref('douyin') - -// 搜索关键词 +// 状态管理 +const userStore = useUserStore() const searchKeyword = ref('') +const isLoading = ref(false) +const isGenerating = ref(false) +const isAnalyzing = ref(false) +const hotTopics = ref([]) +const selectedTopic = ref(null) +const currentCursor = ref(null) +const generatedContent = ref('') // 搜索参数 const searchParams = reactive({ keyword: '', - offset: 0, // 偏移量,第一次请求时为0,后续从返回数据中的 cursor 获取 - sort_type: 1, // 0:综合排序 1:最多点赞 2:最新发布 - publish_time: 7, // 0:不限 1:最近一天 7:最近一周 180:最近半年 - filter_duration: '0', // '0':不限 '0-1':1分钟以内 '1-5':1-5分钟 '5-10000':5分钟以上 - content_type: 0 // 0:不限 1:视频 2:图集 + offset: 0, + sort_type: 1, + publish_time: 7, + filter_duration: '0', + content_type: 0 }) -// 加载状态 -const isLoading = ref(false) - -// 热点列表数据 -const hotTopics = ref([]) - -// 选中的热点 -const selectedTopic = ref(null) - -// 当前 cursor(用于翻页) -const currentCursor = ref(null) - -// 用户store -const userStore = useUserStore() - -// 右侧详细信息 +// 创作详情 const topicDetails = reactive({ title: '', copywriting: '', @@ -55,29 +39,18 @@ const topicDetails = reactive({ stylePromptId: null }) -// 生成的文案内容 -const generatedContent = ref('') - -// 生成文案的loading状态 -const isGenerating = ref(false) - -// 提示词相关状态 +// 提示词相关 const allPrompts = ref([]) const loadingPrompts = ref(false) const showAllPromptsModal = ref(false) const promptSearchKeyword = ref('') -const DISPLAY_COUNT = 6 // 展示的提示词数量 +const DISPLAY_COUNT = 6 -// 计算属性:展示的部分提示词 -const displayPrompts = computed(() => { - return allPrompts.value.slice(0, DISPLAY_COUNT) -}) +// 计算属性 +const displayPrompts = computed(() => allPrompts.value.slice(0, DISPLAY_COUNT)) -// 计算属性:过滤后的全部提示词(用于"更多"弹窗) const filteredPrompts = computed(() => { - if (!promptSearchKeyword.value.trim()) { - return allPrompts.value - } + if (!promptSearchKeyword.value.trim()) return allPrompts.value const keyword = promptSearchKeyword.value.trim().toLowerCase() return allPrompts.value.filter(p => p.name.toLowerCase().includes(keyword) || @@ -85,9 +58,33 @@ const filteredPrompts = computed(() => { ) }) -/** - * 加载用户提示词列表 - */ +// 工具函数 +const formatNumber = (num) => { + if (!num) return '0' + return num >= 10000 ? `${(num / 10000).toFixed(1)}w` : num.toString() +} + +const truncateTitle = (title, maxLength = 30) => { + if (!title) return '' + return title.length <= maxLength ? title : `${title.substring(0, maxLength)}...` +} + +const handleImageError = (event) => { + event.target.style.display = 'none' +} + +const openVideo = (topic, event) => { + event.stopPropagation() + if (topic.videoUrl) window.open(topic.videoUrl, '_blank') +} + +const handleSearchKeypress = (event) => { + if (event.key === 'Enter' && !isLoading.value) { + handleSearch() + } +} + +// 提示词管理 async function loadUserPrompts() { if (!userStore.userId) { console.warn('用户未登录,无法加载提示词') @@ -99,12 +96,12 @@ async function loadUserPrompts() { const response = await UserPromptApi.getUserPromptPage({ pageNo: 1, pageSize: 100, - status: 1 // 只获取启用状态的提示词 + status: 1 }) if (response?.data?.list) { allPrompts.value = response.data.list - // 如果没有选中的提示词,自动选中第一个 + // 自动选中第一个提示词 if (!topicDetails.stylePromptId && allPrompts.value.length > 0) { const firstPrompt = allPrompts.value[0] topicDetails.stylePromptId = firstPrompt.id @@ -119,56 +116,87 @@ async function loadUserPrompts() { } } -/** - * 选择提示词 - */ function selectPrompt(prompt) { - if (!prompt || !prompt.content) { + if (!prompt?.content) { message.warning('提示词内容为空') return } - topicDetails.stylePromptId = prompt.id topicDetails.stylePrompt = prompt.content showAllPromptsModal.value = false } -// 点击创作按钮 -const handleCreate = (topic) => { +// 语音分析 +async function analyzeVoice(audioUrl) { + if (!audioUrl?.trim()) { + console.warn('音频链接为空,无法分析') + return + } + + isAnalyzing.value = true + try { + message.info('正在分析语音链接,提取文案...') + const transcriptions = await getVoiceText([{ audio_url: audioUrl }]) + const transcript = transcriptions?.[0]?.value?.trim() || '' + + if (transcript) { + const hasExistingContent = topicDetails.copywriting?.trim() + topicDetails.copywriting = hasExistingContent + ? `${topicDetails.copywriting}\n\n${transcript}` + : transcript + message.success(`语音分析完成,已提取 ${transcript.length} 字文案内容`) + } else { + console.warn('转写结果为空:', transcriptions) + message.warning('未从语音链接获取到可用的文案内容,请检查音频链接是否有效') + } + } catch (error) { + console.error('分析语音失败:', error) + message.error(`分析语音失败: ${error?.message || '请稍后重试'}`) + } finally { + isAnalyzing.value = false + } +} + +// 创作处理 +async function handleCreate(topic) { selectedTopic.value = topic.id topicDetails.title = topic.title - // 提取文案:使用标题作为初始文案 - topicDetails.copywriting = topic.title || '' - // 保持已选择的风格提示词,如果没有则使用第一个 + + // 自动选中第一个提示词(如果还没有选中) if (!topicDetails.stylePromptId && allPrompts.value.length > 0) { const firstPrompt = allPrompts.value[0] topicDetails.stylePromptId = firstPrompt.id topicDetails.stylePrompt = firstPrompt.content || '' } + + // 自动分析语音 + if (topic.audioUrl?.trim()) { + await analyzeVoice(topic.audioUrl) + } else { + message.info('该视频暂无音频链接,无法自动提取语音文案') + } } // 生成文案(流式) async function handleGenerate() { - if (!topicDetails.copywriting || !topicDetails.copywriting.trim()) { + if (!topicDetails.copywriting?.trim()) { message.warning('请输入文案内容') return } - // 检查是否选择了提示词风格 - if (!topicDetails.stylePrompt || !topicDetails.stylePrompt.trim()) { + if (!topicDetails.stylePrompt?.trim()) { message.warning('请先选择提示词风格') return } isGenerating.value = true - generatedContent.value = '' // 清空之前的内容 + generatedContent.value = '' try { - // 调用 callWorkflow 流式 API const requestData = { - audio_prompt: topicDetails.stylePrompt || '', // 音频提示词 - user_text: topicDetails.copywriting.trim(), // 用户输入内容 - amplitude: 50 // 幅度,默认50% + audio_prompt: topicDetails.stylePrompt, + user_text: topicDetails.copywriting.trim(), + amplitude: 50 } const ctrl = new AbortController() @@ -177,43 +205,72 @@ async function handleGenerate() { let isResolved = false await new Promise((resolve, reject) => { - // 设置超时 - const timeout = setTimeout(() => { + let timeout = null + + const cleanup = () => { + if (timeout) { + clearTimeout(timeout) + timeout = null + } + } + + timeout = setTimeout(() => { if (!isResolved) { + cleanup() ctrl.abort() reject(new Error('请求超时,请稍后重试')) } - }, 180000) // 3分钟超时 + }, 180000) CommonService.callWorkflowStream({ data: requestData, ctrl, onMessage: (event) => { + if (errorOccurred || !event?.data) return + + // 处理特殊标记 + const dataStr = event.data.trim() + if (dataStr === '[DONE]') { + cleanup() + if (!isResolved) { + isResolved = true + resolve() + } + return + } + + if (dataStr.startsWith('[TIMEOUT]')) { + cleanup() + if (!isResolved) { + errorOccurred = true + isResolved = true + reject(new Error(dataStr.replace('[TIMEOUT]', '').trim() || '请求超时')) + } + return + } + try { - if (errorOccurred) return - - const dataStr = event?.data || '' - if (!dataStr) return - - try { - const obj = JSON.parse(dataStr) - // 根据实际返回格式解析 - const piece = obj?.text || obj?.content || obj?.data || '' - if (piece) { - fullText += piece - generatedContent.value = fullText - } - } catch (parseErr) { - console.warn('解析流数据异常:', parseErr) + const obj = JSON.parse(event.data) + const piece = obj?.text || obj?.content || obj?.data || '' + if (piece) { + fullText += piece + generatedContent.value = fullText } } catch (e) { - console.warn('解析流数据异常:', e) + // 如果不是JSON,可能是纯文本 + if (event.data && !event.data.startsWith('[')) { + fullText += event.data + generatedContent.value = fullText + } else { + console.warn('解析流数据异常:', e) + } } }, onError: (err) => { - clearTimeout(timeout) + cleanup() if (!isResolved) { errorOccurred = true + isResolved = true ctrl.abort() const errorMsg = err?.message || '网络请求失败' console.error('SSE请求错误:', err) @@ -222,7 +279,7 @@ async function handleGenerate() { } }, onClose: () => { - clearTimeout(timeout) + cleanup() if (!isResolved) { isResolved = true resolve() @@ -241,123 +298,98 @@ async function handleGenerate() { } } -// 初始化:加载提示词 -onMounted(async () => { - // 等待用户信息加载 - if (userStore.userId) { - await loadUserPrompts() - } else if (userStore.isLoggedIn) { - // 如果已登录但userId未加载,等待一下 - setTimeout(async () => { - if (userStore.userId) { - await loadUserPrompts() - } - }, 500) +// 数据处理 +function extractAudioUrl(video) { + const urlList = video?.play_addr?.url_list + if (Array.isArray(urlList) && urlList.length > 0) { + // 优先使用最后一个,如果为空则使用第一个 + const lastUrl = urlList[urlList.length - 1] + const firstUrl = urlList[0] + return (lastUrl && lastUrl.trim()) || (firstUrl && firstUrl.trim()) || '' } -}) + return (video?.play_addr?.url && video.play_addr.url.trim()) || '' +} -// 切换平台 -// const switchPlatform = (platformId) => { -// activePlatform.value = platformId -// selectedTopic.value = null -// topicDetails.title = '' -// topicDetails.copywriting = '' -// topicDetails.stylePrompt = '' -// } +function extractCover(video) { + return video?.origin_cover?.url_list?.[0] + || video?.cover?.url_list?.[0] + || video?.dynamic_cover?.url_list?.[0] + || video?.animated_cover?.url_list?.[0] + || '' +} -/** - * 处理搜索结果响应 - */ function processSearchResults(response, startId = 1) { try { - // 提取 cursor(用于翻页) - const cursor = response?.data?.cursor || null - currentCursor.value = cursor - - // 处理搜索结果 + currentCursor.value = response?.data?.cursor || null const dataList = response?.data?.data || [] - const searchResults = dataList + + return dataList .map(el => el.aweme_info) - .filter(el => el) + .filter(Boolean) .map((item, index) => ({ id: startId + index, title: item.desc || '无标题', videoId: item.aweme_id, videoUrl: `https://www.douyin.com/video/${item.aweme_id}`, - // 封面图片:优先使用 origin_cover,其次 cover,再次 dynamic_cover,最后 animated_cover - cover: item?.video?.origin_cover?.url_list?.[0] - || item?.video?.cover?.url_list?.[0] - || item?.video?.dynamic_cover?.url_list?.[0] - || item?.video?.animated_cover?.url_list?.[0] - || item?.cover?.url_list?.[0] - || '', - // 作者信息 + audioUrl: extractAudioUrl(item.video), + cover: extractCover(item.video) || item?.cover?.url_list?.[0] || '', author: item.author?.nickname || item.author?.unique_id || '未知', - authorAvatar: item.author?.avatar_thumb?.url_list?.[0] || item.author?.avatar_larger?.url_list?.[0] || '', - // 统计数据 + authorAvatar: item.author?.avatar_thumb?.url_list?.[0] + || item.author?.avatar_larger?.url_list?.[0] + || '', playCount: item.statistics?.play_count || 0, diggCount: item.statistics?.digg_count || 0, commentCount: item.statistics?.comment_count || 0, shareCount: item.statistics?.share_count || 0, collectCount: item.statistics?.collect_count || 0, })) - - return searchResults } catch (error) { console.error('处理搜索结果失败:', error) return [] } } -/** - * 搜索热点 - */ -const handleSearch = async () => { - if (!searchKeyword.value.trim()) { +// 搜索功能 +async function handleSearch() { + const keyword = searchKeyword.value.trim() + if (!keyword) { message.warning('请输入搜索关键词') return } - // 重置参数 + // 重置状态 searchParams.offset = 0 - searchParams.keyword = searchKeyword.value.trim() + searchParams.keyword = keyword currentCursor.value = null hotTopics.value = [] - isLoading.value = true + try { - // 构建请求参数(POST JSON 格式) - // 注意:API 要求 sort_type、publish_time、content_type 为字符串类型 const urlParams = { - keyword: searchParams.keyword, + keyword: encodeURIComponent(searchParams.keyword), offset: String(searchParams.offset), - count: '20', sort_type: String(searchParams.sort_type), publish_time: String(searchParams.publish_time), - filter_duration: searchParams.filter_duration, // 已经是字符串 + filter_duration: searchParams.filter_duration, content_type: String(searchParams.content_type), } const response = await TikhubService.postTikHup({ type: InterfaceType.DOUYIN_SEARCH_GENERAL_SEARCH, methodType: MethodType.POST, - urlParams: urlParams, + urlParams, paramType: ParamType.JSON }) - // 处理搜索结果 const searchResults = processSearchResults(response) if (searchResults.length === 0) { message.warning('未找到相关结果') - hotTopics.value = [] return } - // 替换列表 hotTopics.value = searchResults message.success(`找到 ${searchResults.length} 个结果`) - } catch (error) { console.error('搜索失败:', error) message.error(error?.message || '搜索失败,请稍后重试') @@ -367,71 +399,41 @@ const handleSearch = async () => { } } -// 回车搜索 -const handleSearchKeypress = (event) => { - if (event.key === 'Enter' && !isLoading.value) { - handleSearch() +// 初始化 +onMounted(async () => { + if (userStore.userId) { + await loadUserPrompts() + } else if (userStore.isLoggedIn) { + setTimeout(async () => { + if (userStore.userId) await loadUserPrompts() + }, 500) } -} - -// 格式化数字,将大数字转换为万等单位 -const formatNumber = (num) => { - if (!num) return '0' - if (num >= 10000) { - return (num / 10000).toFixed(1) + 'w' - } - return num.toString() -} - -// 打开视频 -const openVideo = (topic, event) => { - event.stopPropagation() // 阻止事件冒泡,避免触发热点选择 - if (topic.videoUrl) { - window.open(topic.videoUrl, '_blank') - } -} - -// 截断标题,限制最大长度 -const truncateTitle = (title, maxLength = 30) => { - if (!title) return '' - if (title.length <= maxLength) return title - return title.substring(0, maxLength) + '...' -} - -// 处理图片加载错误 -const handleImageError = (event) => { - event.target.style.display = 'none' -} +})