优化前端
This commit is contained in:
@@ -4,13 +4,16 @@ import storage from '@/utils/storage'
|
||||
import { ChatMessageApi } from '@/api/chat'
|
||||
import TikhubService, { InterfaceType, MethodType } from '@/api/tikhub/index.js'
|
||||
import useVoiceText from '@gold/hooks/web/useVoiceText'
|
||||
import { ref, h, onMounted, reactive, computed } from 'vue'
|
||||
import { ref, h, onMounted, reactive } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { EditOutlined, CopyOutlined } from '@ant-design/icons-vue'
|
||||
import { EditOutlined, CopyOutlined, DownloadOutlined } from '@ant-design/icons-vue'
|
||||
import { resolveId } from '@/utils/url'
|
||||
import { exportBenchmarkDataToExcel } from '@/utils/excel'
|
||||
import { usePromptStore } from '@/stores/prompt'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { renderMarkdown } from '@/utils/markdown'
|
||||
import GlobalLoading from '@/components/GlobalLoading.vue'
|
||||
import { streamChat } from '@/utils/streamChat'
|
||||
import ChatMessageRenderer from '@/components/ChatMessageRenderer.vue'
|
||||
|
||||
// ==================== 初始化 ====================
|
||||
const router = useRouter()
|
||||
@@ -23,6 +26,8 @@ const TABLE_DATA_STORAGE_KEY = 'benchmark_table_data'
|
||||
// ==================== 响应式状态 ====================
|
||||
const loading = ref(false)
|
||||
const batchAnalyzeLoading = ref(false)
|
||||
const globalLoading = ref(false)
|
||||
const globalLoadingText = ref('')
|
||||
const data = ref([])
|
||||
const selectedRowKeys = ref([])
|
||||
const expandedRowKeys = ref([])
|
||||
@@ -66,10 +71,7 @@ const defaultColumns = [
|
||||
const columns = reactive([...defaultColumns])
|
||||
|
||||
// ==================== 计算属性 ====================
|
||||
const batchPromptRendered = computed(() => {
|
||||
if (!batchPrompt.value) return '综合提示词将在这里显示...'
|
||||
return renderMarkdown(batchPrompt.value)
|
||||
})
|
||||
// batchPromptRendered 已移除,使用 ChatMessageRenderer 组件渲染
|
||||
|
||||
// ==================== 数据处理函数 ====================
|
||||
/**
|
||||
@@ -122,6 +124,77 @@ function formatTime(ts) {
|
||||
return dayjs(ms).format('YYYY-MM-DD HH:mm:ss')
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出博主主页数据到 Excel(仅导出选中的行,最多10个)
|
||||
*/
|
||||
async function exportToExcel() {
|
||||
if (!data.value || data.value.length === 0) {
|
||||
message.warning('暂无数据可导出')
|
||||
return
|
||||
}
|
||||
|
||||
// 检查是否有选中的行
|
||||
if (selectedRowKeys.value.length === 0) {
|
||||
message.warning('请先选择要导出的行')
|
||||
return
|
||||
}
|
||||
|
||||
// 限制最多10个
|
||||
if (selectedRowKeys.value.length > 10) {
|
||||
message.warning('最多只能导出10条数据,请重新选择')
|
||||
return
|
||||
}
|
||||
|
||||
// 获取选中的行数据
|
||||
const selectedRows = data.value.filter(item => selectedRowKeys.value.includes(item.id))
|
||||
|
||||
// 检查是否有未分析的行
|
||||
const unanalyzedRows = selectedRows.filter(row => !row.transcriptions && !row.prompt)
|
||||
|
||||
if (unanalyzedRows.length > 0) {
|
||||
// 有未分析的行,先分析
|
||||
globalLoading.value = true
|
||||
globalLoadingText.value = `正在分析 ${unanalyzedRows.length} 条数据...`
|
||||
|
||||
try {
|
||||
// 逐个分析未分析的行
|
||||
for (let i = 0; i < unanalyzedRows.length; i++) {
|
||||
const row = unanalyzedRows[i]
|
||||
globalLoadingText.value = `正在分析第 ${i + 1}/${unanalyzedRows.length} 条数据...`
|
||||
await analyzeVideo(row)
|
||||
}
|
||||
|
||||
globalLoadingText.value = '分析完成,正在导出...'
|
||||
} catch (error) {
|
||||
console.error('分析失败:', error)
|
||||
message.error('部分数据分析失败,将导出已分析的数据')
|
||||
}
|
||||
} else {
|
||||
globalLoading.value = true
|
||||
globalLoadingText.value = '正在导出数据...'
|
||||
}
|
||||
|
||||
try {
|
||||
// 重新获取选中的行数据(可能已经更新)
|
||||
const finalSelectedRows = data.value.filter(item => selectedRowKeys.value.includes(item.id))
|
||||
|
||||
// 导出数据
|
||||
const result = exportBenchmarkDataToExcel(finalSelectedRows, {
|
||||
platform: form.value.platform,
|
||||
formatTime
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
message.success(result.message)
|
||||
} else {
|
||||
message.error(result.message)
|
||||
}
|
||||
} finally {
|
||||
globalLoading.value = false
|
||||
globalLoadingText.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 会话存储相关 ====================
|
||||
async function saveTableDataToSession() {
|
||||
try {
|
||||
@@ -162,110 +235,18 @@ function buildPromptFromTranscription(text) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 节流函数,优化流式渲染性能
|
||||
*/
|
||||
function createThrottledUpdate(updateFn, delay = 100) {
|
||||
let lastTime = 0
|
||||
let timeout = null
|
||||
|
||||
return (value) => {
|
||||
const now = Date.now()
|
||||
|
||||
if (now - lastTime >= delay) {
|
||||
lastTime = now
|
||||
updateFn(value)
|
||||
} else {
|
||||
clearTimeout(timeout)
|
||||
timeout = setTimeout(() => {
|
||||
lastTime = Date.now()
|
||||
updateFn(value)
|
||||
}, delay - (now - lastTime))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用:SSE 流式发送并累计文本,支持回调实时更新
|
||||
* 通用:SSE 流式发送并累计文本,支持打字机效果和平滑更新
|
||||
* @deprecated 使用 streamChat 替代,此函数保留用于兼容
|
||||
*/
|
||||
async function sendPromptWithStream(conversationId, content, onChunk) {
|
||||
const ctrl = new AbortController()
|
||||
let fullText = ''
|
||||
let errorOccurred = false
|
||||
let isResolved = false
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
if (!isResolved) {
|
||||
ctrl.abort()
|
||||
reject(new Error('请求超时,请稍后重试'))
|
||||
}
|
||||
}, 120000)
|
||||
|
||||
ChatMessageApi.sendChatMessageStream({
|
||||
conversationId,
|
||||
content,
|
||||
ctrl,
|
||||
enableContext: true,
|
||||
enableWebSearch: false,
|
||||
onMessage: (event) => {
|
||||
try {
|
||||
if (errorOccurred) return
|
||||
|
||||
const dataStr = event?.data || ''
|
||||
if (!dataStr) return
|
||||
|
||||
try {
|
||||
const { code, data: responseData, msg } = JSON.parse(dataStr)
|
||||
if (code !== 0) {
|
||||
message.warning(`对话异常! ${msg}`)
|
||||
return
|
||||
}
|
||||
|
||||
if (responseData.receive.content === '' && !responseData.receive.reasoningContent) return
|
||||
|
||||
const piece = responseData.receive.content || responseData.receive.reasoningContent || ''
|
||||
if (piece) {
|
||||
fullText += piece
|
||||
if (typeof onChunk === 'function') onChunk(fullText)
|
||||
}
|
||||
} catch {
|
||||
try {
|
||||
const obj = JSON.parse(dataStr)
|
||||
const piece = obj?.content || obj?.data || obj?.text || ''
|
||||
if (piece) {
|
||||
fullText += piece
|
||||
if (typeof onChunk === 'function') onChunk(fullText)
|
||||
}
|
||||
} catch (parseErr) {
|
||||
console.warn('解析流数据异常:', parseErr)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('解析流数据异常:', e)
|
||||
}
|
||||
},
|
||||
onError: (err) => {
|
||||
clearTimeout(timeout)
|
||||
if (!isResolved) {
|
||||
errorOccurred = true
|
||||
ctrl.abort()
|
||||
const errorMsg = err?.message || '网络请求失败'
|
||||
console.error('SSE请求错误:', err)
|
||||
message.error(errorMsg)
|
||||
reject(new Error(errorMsg))
|
||||
}
|
||||
},
|
||||
onClose: () => {
|
||||
clearTimeout(timeout)
|
||||
if (!isResolved) {
|
||||
isResolved = true
|
||||
resolve()
|
||||
}
|
||||
},
|
||||
attachmentUrls: []
|
||||
})
|
||||
return await streamChat({
|
||||
conversationId,
|
||||
content,
|
||||
onUpdate: onChunk,
|
||||
enableTypewriter: true,
|
||||
typewriterSpeed: 10,
|
||||
typewriterBatchSize: 2
|
||||
})
|
||||
return fullText.trim()
|
||||
}
|
||||
|
||||
// ==================== API 调用函数 ====================
|
||||
@@ -365,8 +346,9 @@ async function analyzeVideo(row) {
|
||||
if (index !== -1) data.value[index].prompt = finalPrompt
|
||||
|
||||
// 6) 分析完成后自动展开该行
|
||||
if (!expandedRowKeys.value.includes(row.id)) {
|
||||
expandedRowKeys.value.push(row.id)
|
||||
const rowId = String(row.id) // 确保类型一致
|
||||
if (!expandedRowKeys.value.includes(rowId)) {
|
||||
expandedRowKeys.value.push(rowId)
|
||||
}
|
||||
|
||||
// 7) 保存数据到 session
|
||||
@@ -393,12 +375,15 @@ async function batchAnalyze() {
|
||||
}
|
||||
|
||||
batchAnalyzeLoading.value = true
|
||||
globalLoading.value = true
|
||||
globalLoadingText.value = `正在批量分析 ${selectedRowKeys.value.length} 个视频...`
|
||||
modalVisible.value = false
|
||||
batchPrompt.value = ''
|
||||
batchPromptEditMode.value = false
|
||||
|
||||
try {
|
||||
// 1. 获取所有选中视频的语音转写
|
||||
globalLoadingText.value = '正在获取中...'
|
||||
const selectedRows = data.value.filter(item => selectedRowKeys.value.includes(item.id))
|
||||
const transcriptions = await getVoiceText(selectedRows)
|
||||
|
||||
@@ -419,6 +404,8 @@ async function batchAnalyze() {
|
||||
if (allTexts.length === 0) {
|
||||
message.warning('未提取到任何语音内容,请检查音频文件或稍后重试')
|
||||
batchAnalyzeLoading.value = false
|
||||
globalLoading.value = false
|
||||
globalLoadingText.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
@@ -428,6 +415,8 @@ async function batchAnalyze() {
|
||||
// 4. 打开弹窗
|
||||
modalVisible.value = true
|
||||
batchAnalyzeLoading.value = false
|
||||
globalLoading.value = false
|
||||
globalLoadingText.value = ''
|
||||
|
||||
// 5. 在弹窗中流式生成 AI 内容
|
||||
try {
|
||||
@@ -445,14 +434,21 @@ async function batchAnalyze() {
|
||||
throw new Error('创建对话失败:未获取到 conversationId')
|
||||
}
|
||||
|
||||
const throttledUpdate = createThrottledUpdate((fullText) => {
|
||||
batchPrompt.value = fullText
|
||||
const aiContent = await streamChat({
|
||||
conversationId,
|
||||
content: mergedText,
|
||||
onUpdate: (fullText) => {
|
||||
batchPrompt.value = fullText
|
||||
},
|
||||
enableTypewriter: true,
|
||||
typewriterSpeed: 10,
|
||||
typewriterBatchSize: 2
|
||||
})
|
||||
|
||||
const aiContent = await sendPromptWithStream(conversationId, mergedText, throttledUpdate)
|
||||
|
||||
const finalPrompt = aiContent || mergedText || ''
|
||||
batchPrompt.value = finalPrompt
|
||||
// streamChat 已经通过 onUpdate 更新了 batchPrompt,这里确保最终值正确
|
||||
if (aiContent && aiContent !== batchPrompt.value) {
|
||||
batchPrompt.value = aiContent
|
||||
}
|
||||
|
||||
message.success(`批量分析完成:已基于 ${allTexts.length} 个视频的文案生成综合提示词`)
|
||||
} catch (aiError) {
|
||||
@@ -465,6 +461,8 @@ async function batchAnalyze() {
|
||||
console.error('批量分析失败:', error)
|
||||
message.error('批量分析失败,请稍后重试')
|
||||
batchAnalyzeLoading.value = false
|
||||
globalLoading.value = false
|
||||
globalLoadingText.value = ''
|
||||
} finally {
|
||||
selectedRowKeys.value = []
|
||||
}
|
||||
@@ -475,19 +473,7 @@ function onSelectChange(selectedKeys) {
|
||||
selectedRowKeys.value = selectedKeys
|
||||
}
|
||||
|
||||
function handleExpand(record, expanded) {
|
||||
const key = record.id
|
||||
if (expanded) {
|
||||
if (!expandedRowKeys.value.includes(key)) {
|
||||
expandedRowKeys.value.push(key)
|
||||
}
|
||||
} else {
|
||||
const index = expandedRowKeys.value.indexOf(key)
|
||||
if (index > -1) {
|
||||
expandedRowKeys.value.splice(index, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function onView(row) {
|
||||
analyzeVideo(row)
|
||||
@@ -570,6 +556,7 @@ function copyBatchPrompt() {
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
// ==================== 生命周期 ====================
|
||||
onMounted(async () => {
|
||||
await loadTableDataFromSession()
|
||||
@@ -626,7 +613,17 @@ defineOptions({ name: 'ContentStyleBenchmark' })
|
||||
<section class="card results-card" v-if="data.length > 0">
|
||||
<div class="section-header">
|
||||
<div class="section-title">分析结果</div>
|
||||
<a-space>
|
||||
<a-space align="center">
|
||||
<a-button
|
||||
size="small"
|
||||
type="default"
|
||||
@click="exportToExcel"
|
||||
:disabled="data.length === 0 || selectedRowKeys.length === 0 || selectedRowKeys.length > 10">
|
||||
<template #icon>
|
||||
<DownloadOutlined />
|
||||
</template>
|
||||
导出Excel ({{ selectedRowKeys.length }}/10)
|
||||
</a-button>
|
||||
<a-button size="small" type="primary" class="batch-btn" :loading="batchAnalyzeLoading" @click="batchAnalyze">
|
||||
批量分析 ({{ selectedRowKeys.length }})
|
||||
</a-button>
|
||||
@@ -637,13 +634,34 @@ defineOptions({ name: 'ContentStyleBenchmark' })
|
||||
:columns="columns"
|
||||
:pagination="false"
|
||||
:row-selection="{ selectedRowKeys, onChange: onSelectChange, hideSelectAll: true }"
|
||||
:expandedRowKeys="expandedRowKeys"
|
||||
@expand="(record, expanded) => handleExpand(record, expanded)"
|
||||
rowKey="id"
|
||||
:loading="loading">
|
||||
v-model:expandedRowKeys="expandedRowKeys"
|
||||
:expandable="{
|
||||
expandRowByClick: false
|
||||
}"
|
||||
:rowKey="(record) => String(record.id)"
|
||||
:loading="loading"
|
||||
class="benchmark-table">
|
||||
<template #expandedRowRender="{ record }">
|
||||
<div class="expanded-content">
|
||||
<div class="two-col">
|
||||
<!-- 未分析的行显示提示 -->
|
||||
<div v-if="!record.transcriptions && !record.prompt" class="no-analysis-tip">
|
||||
<a-empty description="该视频尚未分析">
|
||||
<template #image>
|
||||
<svg width="120" height="120" viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="20" y="30" width="80" height="60" rx="4" stroke="currentColor" stroke-width="2" fill="none" opacity="0.3"/>
|
||||
<circle cx="40" cy="50" r="8" fill="currentColor" opacity="0.4"/>
|
||||
<rect x="54" y="47" width="40" height="6" rx="3" fill="currentColor" opacity="0.4"/>
|
||||
<rect x="54" y="60" width="32" height="6" rx="3" fill="currentColor" opacity="0.4"/>
|
||||
</svg>
|
||||
</template>
|
||||
<a-button type="primary" @click="onView(record)" :loading="record._analyzing">
|
||||
{{ record._analyzing ? '分析中…' : '开始分析' }}
|
||||
</a-button>
|
||||
</a-empty>
|
||||
</div>
|
||||
|
||||
<!-- 已分析的行显示内容 -->
|
||||
<div v-else class="two-col">
|
||||
<!-- 左侧:原配音内容 -->
|
||||
<section class="col left-col">
|
||||
<div class="sub-title">原配音</div>
|
||||
@@ -671,7 +689,11 @@ defineOptions({ name: 'ContentStyleBenchmark' })
|
||||
</div>
|
||||
|
||||
<div v-if="editingRowId !== record.id" class="prompt-display-wrapper">
|
||||
<div class="prompt-display" v-html="renderMarkdown(record.prompt || '暂无提示词')"></div>
|
||||
<ChatMessageRenderer
|
||||
:content="record.prompt || ''"
|
||||
:is-streaming="record._analyzing || false"
|
||||
/>
|
||||
<div v-if="!record.prompt" class="no-prompt">暂无提示词</div>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
@@ -717,16 +739,16 @@ defineOptions({ name: 'ContentStyleBenchmark' })
|
||||
{{ record.play_count ? (record.play_count / 10000).toFixed(1) + 'w' : '0' }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'digg_count'">
|
||||
{{ record.digg_count ? (record.digg_count / 10000).toFixed(1) + 'w' : '0' }}
|
||||
{{ record.digg_count ? record.digg_count.toLocaleString('zh-CN') : '0' }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'comment_count'">
|
||||
{{ record.comment_count ? (record.comment_count / 10000).toFixed(1) + 'w' : '0' }}
|
||||
{{ record.comment_count ? record.comment_count.toLocaleString('zh-CN') : '0' }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'share_count'">
|
||||
{{ record.share_count ? (record.share_count / 10000).toFixed(1) + 'w' : '0' }}
|
||||
{{ record.share_count ? record.share_count.toLocaleString('zh-CN') : '0' }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'collect_count'">
|
||||
{{ record.collect_count ? (record.collect_count / 10000).toFixed(1) + 'w' : '0' }}
|
||||
{{ record.collect_count ? record.collect_count.toLocaleString('zh-CN') : '0' }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'is_top'">
|
||||
<a-tag v-if="record.is_top" color="red">置顶</a-tag>
|
||||
@@ -776,7 +798,12 @@ defineOptions({ name: 'ContentStyleBenchmark' })
|
||||
:maskClosable="false"
|
||||
:keyboard="false">
|
||||
<div class="batch-prompt-modal">
|
||||
<div v-if="!batchPromptEditMode" class="batch-prompt-display" v-html="batchPromptRendered"></div>
|
||||
<div v-if="!batchPromptEditMode" class="batch-prompt-display">
|
||||
<ChatMessageRenderer
|
||||
:content="batchPrompt"
|
||||
:is-streaming="batchPromptGenerating"
|
||||
/>
|
||||
</div>
|
||||
<a-textarea
|
||||
v-else
|
||||
v-model:value="batchPrompt"
|
||||
@@ -805,6 +832,9 @@ defineOptions({ name: 'ContentStyleBenchmark' })
|
||||
</template>
|
||||
</a-modal>
|
||||
</a-spin>
|
||||
|
||||
<!-- 全局 Loading 遮罩 -->
|
||||
<GlobalLoading :visible="globalLoading" :text="globalLoadingText" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -903,6 +933,17 @@ defineOptions({ name: 'ContentStyleBenchmark' })
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.section-header .ant-space {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.section-header .ant-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* 批量分析按钮更明显 */
|
||||
.batch-btn {
|
||||
font-weight: 600;
|
||||
@@ -1367,4 +1408,38 @@ defineOptions({ name: 'ContentStyleBenchmark' })
|
||||
:deep(.batch-analyze-spin-wrapper .ant-spin-spinning) {
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
/* 展开列样式优化 */
|
||||
.benchmark-table :deep(.ant-table-expand-icon-th),
|
||||
.benchmark-table :deep(.ant-table-row-expand-icon-cell) {
|
||||
width: 48px;
|
||||
min-width: 48px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 确保展开图标正确对齐 */
|
||||
.benchmark-table :deep(.ant-table-row-expand-icon) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* 未分析提示样式 */
|
||||
.no-analysis-tip {
|
||||
padding: 40px 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.no-analysis-tip :deep(.ant-empty) {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.no-analysis-tip :deep(.ant-empty-description) {
|
||||
color: var(--color-text-secondary);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user