优化前端

This commit is contained in:
2025-11-10 23:53:05 +08:00
parent bac96fcbe6
commit cd443f9b3a
7 changed files with 1251 additions and 143 deletions

View File

@@ -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>