1426 lines
38 KiB
Vue
1426 lines
38 KiB
Vue
<script setup>
|
||
import { ref, reactive, computed, onMounted } from 'vue'
|
||
import { message } from 'ant-design-vue'
|
||
import TikhubService, { InterfaceType, MethodType, ParamType } from '@/api/tikhub'
|
||
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' })
|
||
|
||
// 状态管理
|
||
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,
|
||
sort_type: 1,
|
||
publish_time: 7,
|
||
filter_duration: '0',
|
||
content_type: 0
|
||
})
|
||
|
||
// 创作详情
|
||
const topicDetails = reactive({
|
||
title: '',
|
||
copywriting: '',
|
||
stylePrompt: '',
|
||
stylePromptId: null
|
||
})
|
||
|
||
// 提示词相关
|
||
const allPrompts = ref([])
|
||
const loadingPrompts = ref(false)
|
||
const showAllPromptsModal = ref(false)
|
||
const promptSearchKeyword = ref('')
|
||
const DISPLAY_COUNT = 6
|
||
|
||
// 计算属性
|
||
const displayPrompts = computed(() => allPrompts.value.slice(0, DISPLAY_COUNT))
|
||
|
||
const filteredPrompts = computed(() => {
|
||
if (!promptSearchKeyword.value.trim()) return allPrompts.value
|
||
const keyword = promptSearchKeyword.value.trim().toLowerCase()
|
||
return allPrompts.value.filter(p =>
|
||
p.name.toLowerCase().includes(keyword) ||
|
||
(p.content && p.content.toLowerCase().includes(keyword))
|
||
)
|
||
})
|
||
|
||
// 工具函数
|
||
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('用户未登录,无法加载提示词')
|
||
return
|
||
}
|
||
|
||
loadingPrompts.value = true
|
||
try {
|
||
const response = await UserPromptApi.getUserPromptPage({
|
||
pageNo: 1,
|
||
pageSize: 100,
|
||
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
|
||
topicDetails.stylePrompt = firstPrompt.content || ''
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('加载提示词失败:', error)
|
||
message.error('加载提示词失败')
|
||
} finally {
|
||
loadingPrompts.value = false
|
||
}
|
||
}
|
||
|
||
function selectPrompt(prompt) {
|
||
if (!prompt?.content) {
|
||
message.warning('提示词内容为空')
|
||
return
|
||
}
|
||
topicDetails.stylePromptId = prompt.id
|
||
topicDetails.stylePrompt = prompt.content
|
||
showAllPromptsModal.value = false
|
||
}
|
||
|
||
// 语音分析
|
||
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
|
||
|
||
// 自动选中第一个提示词(如果还没有选中)
|
||
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?.trim()) {
|
||
message.warning('请输入文案内容')
|
||
return
|
||
}
|
||
|
||
if (!topicDetails.stylePrompt?.trim()) {
|
||
message.warning('请先选择提示词风格')
|
||
return
|
||
}
|
||
|
||
isGenerating.value = true
|
||
generatedContent.value = ''
|
||
|
||
try {
|
||
const requestData = {
|
||
audio_prompt: topicDetails.stylePrompt,
|
||
user_text: topicDetails.copywriting.trim(),
|
||
amplitude: 50
|
||
}
|
||
|
||
const ctrl = new AbortController()
|
||
let fullText = ''
|
||
let errorOccurred = false
|
||
let isResolved = false
|
||
|
||
await new Promise((resolve, reject) => {
|
||
let timeout = null
|
||
|
||
const cleanup = () => {
|
||
if (timeout) {
|
||
clearTimeout(timeout)
|
||
timeout = null
|
||
}
|
||
}
|
||
|
||
timeout = setTimeout(() => {
|
||
if (!isResolved) {
|
||
cleanup()
|
||
ctrl.abort()
|
||
reject(new Error('请求超时,请稍后重试'))
|
||
}
|
||
}, 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 {
|
||
const obj = JSON.parse(event.data)
|
||
const piece = obj?.text || obj?.content || obj?.data || ''
|
||
if (piece) {
|
||
fullText += piece
|
||
generatedContent.value = fullText
|
||
}
|
||
} catch (e) {
|
||
// 如果不是JSON,可能是纯文本
|
||
if (event.data && !event.data.startsWith('[')) {
|
||
fullText += event.data
|
||
generatedContent.value = fullText
|
||
} else {
|
||
console.warn('解析流数据异常:', e)
|
||
}
|
||
}
|
||
},
|
||
onError: (err) => {
|
||
cleanup()
|
||
if (!isResolved) {
|
||
errorOccurred = true
|
||
isResolved = true
|
||
ctrl.abort()
|
||
const errorMsg = err?.message || '网络请求失败'
|
||
console.error('SSE请求错误:', err)
|
||
message.error(errorMsg)
|
||
reject(new Error(errorMsg))
|
||
}
|
||
},
|
||
onClose: () => {
|
||
cleanup()
|
||
if (!isResolved) {
|
||
isResolved = true
|
||
resolve()
|
||
}
|
||
}
|
||
})
|
||
})
|
||
|
||
generatedContent.value = fullText.trim()
|
||
message.success('文案生成成功')
|
||
} catch (error) {
|
||
console.error('生成文案失败:', error)
|
||
message.error('生成文案失败,请重试')
|
||
} finally {
|
||
isGenerating.value = false
|
||
}
|
||
}
|
||
|
||
// 数据处理
|
||
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()) || ''
|
||
}
|
||
|
||
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 {
|
||
currentCursor.value = response?.data?.cursor || null
|
||
const dataList = response?.data?.data || []
|
||
|
||
return dataList
|
||
.map(el => el.aweme_info)
|
||
.filter(Boolean)
|
||
.map((item, index) => ({
|
||
id: startId + index,
|
||
title: item.desc || '无标题',
|
||
videoId: item.aweme_id,
|
||
videoUrl: `https://www.douyin.com/video/${item.aweme_id}`,
|
||
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]
|
||
|| '',
|
||
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,
|
||
}))
|
||
} catch (error) {
|
||
console.error('处理搜索结果失败:', error)
|
||
return []
|
||
}
|
||
}
|
||
|
||
// 搜索功能
|
||
async function handleSearch() {
|
||
const keyword = searchKeyword.value.trim()
|
||
if (!keyword) {
|
||
message.warning('请输入搜索关键词')
|
||
return
|
||
}
|
||
|
||
// 重置状态
|
||
searchParams.offset = 0
|
||
searchParams.keyword = keyword
|
||
currentCursor.value = null
|
||
hotTopics.value = []
|
||
isLoading.value = true
|
||
|
||
try {
|
||
const urlParams = {
|
||
keyword:searchParams.keyword,
|
||
offset: String(searchParams.offset),
|
||
sort_type: String(searchParams.sort_type),
|
||
publish_time: String(searchParams.publish_time),
|
||
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,
|
||
paramType: ParamType.JSON
|
||
})
|
||
|
||
const searchResults = processSearchResults(response)
|
||
|
||
if (searchResults.length === 0) {
|
||
message.warning('未找到相关结果')
|
||
return
|
||
}
|
||
|
||
hotTopics.value = searchResults
|
||
message.success(`找到 ${searchResults.length} 个结果`)
|
||
} catch (error) {
|
||
console.error('搜索失败:', error)
|
||
message.error(error?.message || '搜索失败,请稍后重试')
|
||
hotTopics.value = []
|
||
} finally {
|
||
isLoading.value = false
|
||
}
|
||
}
|
||
|
||
// 初始化
|
||
onMounted(async () => {
|
||
if (userStore.userId) {
|
||
await loadUserPrompts()
|
||
} else if (userStore.isLoggedIn) {
|
||
setTimeout(async () => {
|
||
if (userStore.userId) await loadUserPrompts()
|
||
}, 500)
|
||
}
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<div class="fc-page">
|
||
<div class="fc-grid">
|
||
<!-- 左侧:热点列表 -->
|
||
<section class="fc-left">
|
||
<div class="fc-title">热点预测</div>
|
||
|
||
<!-- 搜索框 -->
|
||
<div class="search-box">
|
||
<div class="search-input-wrapper">
|
||
<input
|
||
v-model="searchKeyword"
|
||
type="text"
|
||
placeholder="输入关键词搜索抖音热点..."
|
||
class="search-input"
|
||
:disabled="isLoading"
|
||
@keypress="handleSearchKeypress"
|
||
/>
|
||
<button
|
||
@click="handleSearch"
|
||
:disabled="isLoading || !searchKeyword.trim()"
|
||
class="search-btn"
|
||
>
|
||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
|
||
<!-- 搜索参数配置 -->
|
||
<div class="search-params">
|
||
<div class="param-row">
|
||
<div class="param-item">
|
||
<label class="param-label">排序方式</label>
|
||
<select v-model="searchParams.sort_type" class="param-select">
|
||
<option value="0">综合排序</option>
|
||
<option value="1">最多点赞</option>
|
||
<option value="2">最新发布</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div class="param-item">
|
||
<label class="param-label">发布时间</label>
|
||
<select v-model="searchParams.publish_time" class="param-select">
|
||
<option value="0">不限</option>
|
||
<option value="1">最近一天</option>
|
||
<option value="7">最近一周</option>
|
||
<option value="180">最近半年</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div class="param-item">
|
||
<label class="param-label">视频时长</label>
|
||
<select v-model="searchParams.filter_duration" class="param-select">
|
||
<option value="0">不限</option>
|
||
<option value="0-1">1分钟以内</option>
|
||
<option value="1-5">1-5分钟</option>
|
||
<option value="5-10000">5分钟以上</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="param-row">
|
||
<div class="param-item">
|
||
<label class="param-label">内容类型</label>
|
||
<select v-model="searchParams.content_type" class="param-select">
|
||
<option value="0">不限</option>
|
||
<option value="1">视频</option>
|
||
<option value="2">图集</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 热点列表 -->
|
||
<div class="topic-list">
|
||
<div class="p-2">
|
||
<!-- 空状态 -->
|
||
<div v-if="hotTopics.length === 0" class="empty-state">
|
||
<svg class="empty-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
|
||
<circle cx="9" cy="9" r="3"></circle>
|
||
</svg>
|
||
<p class="empty-text">搜索抖音热点内容</p>
|
||
<p class="empty-hint">输入关键词,开始搜索热门内容</p>
|
||
</div>
|
||
|
||
<!-- 热点列表 -->
|
||
<div
|
||
v-for="topic in hotTopics"
|
||
:key="topic.id"
|
||
@click="handleCreate(topic)"
|
||
class="topic-item"
|
||
:class="{ 'topic-item--selected': selectedTopic === topic.id }"
|
||
>
|
||
<div class="flex flex-col flex-1 min-w-0">
|
||
<!-- 封面和标题 -->
|
||
<div class="flex items-start gap-3 mb-2">
|
||
<!-- 封面图片 -->
|
||
<div class="topic-cover-wrapper">
|
||
<img
|
||
v-if="topic.cover"
|
||
:src="topic.cover"
|
||
alt="封面"
|
||
class="topic-cover"
|
||
@error="handleImageError"
|
||
/>
|
||
<div v-else class="topic-cover-placeholder">
|
||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
|
||
</svg>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 标题和作者 -->
|
||
<div class="flex-1 min-w-0">
|
||
<div class="flex items-center mb-1">
|
||
<span class="topic-number">{{ topic.id }}</span>
|
||
<span
|
||
v-if="topic.videoUrl"
|
||
@click="openVideo(topic, $event)"
|
||
class="flex-1 topic-title topic-title--clickable"
|
||
:title="topic.title"
|
||
>
|
||
{{ truncateTitle(topic.title) }}
|
||
</span>
|
||
<span v-else class="flex-1 topic-title" :title="topic.title">
|
||
{{ truncateTitle(topic.title) }}
|
||
</span>
|
||
</div>
|
||
<!-- 作者信息 -->
|
||
<div class="flex items-center gap-2 text-xs text-gray-500 topic-author">
|
||
<img
|
||
v-if="topic.authorAvatar"
|
||
:src="topic.authorAvatar"
|
||
alt="作者头像"
|
||
class="author-avatar"
|
||
@error="handleImageError"
|
||
/>
|
||
<span>{{ topic.author }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<!-- 统计信息 -->
|
||
<div
|
||
v-if="topic.diggCount || topic.playCount || topic.commentCount || topic.collectCount || topic.shareCount"
|
||
class="flex flex-wrap items-center gap-4 text-xs text-gray-500 topic-stats"
|
||
>
|
||
<span v-if="topic.playCount" class="stat-item">
|
||
<svg class="inline w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||
<path d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z" />
|
||
</svg>
|
||
播放 {{ formatNumber(topic.playCount) }}
|
||
</span>
|
||
<span v-if="topic.diggCount" class="stat-item">
|
||
<svg class="inline w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||
<path d="M2 10.5a1.5 1.5 0 113 0v6a1.5 1.5 0 01-3 0v-6zM6 10.333v5.834a1 1 0 001.555.832L12 13.202a5 5 0 001.196-.599l.707-.707a1 1 0 00.293-.707V8.465a1 1 0 00-1.707-.707L12 8.465V5a2 2 0 00-2-2H7a1 1 0 000 2h3v3z" />
|
||
</svg>
|
||
点赞 {{ formatNumber(topic.diggCount) }}
|
||
</span>
|
||
<span v-if="topic.commentCount" class="stat-item">
|
||
<svg class="inline w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||
<path fill-rule="evenodd" d="M18 10c0 3.866-3.582 7-8 7a8.841 8.841 0 01-4.083-.98L2 17l1.338-3.123C2.493 12.767 2 11.434 2 10c0-3.866 3.582-7 8-7s8 3.134 8 7zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z" clip-rule="evenodd" />
|
||
</svg>
|
||
评论 {{ formatNumber(topic.commentCount) }}
|
||
</span>
|
||
<span v-if="topic.collectCount" class="stat-item">
|
||
<svg class="inline w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||
<path fill-rule="evenodd" d="M3.172 5.172a4 4 0 015.656 0L10 6.343l1.172-1.171a4 4 0 115.656 5.656L10 17.657l-6.828-6.829a4 4 0 010-5.656z" clip-rule="evenodd" />
|
||
</svg>
|
||
收藏 {{ formatNumber(topic.collectCount) }}
|
||
</span>
|
||
<span v-if="topic.shareCount" class="stat-item">
|
||
<svg class="inline w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||
<path d="M15 8a3 3 0 10-2.977-2.63l-4.94 2.47a3 3 0 100 4.319l4.94 2.47a3 3 0 10.895-1.789l-4.94-2.47a3.027 3.027 0 000-.74l4.94-2.47C13.456 7.68 14.19 8 15 8z" />
|
||
</svg>
|
||
分享 {{ formatNumber(topic.shareCount) }}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<GradientButton
|
||
text="创作"
|
||
size="small"
|
||
@click.stop="handleCreate(topic)"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Loading 指示器 -->
|
||
<div v-if="isLoading" class="loading-indicator">
|
||
<a-spin size="small" />
|
||
<span class="loading-text">搜索中...</span>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- 右侧:创作详情 -->
|
||
<section class="fc-right">
|
||
<div class="fc-title">创作详情</div>
|
||
|
||
<div class="detail-content">
|
||
<!-- 热点标题 -->
|
||
<div>
|
||
<label class="form-label">热点标题</label>
|
||
<input
|
||
v-model="topicDetails.title"
|
||
type="text"
|
||
placeholder="选择左侧热点或手动输入标题"
|
||
class="form-input"
|
||
/>
|
||
</div>
|
||
|
||
<!-- 文案 -->
|
||
<div>
|
||
<div class="form-label-wrapper">
|
||
<label class="form-label">文案</label>
|
||
<span v-if="isAnalyzing" class="analyzing-indicator">
|
||
<a-spin size="small" />
|
||
<span class="analyzing-text">正在分析语音...</span>
|
||
</span>
|
||
</div>
|
||
<textarea
|
||
v-model="topicDetails.copywriting"
|
||
rows="5"
|
||
placeholder="输入或AI生成文案内容"
|
||
class="form-textarea"
|
||
:disabled="isAnalyzing"
|
||
></textarea>
|
||
</div>
|
||
|
||
<!-- 风格提示词 -->
|
||
<div>
|
||
<div class="form-label-wrapper">
|
||
<label class="form-label">风格提示词</label>
|
||
<a-button
|
||
v-if="allPrompts.length > DISPLAY_COUNT"
|
||
size="small"
|
||
type="link"
|
||
@click="showAllPromptsModal = true"
|
||
style="padding: 0; height: auto; font-size: 12px;"
|
||
>
|
||
更多 ({{ allPrompts.length }})
|
||
</a-button>
|
||
</div>
|
||
|
||
<!-- 提示词标签展示区域 -->
|
||
<div v-if="displayPrompts.length > 0" class="prompt-tags-container">
|
||
<div class="prompt-tags-grid">
|
||
<div
|
||
v-for="prompt in displayPrompts"
|
||
:key="prompt.id"
|
||
class="prompt-tag"
|
||
:class="{ 'prompt-tag-selected': topicDetails.stylePromptId === prompt.id }"
|
||
@click="selectPrompt(prompt)"
|
||
>
|
||
<span class="prompt-tag-name">{{ prompt.name }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 空状态 -->
|
||
<div v-else-if="!loadingPrompts" class="prompt-empty">
|
||
<div style="color: var(--color-text-secondary); font-size: 12px; text-align: center; padding: 20px;">
|
||
您可以在视频分析页面保存风格
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 加载状态 -->
|
||
<div v-else class="prompt-loading">
|
||
<a-spin size="small" />
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 生成文案按钮 -->
|
||
<div class="pt-2">
|
||
<button
|
||
class="cyber-button"
|
||
:class="{
|
||
'cyber-button--disabled': !topicDetails.copywriting?.trim() || !topicDetails.stylePromptId || isGenerating,
|
||
'cyber-button--loading': isGenerating
|
||
}"
|
||
:disabled="!topicDetails.copywriting?.trim() || !topicDetails.stylePromptId || isGenerating"
|
||
@click="handleGenerate"
|
||
>
|
||
<span v-if="isGenerating" class="cyber-button__loading">
|
||
<span class="cyber-button__spinner"></span>
|
||
<span class="cyber-button__text">生成中...</span>
|
||
</span>
|
||
<span v-else class="cyber-button__content">
|
||
<span class="cyber-button__glow"></span>
|
||
<span class="cyber-button__text">生成爆款</span>
|
||
<span class="cyber-button__arrow">→</span>
|
||
</span>
|
||
</button>
|
||
</div>
|
||
|
||
<!-- 生成的文案显示区域 -->
|
||
<div v-if="generatedContent" class="generated-content-section">
|
||
<div class="form-label">生成结果</div>
|
||
<div class="generated-content-wrapper">
|
||
<div class="generated-content-text">{{ generatedContent }}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- 更多提示词弹窗 -->
|
||
<a-modal
|
||
v-model:open="showAllPromptsModal"
|
||
title="选择提示词风格"
|
||
:width="600"
|
||
:footer="null"
|
||
>
|
||
<div class="prompt-modal-content">
|
||
<!-- 搜索框 -->
|
||
<a-input
|
||
v-model:value="promptSearchKeyword"
|
||
placeholder="搜索提示词..."
|
||
style="margin-bottom: 16px;"
|
||
allow-clear
|
||
>
|
||
<template #prefix>
|
||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
|
||
</svg>
|
||
</template>
|
||
</a-input>
|
||
|
||
<!-- 提示词列表 -->
|
||
<div v-if="filteredPrompts.length > 0" class="all-prompts-grid">
|
||
<div
|
||
v-for="prompt in filteredPrompts"
|
||
:key="prompt.id"
|
||
class="all-prompt-tag"
|
||
:class="{ 'all-prompt-tag-selected': topicDetails.stylePromptId === prompt.id }"
|
||
@click="selectPrompt(prompt)"
|
||
>
|
||
<span class="all-prompt-tag-name">{{ prompt.name }}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 空状态 -->
|
||
<div v-else style="text-align: center; padding: 40px; color: var(--color-text-secondary);">
|
||
没有找到匹配的提示词
|
||
</div>
|
||
</div>
|
||
</a-modal>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.fc-page {
|
||
color: var(--color-text);
|
||
}
|
||
|
||
.fc-grid {
|
||
display: grid;
|
||
grid-template-columns: 1fr;
|
||
gap: 16px;
|
||
}
|
||
|
||
@media (min-width: 1024px) {
|
||
.fc-grid {
|
||
grid-template-columns: 1fr 1fr;
|
||
}
|
||
}
|
||
|
||
.fc-left,
|
||
.fc-right {
|
||
background: var(--color-surface);
|
||
border: 1px solid var(--color-border);
|
||
border-radius: var(--radius-card);
|
||
box-shadow: var(--shadow-inset-card);
|
||
padding: 16px;
|
||
}
|
||
|
||
.fc-title {
|
||
font-size: 14px;
|
||
color: var(--color-text-secondary);
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
/* 搜索框 */
|
||
.search-box {
|
||
margin-bottom: 12px;
|
||
padding-bottom: 12px;
|
||
border-bottom: 1px solid var(--color-border);
|
||
}
|
||
|
||
.search-params {
|
||
margin-top: 12px;
|
||
padding-top: 12px;
|
||
border-top: 1px solid var(--color-border);
|
||
}
|
||
|
||
.param-row {
|
||
display: flex;
|
||
gap: 12px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.param-item {
|
||
flex: 1;
|
||
min-width: 120px;
|
||
}
|
||
|
||
.param-label {
|
||
display: block;
|
||
font-size: 12px;
|
||
color: var(--color-text-secondary);
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
.param-select {
|
||
width: 100%;
|
||
padding: 6px 8px;
|
||
font-size: 13px;
|
||
color: var(--color-text);
|
||
background: var(--color-bg);
|
||
border: 1px solid var(--color-border);
|
||
border-radius: 4px;
|
||
transition: all 0.2s;
|
||
}
|
||
|
||
.param-select:focus {
|
||
outline: none;
|
||
border-color: var(--color-primary);
|
||
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.1);
|
||
}
|
||
|
||
.param-select:hover {
|
||
border-color: var(--color-primary);
|
||
}
|
||
|
||
.search-input-wrapper {
|
||
display: flex;
|
||
gap: 8px;
|
||
}
|
||
|
||
.search-input {
|
||
flex: 1;
|
||
padding: 8px 12px;
|
||
font-size: 14px;
|
||
color: var(--color-text);
|
||
background: var(--color-bg);
|
||
border: 1px solid var(--color-border);
|
||
border-radius: 6px;
|
||
transition: all 0.2s;
|
||
}
|
||
|
||
.search-input:focus {
|
||
outline: none;
|
||
border-color: var(--color-primary);
|
||
box-shadow: 0 0 0 3px rgba(0, 176, 48, 0.1);
|
||
}
|
||
|
||
.search-input:disabled {
|
||
opacity: 0.6;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
.search-input::placeholder {
|
||
color: var(--color-text-secondary);
|
||
opacity: 0.6;
|
||
}
|
||
|
||
.search-btn {
|
||
padding: 8px 16px;
|
||
background: var(--color-primary);
|
||
color: white;
|
||
border: none;
|
||
border-radius: 6px;
|
||
cursor: pointer;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
transition: all 0.2s;
|
||
min-width: 48px;
|
||
}
|
||
|
||
.search-btn:hover:not(:disabled) {
|
||
background: var(--color-primary);
|
||
filter: brightness(1.1);
|
||
transform: scale(1.05);
|
||
}
|
||
|
||
.search-btn:disabled {
|
||
opacity: 0.5;
|
||
cursor: not-allowed;
|
||
transform: none;
|
||
}
|
||
|
||
/* Loading 指示器 */
|
||
.loading-indicator {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 8px;
|
||
padding: 16px;
|
||
color: var(--color-text-secondary);
|
||
}
|
||
|
||
.loading-text {
|
||
font-size: 14px;
|
||
}
|
||
|
||
/* 热点列表 */
|
||
.topic-list {
|
||
max-height: 500px;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.topic-list::-webkit-scrollbar {
|
||
width: 6px;
|
||
}
|
||
|
||
.topic-list::-webkit-scrollbar-track {
|
||
background: var(--color-bg);
|
||
border-radius: 10px;
|
||
}
|
||
|
||
.topic-list::-webkit-scrollbar-thumb {
|
||
background: var(--color-border);
|
||
border-radius: 10px;
|
||
}
|
||
|
||
.topic-list::-webkit-scrollbar-thumb:hover {
|
||
background: var(--color-text-secondary);
|
||
}
|
||
|
||
/* 空状态 */
|
||
.empty-state {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 80px 20px;
|
||
text-align: center;
|
||
}
|
||
|
||
.empty-icon {
|
||
width: 80px;
|
||
height: 80px;
|
||
color: var(--color-text-secondary);
|
||
opacity: 0.4;
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.empty-text {
|
||
font-size: 16px;
|
||
font-weight: 500;
|
||
color: var(--color-text);
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.empty-hint {
|
||
font-size: 14px;
|
||
color: var(--color-text-secondary);
|
||
opacity: 0.6;
|
||
}
|
||
|
||
/* 热点项 */
|
||
.topic-item {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
justify-content: space-between;
|
||
padding: 12px;
|
||
margin-bottom: 8px;
|
||
border-radius: 6px;
|
||
cursor: pointer;
|
||
transition: all 0.15s;
|
||
border: 1px solid transparent;
|
||
}
|
||
|
||
.topic-item:hover {
|
||
background: var(--color-bg);
|
||
}
|
||
|
||
.topic-item--selected {
|
||
background: var(--color-bg);
|
||
border-color: var(--color-primary);
|
||
}
|
||
|
||
.topic-number {
|
||
flex-shrink: 0;
|
||
margin-right: 12px;
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
color: var(--color-text-secondary);
|
||
}
|
||
|
||
.topic-title {
|
||
font-size: 14px;
|
||
color: var(--color-text);
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
max-width: 100%;
|
||
}
|
||
|
||
.topic-title--clickable {
|
||
cursor: pointer;
|
||
color: #1890ff;
|
||
transition: all 0.2s;
|
||
}
|
||
|
||
.topic-title--clickable:hover {
|
||
text-decoration: underline;
|
||
color: #40a9ff;
|
||
opacity: 0.8;
|
||
}
|
||
|
||
/* 封面图片 */
|
||
.topic-cover-wrapper {
|
||
flex-shrink: 0;
|
||
width: 80px;
|
||
height: 45px;
|
||
border-radius: 4px;
|
||
overflow: hidden;
|
||
background: var(--color-bg);
|
||
border: 1px solid var(--color-border);
|
||
}
|
||
|
||
.topic-cover {
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: cover;
|
||
}
|
||
|
||
.topic-cover-placeholder {
|
||
width: 100%;
|
||
height: 100%;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
color: var(--color-text-secondary);
|
||
opacity: 0.4;
|
||
}
|
||
|
||
/* 作者信息 */
|
||
.topic-author {
|
||
padding-left: 32px;
|
||
margin-top: 4px;
|
||
}
|
||
|
||
.author-avatar {
|
||
width: 16px;
|
||
height: 16px;
|
||
border-radius: 50%;
|
||
object-fit: cover;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
/* 统计信息 */
|
||
.topic-stats {
|
||
padding-left: 32px;
|
||
margin-top: 4px;
|
||
}
|
||
|
||
.stat-item {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
color: var(--color-text-secondary);
|
||
}
|
||
|
||
.stat-item svg {
|
||
width: 12px;
|
||
height: 12px;
|
||
vertical-align: middle;
|
||
}
|
||
|
||
/* 详情内容 */
|
||
.detail-content {
|
||
color: var(--color-text);
|
||
}
|
||
|
||
.form-label {
|
||
display: block;
|
||
margin-bottom: 8px;
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
color: var(--color-text);
|
||
}
|
||
|
||
.form-input,
|
||
.form-textarea {
|
||
width: 100%;
|
||
padding: 8px 12px;
|
||
color: var(--color-text);
|
||
background: var(--color-bg);
|
||
border: 1px solid var(--color-border);
|
||
border-radius: 6px;
|
||
transition: all 0.2s;
|
||
font-size: 14px;
|
||
font-family: inherit;
|
||
}
|
||
|
||
.form-input::placeholder,
|
||
.form-textarea::placeholder {
|
||
color: var(--color-text-secondary);
|
||
opacity: 0.6;
|
||
}
|
||
|
||
.form-input:focus,
|
||
.form-textarea:focus {
|
||
outline: none;
|
||
border-color: var(--color-primary);
|
||
box-shadow: 0 0 0 3px rgba(0, 176, 48, 0.1);
|
||
}
|
||
|
||
.form-textarea {
|
||
resize: none;
|
||
}
|
||
|
||
.form-label-wrapper {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
/* 提示词标签样式 */
|
||
.prompt-tags-container {
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.prompt-tags-grid {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
}
|
||
|
||
.prompt-tag {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
padding: 6px 14px;
|
||
background: var(--color-surface);
|
||
border: 1px solid var(--color-border);
|
||
border-radius: 16px;
|
||
cursor: pointer;
|
||
transition: all 0.2s ease;
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
color: var(--color-text);
|
||
}
|
||
|
||
.prompt-tag:hover {
|
||
border-color: var(--color-primary);
|
||
background: rgba(24, 144, 255, 0.08);
|
||
transform: translateY(-1px);
|
||
}
|
||
|
||
.prompt-tag-selected {
|
||
border-color: var(--color-primary);
|
||
background: var(--color-primary);
|
||
color: #fff;
|
||
}
|
||
|
||
.prompt-tag-selected:hover {
|
||
background: var(--color-primary);
|
||
filter: brightness(1.1);
|
||
}
|
||
|
||
.prompt-tag-name {
|
||
white-space: nowrap;
|
||
user-select: none;
|
||
}
|
||
|
||
.prompt-empty {
|
||
padding: 20px;
|
||
text-align: center;
|
||
}
|
||
|
||
.prompt-loading {
|
||
display: flex;
|
||
justify-content: center;
|
||
padding: 20px;
|
||
}
|
||
|
||
/* 生成结果区域 */
|
||
.generated-content-section {
|
||
margin-top: 24px;
|
||
padding-top: 24px;
|
||
border-top: 1px solid var(--color-border);
|
||
}
|
||
|
||
.generated-content-wrapper {
|
||
margin-top: 12px;
|
||
padding: 16px;
|
||
background: var(--color-bg);
|
||
border: 1px solid var(--color-border);
|
||
border-radius: 6px;
|
||
max-height: 400px;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.generated-content-text {
|
||
color: var(--color-text);
|
||
font-size: 14px;
|
||
line-height: 1.6;
|
||
white-space: pre-wrap;
|
||
word-wrap: break-word;
|
||
}
|
||
|
||
/* 更多提示词弹窗样式 */
|
||
.prompt-modal-content {
|
||
max-height: 500px;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.all-prompts-grid {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
}
|
||
|
||
.all-prompt-tag {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
padding: 8px 16px;
|
||
background: var(--color-surface);
|
||
border: 1px solid var(--color-border);
|
||
border-radius: 16px;
|
||
cursor: pointer;
|
||
transition: all 0.2s ease;
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
color: var(--color-text);
|
||
}
|
||
|
||
.all-prompt-tag:hover {
|
||
border-color: var(--color-primary);
|
||
background: rgba(24, 144, 255, 0.08);
|
||
transform: translateY(-1px);
|
||
}
|
||
|
||
.all-prompt-tag-selected {
|
||
border-color: var(--color-primary);
|
||
background: var(--color-primary);
|
||
color: #fff;
|
||
}
|
||
|
||
.all-prompt-tag-selected:hover {
|
||
background: var(--color-primary);
|
||
filter: brightness(1.1);
|
||
}
|
||
|
||
.all-prompt-tag-name {
|
||
white-space: nowrap;
|
||
user-select: none;
|
||
}
|
||
|
||
/* 赛博朋克风格按钮 - 蓝色主题 */
|
||
.cyber-button {
|
||
position: relative;
|
||
width: 100%;
|
||
padding: 12px 24px;
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
color: #1890ff;
|
||
background: transparent;
|
||
border: 1px solid #1890ff;
|
||
border-radius: 0;
|
||
cursor: pointer;
|
||
overflow: hidden;
|
||
transition: all 0.3s ease;
|
||
text-transform: uppercase;
|
||
letter-spacing: 1px;
|
||
box-shadow:
|
||
0 0 10px rgba(24, 144, 255, 0.3),
|
||
inset 0 0 10px rgba(24, 144, 255, 0.1);
|
||
}
|
||
|
||
.cyber-button::before {
|
||
content: '';
|
||
position: absolute;
|
||
top: 0;
|
||
left: -100%;
|
||
width: 100%;
|
||
height: 100%;
|
||
background: linear-gradient(90deg, transparent, rgba(24, 144, 255, 0.2), transparent);
|
||
transition: left 0.5s ease;
|
||
}
|
||
|
||
.cyber-button:hover::before {
|
||
left: 100%;
|
||
}
|
||
|
||
.cyber-button:hover:not(.cyber-button--disabled) {
|
||
background: rgba(24, 144, 255, 0.1);
|
||
box-shadow:
|
||
0 0 20px rgba(24, 144, 255, 0.5),
|
||
0 0 40px rgba(24, 144, 255, 0.3),
|
||
inset 0 0 20px rgba(24, 144, 255, 0.2);
|
||
transform: translateY(-1px);
|
||
}
|
||
|
||
.cyber-button:active:not(.cyber-button--disabled) {
|
||
transform: translateY(0);
|
||
box-shadow:
|
||
0 0 15px rgba(24, 144, 255, 0.4),
|
||
inset 0 0 15px rgba(24, 144, 255, 0.15);
|
||
}
|
||
|
||
.cyber-button__content {
|
||
position: relative;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 8px;
|
||
z-index: 1;
|
||
}
|
||
|
||
.cyber-button__glow {
|
||
position: absolute;
|
||
top: 50%;
|
||
left: 50%;
|
||
width: 0;
|
||
height: 0;
|
||
border-radius: 50%;
|
||
background: rgba(24, 144, 255, 0.4);
|
||
transform: translate(-50%, -50%);
|
||
transition: width 0.6s ease, height 0.6s ease;
|
||
pointer-events: none;
|
||
}
|
||
|
||
.cyber-button:hover:not(.cyber-button--disabled) .cyber-button__glow {
|
||
width: 200px;
|
||
height: 200px;
|
||
}
|
||
|
||
.cyber-button__text {
|
||
position: relative;
|
||
z-index: 2;
|
||
text-shadow: 0 0 10px rgba(24, 144, 255, 0.8);
|
||
}
|
||
|
||
.cyber-button__arrow {
|
||
position: relative;
|
||
z-index: 2;
|
||
transition: transform 0.3s ease;
|
||
font-size: 16px;
|
||
}
|
||
|
||
.cyber-button:hover:not(.cyber-button--disabled) .cyber-button__arrow {
|
||
transform: translateX(4px);
|
||
}
|
||
|
||
.cyber-button__loading {
|
||
position: relative;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 8px;
|
||
z-index: 1;
|
||
}
|
||
|
||
.cyber-button__spinner {
|
||
width: 14px;
|
||
height: 14px;
|
||
border: 2px solid rgba(24, 144, 255, 0.3);
|
||
border-top-color: #1890ff;
|
||
border-radius: 50%;
|
||
animation: cyber-spin 0.8s linear infinite;
|
||
}
|
||
|
||
@keyframes cyber-spin {
|
||
to {
|
||
transform: rotate(360deg);
|
||
}
|
||
}
|
||
|
||
.cyber-button--disabled,
|
||
.cyber-button:disabled {
|
||
opacity: 0.4;
|
||
cursor: not-allowed;
|
||
border-color: rgba(24, 144, 255, 0.3);
|
||
box-shadow: none;
|
||
color: rgba(24, 144, 255, 0.5);
|
||
}
|
||
|
||
.cyber-button--disabled:hover,
|
||
.cyber-button:disabled:hover {
|
||
background: transparent;
|
||
box-shadow: none;
|
||
transform: none;
|
||
}
|
||
|
||
.cyber-button--loading {
|
||
cursor: wait;
|
||
}
|
||
|
||
.cyber-button--loading:hover {
|
||
transform: none;
|
||
}
|
||
|
||
/* 语音分析loading指示器 */
|
||
.analyzing-indicator {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
font-size: 12px;
|
||
color: var(--color-primary);
|
||
}
|
||
|
||
.analyzing-text {
|
||
color: var(--color-primary);
|
||
}
|
||
</style>
|