feat: 热点改进

This commit is contained in:
2026-02-25 01:24:57 +08:00
parent cd5a2f0c7d
commit 7285534405
8 changed files with 537 additions and 122 deletions

View File

@@ -0,0 +1,75 @@
/**
* Forecast 文案改写 API
*/
import { fetchEventSource } from '@microsoft/fetch-event-source'
import tokenManager from '@gold/utils/token-manager'
import { API_BASE } from '@gold/config/api'
const BASE_URL = `${API_BASE.APP_TIK}`
/**
* 流式文案改写SSE
* @param {Object} options - 请求配置
* @param {number} options.agentId - 智能体ID
* @param {string} options.userText - 用户输入文案
* @param {number} [options.level] - 改写级别/强度
* @param {string} [options.modelType] - 模型类型forecast_standard/forecast_meiju
* @param {AbortController} [options.ctrl] - 取消控制器
* @param {Function} options.onMessage - 消息回调
* @param {Function} [options.onError] - 错误回调
* @param {Function} [options.onClose] - 关闭回调
*/
export async function rewriteStream(options) {
const {
agentId,
userText,
level = 50,
modelType = 'forecast_standard',
ctrl,
onMessage,
onError,
onClose
} = options || {}
const token = tokenManager.getAccessToken()
return fetchEventSource(`${BASE_URL}/dify/forecast/rewrite`, {
method: 'post',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
'tenant-id': import.meta.env?.VITE_TENANT_ID
},
openWhenHidden: true,
body: JSON.stringify({
agentId,
userText,
level,
modelType
}),
onmessage: (event) => {
if (typeof onMessage === 'function') {
try {
const data = JSON.parse(event.data)
// 解析 CommonResult 包装
const result = data.code === 0 ? data.data : data
onMessage(result)
} catch (e) {
console.error('解析 SSE 数据失败:', e)
}
}
},
onerror: (err) => {
if (typeof onError === 'function') {
onError(err)
}
throw err // 不重试
},
onclose: () => {
if (typeof onClose === 'function') {
onClose()
}
},
signal: ctrl ? ctrl.signal : undefined
})
}

View File

