Files
sionrui/frontend/app/web-gold/src/views/trends/Forecast.vue
2025-11-15 21:46:57 +08:00

1426 lines
38 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>