@@ -1,11 +1,10 @@
<script setup>
import { onMounted, reactive, ref } from 'vue'
import { onMounted, reactive, ref, computed } from 'vue'
import { message } from 'ant-design-vue'
import { CommonService } from '@/api/common'
import TikhubService, { InterfaceType, MethodType, ParamType } from '@/api/tikhub'
import { UserPromptApi } from '@/api/userPrompt'
import PromptSelector from '@/components/PromptSelector.vue'
import { rewriteStream } from '@/api/forecast'
import { getAgentList } from '@/api/agent'
import { useUserStore } from '@/stores/user'
import { getVoiceText } from '@gold/hooks/web/useVoiceText'
@@ -25,25 +24,42 @@ const generatedContent = ref('')
// 搜索参数
const searchParams = reactive({
keyword: '',
offset: 0,
sort_type: '1',
publish_time: '7',
cursor: 0,
sort_type: '0',
publish_time: '0',
filter_duration: '0',
content_type: '0'
content_type: '0',
search_id: '',
backtrace: ''
})
// 创作详情
const topicDetails = reactive({
title: '',
copywriting: '',
stylePrompt: '',
stylePromptId: null
selectedAgentId: null,
modelType: 'forecast_standard',
level: 50
})
// 提示词相关
const allPrompts = ref([])
const loadingPrompts = ref(false)
const promptSearchKeyword = ref('')
// 智能体列表
const agentList = ref([])
const loadingAgents = ref(false)
const agentSearchKeyword = ref('')
// 当前选中的智能体
const selectedAgent = ref(null)
// 过滤后的智能体列表
const filteredAgentList = computed(() => {
if (!agentSearchKeyword.value.trim()) {
return agentList.value
}
const keyword = agentSearchKeyword.value.trim().toLowerCase()
return agentList.value.filter(agent =>
agent.name.toLowerCase().includes(keyword) ||
(agent.description && agent.description.toLowerCase().includes(keyword))
)
})
// 工具函数
function formatNumber(num) {
@@ -80,39 +96,41 @@ async function copyContent() {
}
}
// 提示词管理
async function loadUserPrompts() {
if (!userStore.userId) {
console.warn('用户未登录,无法加载提示词')
return
}
loadingPrompts.value = true
// 加载智能体列表
async function loadAgentList() {
loadingAgents.value = true
try {
const response = await UserPromptApi.getUserPromptPage({
pageNo: 1,
pageSize: 100,
status: 1
})
if (response?.data?.list) {
allPrompts.value = response.data.list
const res = await getAgentList()
if (res.code === 0 && res.data) {
agentList.value = res.data.map(item => ({
id: item.id,
agentId: item.agentId,
name: item.agentName,
description: item.description,
systemPrompt: item.systemPrompt,
avatar: item.icon,
categoryName: item.categoryName || '其他'
}))
// 默认选中第一个
if (agentList.value.length > 0 && !topicDetails.selectedAgentId) {
topicDetails.selectedAgentId = agentList.value[0].id
selectedAgent.value = agentList.value[0]
}
}
} catch (error) {
console.error('加载提示词失败:', error)
console.error('加载智能体列表失败:', error)
} finally {
loadingPrompts.value = false
loadingAgents.value = false
}
}
// 处理提示词选择
function handlePromptChange(prompt) {
if (!prompt?.content) {
message.warning('提示词内容为空')
return
// 处理智能体选择
function handleAgentChange(agentId) {
const agent = agentList.value.find(a => a.id === agentId)
if (agent) {
selectedAgent.value = agent
topicDetails.selectedAgentId = agentId
}
topicDetails.stylePromptId = prompt.id
topicDetails.stylePrompt = prompt.content
}
// 语音分析
@@ -145,16 +163,14 @@ async function analyzeVoice(audioUrl) {
}
}
// 选择话题
function handleSelectTopic(topic) {
selectedTopic.value = topic.id
}
// 创作处理
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)
@@ -170,7 +186,7 @@ async function handleGenerate() {
return
}
if (!topicDetails.stylePrompt?.trim()) {
if (!selectedAgent.value?.id) {
message.warning('请先选择文案风格')
return
}
@@ -179,12 +195,6 @@ async function handleGenerate() {
generatedContent.value = ''
try {
const requestData = {
audio_prompt: topicDetails.stylePrompt,
user_text: topicDetails.copywriting.trim(),
amplitude: 50
}
const ctrl = new AbortController()
let fullText = ''
let completed = false
@@ -198,40 +208,27 @@ async function handleGenerate() {
}
}, 180000)
CommonService.callWorkflowStream({
data: requestData,
rewriteStream({
agentId: selectedAgent.value.id,
userText: topicDetails.copywriting.trim(),
level: topicDetails.level,
modelType: topicDetails.modelType,
ctrl,
onMessage: (event) => {
if (completed || !event?.data) return
onMessage: (data) => {
if (completed) return
const dataStr = event.data.trim()
if (dataStr === '[DONE]') {
// 处理 Dify 响应格式
if (data.event === 'message' && data.content) {
fullText += data.content
generatedContent.value = fullText
} else if (data.event === 'done') {
completed = true
clearTimeout(timeout)
resolve()
return
}
if (dataStr.startsWith('[TIMEOUT]')) {
} else if (data.event === 'error') {
completed = true
clearTimeout(timeout)
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 {
if (event.data && !event.data.startsWith('[')) {
fullText += event.data
generatedContent.value = fullText
}
reject(new Error(data.errorMessage || '生成失败'))
}
},
onError: (err) => {
@@ -284,8 +281,13 @@ function extractCover(video) {
function processSearchResults(response, startId = 1) {
try {
currentCursor.value = response?.data?.cursor || null
const dataList = response?.data?.data || []
// 保存分页信息用于下次请求
const responseData = response?.data || {}
currentCursor.value = responseData.cursor || null
searchParams.search_id = responseData.search_id || ''
searchParams.backtrace = responseData.backtrace || ''
const dataList = responseData.data || []
return dataList
.map(el => el.aweme_info)
@@ -321,8 +323,11 @@ async function handleSearch() {
return
}
searchParams.offset = 0
// 重置分页参数
searchParams.keyword = keyword
searchParams.cursor = 0
searchParams.search_id = ''
searchParams.backtrace = ''
currentCursor.value = null
hotTopics.value = []
isLoading.value = true
@@ -330,11 +335,13 @@ async function handleSearch() {
try {
const urlParams = {
keyword: searchParams.keyword,
offset: String(searchParams.offset),
sort_type: String(searchParams.sort_type),
publish_time: String(searchParams.publish_time),
cursor: searchParams.cursor,
sort_type: searchParams.sort_type,
publish_time: searchParams.publish_time,
filter_duration: searchParams.filter_duration,
content_type: String(searchParams.content_type),
content_type: searchParams.content_type,
search_id: searchParams.search_id,
backtrace: searchParams.backtrace
}
const response = await TikhubService.postTikHup({
@@ -364,13 +371,7 @@ async function handleSearch() {
// 初始化
onMounted(() => {
if (userStore.userId) {
loadUserPrompts()
} else if (userStore.isLoggedIn) {
setTimeout(() => {
if (userStore.userId) loadUserPrompts()
}, 500)
}
loadAgentList()
})
</script>
@@ -413,26 +414,26 @@ onMounted(() => {
<label>排序</label>
<a-select v-model:value="searchParams.sort_type" size="small">
<a-select-option value="0">综合</a-select-option>
<a-select-option value="1"></a-select-option>
<a-select-option value="2">最新</a-select-option>
<a-select-option value="1">多点赞</a-select-option>
<a-select-option value="2">最新发布</a-select-option>
</a-select>
</div>
<div class="filter-item">
<label>时间</label>
<a-select v-model:value="searchParams.publish_time" size="small">
<a-select-option value="0">不限</a-select-option>
<a-select-option value="1"></a-select-option>
<a-select-option value="7"></a-select-option>
<a-select-option value="180">半年</a-select-option>
<a-select-option value="1">最近一</a-select-option>
<a-select-option value="7">最近一</a-select-option>
<a-select-option value="180">最近半年</a-select-option>
</a-select>
</div>
<div class="filter-item">
<label>时长</label>
<a-select v-model:value="searchParams.filter_duration" size="small">
<a-select-option value="0">不限</a-select-option>
<a-select-option value="0-1">&lt;1分钟</a-select-option>
<a-select-option value="0-1">1分钟以内</a-select-option>
<a-select-option value="1-5">1-5分钟</a-select-option>
<a-select-option value="5-10000">&gt;5分钟</a-select-option>
<a-select-option value="5-10000">5分钟以上</a-select-option>
</a-select>
</div>
</div>
@@ -464,7 +465,7 @@ onMounted(() => {
<article
v-for="topic in hotTopics"
:key="topic.id"
@click="handleCreate(topic)"
@click="handleSelectTopic(topic)"
class="topic-card"
:class="{ active: selectedTopic === topic.id }"
>
@@ -535,16 +536,6 @@ onMounted(() => {
</header>
<div class="editor-section">
<!-- 标题 -->
<div class="form-group">
<label class="form-label">标题</label>
<a-input
v-model:value="topicDetails.title"
placeholder="选择热点或输入标题"
class="form-input"
/>
</div>
<!-- 文案 -->
<div class="form-group">
<div class="form-label-row">
@@ -566,21 +557,75 @@ onMounted(() => {
<!-- 风格选择 -->
<div class="form-group">
<label class="form-label">风格</label>
<PromptSelector
v-model="topicDetails.stylePromptId"
:prompts="allPrompts"
:loading="loadingPrompts"
:search-keyword="promptSearchKeyword"
@change="handlePromptChange"
@update:searchKeyword="promptSearchKeyword = $event"
/>
<a-select
v-model:value="topicDetails.selectedAgentId"
placeholder="选择文案风格"
style="width: 100%"
:loading="loadingAgents"
show-search
:filter-option="false"
@change="handleAgentChange"
@search="agentSearchKeyword = $event"
>
<a-select-option
v-for="agent in filteredAgentList"
:key="agent.id"
:value="agent.id"
>
<div class="agent-option">
<span class="agent-option-name">{{ agent.name }}</span>
<span class="agent-option-category">{{ agent.categoryName }}</span>
</div>
</a-select-option>
</a-select>
</div>
<!-- 模型类型切换 -->
<div class="form-group">
<label class="form-label">模式</label>
<div class="model-type-switch">
<button
:class="['type-btn', { active: topicDetails.modelType === 'forecast_standard' }]"
@click="topicDetails.modelType = 'forecast_standard'"
>
标准版
</button>
<button
:class="['type-btn', { active: topicDetails.modelType === 'forecast_meiju' }]"
@click="topicDetails.modelType = 'forecast_meiju'"
>
美剧版
</button>
</div>
</div>
<!-- 改写幅度 -->
<div class="form-group">
<div class="form-label-row">
<label class="form-label">改写幅度</label>
<span class="level-value">{{ topicDetails.level }}%</span>
</div>
<div class="level-slider">
<input
type="range"
v-model.number="topicDetails.level"
min="10"
max="100"
step="10"
class="slider"
/>
<div class="level-labels">
<span>保守</span>
<span>激进</span>
</div>
</div>
</div>
<!-- 生成按钮 -->
<button
class="generate-btn"
:class="{ generating: isGenerating }"
:disabled="!topicDetails.copywriting?.trim() || !topicDetails.stylePromptId || isGenerating"
:disabled="!topicDetails.copywriting?.trim() || !topicDetails.selectedAgentId || isGenerating"
@click="handleGenerate"
>
<span v-if="isGenerating" class="btn-loading">
@@ -1098,6 +1143,90 @@ onMounted(() => {
50% { opacity: 0.5; transform: scale(0.8); }
}
// 模型类型切换
.model-type-switch {
display: flex;
gap: 8px;
}
.type-btn {
flex: 1;
padding: 10px 16px;
font-size: 13px;
font-weight: 500;
color: @text-secondary;
background: @bg;
border: 1px solid @border;
border-radius: @radius-sm;
cursor: pointer;
transition: all 0.2s;
&:hover {
color: @text;
border-color: rgba(255, 255, 255, 0.15);
}
&.active {
color: #fff;
background: @primary;
border-color: @primary;
}
}
// 改写幅度滑块
.level-value {
font-size: 13px;
font-weight: 600;
color: @primary;
}
.level-slider {
padding: 4px 0;
}
.slider {
width: 100%;
height: 6px;
-webkit-appearance: none;
appearance: none;
background: @bg;
border-radius: 3px;
outline: none;
&::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 18px;
height: 18px;
background: @primary;
border-radius: 50%;
cursor: pointer;
transition: transform 0.15s, box-shadow 0.15s;
&:hover {
transform: scale(1.1);
box-shadow: 0 0 0 4px fade(@primary-color, 20%);
}
}
&::-moz-range-thumb {
width: 18px;
height: 18px;
background: @primary;
border: none;
border-radius: 50%;
cursor: pointer;
}
}
.level-labels {
display: flex;
justify-content: space-between;
margin-top: 6px;
font-size: 11px;
color: @text-secondary;
}
// 生成按钮
.generate-btn {
width: 100%;