diff --git a/frontend/app/web-gold/package.json b/frontend/app/web-gold/package.json
index ffda06bb55..240ab363d1 100644
--- a/frontend/app/web-gold/package.json
+++ b/frontend/app/web-gold/package.json
@@ -28,7 +28,9 @@
"pinia-plugin-persistedstate": "^4.5.0",
"qs": "^6.14.0",
"vue": "^3.5.22",
- "vue-router": "^4.5.1"
+ "vue-router": "^4.5.1",
+ "xlsx": "^0.18.5",
+ "xlsx-js-style": "^1.2.0"
},
"devDependencies": {
"@eslint/js": "^9.33.0",
diff --git a/frontend/app/web-gold/src/components/ChatMessageRenderer.vue b/frontend/app/web-gold/src/components/ChatMessageRenderer.vue
new file mode 100644
index 0000000000..3e4109a449
--- /dev/null
+++ b/frontend/app/web-gold/src/components/ChatMessageRenderer.vue
@@ -0,0 +1,221 @@
+
+
+
+
+
+
+
diff --git a/frontend/app/web-gold/src/components/GlobalLoading.vue b/frontend/app/web-gold/src/components/GlobalLoading.vue
new file mode 100644
index 0000000000..47ad6284cb
--- /dev/null
+++ b/frontend/app/web-gold/src/components/GlobalLoading.vue
@@ -0,0 +1,193 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/app/web-gold/src/utils/excel.js b/frontend/app/web-gold/src/utils/excel.js
new file mode 100644
index 0000000000..4d3a40389d
--- /dev/null
+++ b/frontend/app/web-gold/src/utils/excel.js
@@ -0,0 +1,331 @@
+import * as XLSX from 'xlsx-js-style'
+import dayjs from 'dayjs'
+
+/**
+ * 格式化数字,添加千分位分隔符
+ */
+function formatNumber(num) {
+ if (typeof num !== 'number' || isNaN(num)) return num
+ return num.toLocaleString('zh-CN')
+}
+
+/**
+ * 计算列宽(根据内容自动调整)
+ */
+function calculateColumnWidth(data, columnKey) {
+ if (!data || data.length === 0) return 15
+
+ const maxLength = Math.max(
+ columnKey.length, // 表头长度
+ ...data.map(row => {
+ const value = row[columnKey]
+ if (value === null || value === undefined) return 0
+ return String(value).length
+ })
+ )
+
+ // 最小宽度 8,最大宽度根据内容,但不超过 100
+ return Math.min(Math.max(maxLength + 2, 8), 100)
+}
+
+/**
+ * 导出博主主页数据到 Excel(优化版)
+ * @param {Array} data - 要导出的数据数组
+ * @param {Object} options - 导出配置选项
+ * @param {string} options.platform - 平台类型(抖音/小红书)
+ * @param {Function} options.formatTime - 时间格式化函数
+ * @param {string} options.filename - 自定义文件名(可选)
+ * @returns {Object} 导出结果
+ */
+export function exportBenchmarkDataToExcel(data, options = {}) {
+ const { platform = '抖音', formatTime, filename } = options
+
+ if (!data || data.length === 0) {
+ return { success: false, message: '暂无数据可导出' }
+ }
+
+ try {
+ // 定义列配置
+ const columnConfig = [
+ { key: '序号', width: 8, align: 'center' },
+ { key: 'ID', width: 18, align: 'left' },
+ { key: '描述', width: 60, align: 'left', wrap: true },
+ { key: '点赞数', width: 12, align: 'right', format: 'number' },
+ { key: '评论数', width: 12, align: 'right', format: 'number' },
+ { key: '分享数', width: 12, align: 'right', format: 'number' },
+ { key: '收藏数', width: 12, align: 'right', format: 'number' },
+ { key: '播放量', width: 12, align: 'right', format: 'number' },
+ { key: '时长(秒)', width: 12, align: 'right', format: 'number' },
+ { key: '置顶', width: 8, align: 'center' },
+ { key: '创建时间', width: 20, align: 'left' },
+ { key: '链接', width: 60, align: 'left' },
+ { key: '封面链接', width: 60, align: 'left' },
+ { key: '音频链接', width: 60, align: 'left' },
+ { key: '原配音', width: 80, align: 'left', wrap: true },
+ { key: '风格提示词', width: 80, align: 'left', wrap: true }
+ ]
+
+ // 准备导出数据
+ const exportData = data.map((item, index) => {
+ const row = {
+ '序号': index + 1,
+ 'ID': item.id || '',
+ '描述': item.desc || '',
+ '点赞数': item.digg_count || 0,
+ '评论数': item.comment_count || 0,
+ '分享数': item.share_count || 0,
+ '收藏数': item.collect_count || 0,
+ '播放量': item.play_count || 0,
+ '时长(秒)': item.duration_s || 0,
+ '置顶': item.is_top ? '是' : '否',
+ '创建时间': formatTime ? formatTime(item.create_time) : (item.create_time || ''),
+ '链接': item.share_url || '',
+ '封面链接': item.cover || '',
+ '音频链接': item.audio_url || '',
+ '原配音': item.transcriptions || '',
+ '风格提示词': item.prompt || ''
+ }
+
+ // 小红书平台特殊处理
+ if (platform === '小红书' && !row['播放量']) {
+ row['浏览量'] = item.view_count || 0
+ // 调整列配置,将播放量改为浏览量
+ const playIndex = columnConfig.findIndex(col => col.key === '播放量')
+ if (playIndex !== -1) {
+ columnConfig[playIndex].key = '浏览量'
+ }
+ }
+
+ // 确保数字字段为数字类型(不格式化,显示完整数字)
+ columnConfig.forEach(col => {
+ if (col.format === 'number' && row[col.key] !== undefined) {
+ const num = Number(row[col.key])
+ if (!isNaN(num)) {
+ row[col.key] = num // 直接使用数字,不格式化
+ } else {
+ row[col.key] = 0
+ }
+ }
+ })
+
+ return row
+ })
+
+ // 创建工作簿和工作表
+ const wb = XLSX.utils.book_new()
+ const ws = XLSX.utils.json_to_sheet(exportData)
+
+ // 优化列宽设置(根据实际内容动态计算)
+ ws['!cols'] = columnConfig.map(col => {
+ // 如果数据中有该列,根据实际内容计算宽度
+ const actualWidth = calculateColumnWidth(exportData, col.key)
+ return {
+ wch: Math.max(col.width, actualWidth)
+ }
+ })
+
+ // 设置冻结首行(冻结窗格)
+ ws['!freeze'] = { xSplit: 0, ySplit: 1, topLeftCell: 'A2', activePane: 'bottomLeft', state: 'frozen' }
+
+ // 设置行高(首行稍高,便于阅读)
+ if (!ws['!rows']) ws['!rows'] = []
+ ws['!rows'][0] = { hpt: 25 } // 首行高度 25pt
+ // 数据行高度
+ for (let i = 1; i <= exportData.length; i++) {
+ ws['!rows'][i] = { hpt: 18 }
+ }
+
+ // 获取表头范围
+ const headerRange = XLSX.utils.decode_range(ws['!ref'])
+ const lastCol = headerRange.e.c
+
+ // 设置表头样式(加粗、背景色、居中对齐)
+ const headerStyle = {
+ font: { bold: true, color: { rgb: 'FFFFFF' }, sz: 11 },
+ fill: { fgColor: { rgb: '4472C4' } }, // 蓝色背景
+ alignment: { horizontal: 'center', vertical: 'center', wrapText: true },
+ border: {
+ top: { style: 'thin', color: { rgb: '000000' } },
+ bottom: { style: 'thin', color: { rgb: '000000' } },
+ left: { style: 'thin', color: { rgb: '000000' } },
+ right: { style: 'thin', color: { rgb: '000000' } }
+ }
+ }
+
+ // 应用表头样式
+ for (let col = 0; col <= lastCol; col++) {
+ const cellAddress = XLSX.utils.encode_cell({ r: 0, c: col })
+ if (!ws[cellAddress]) ws[cellAddress] = { t: 's', v: '' }
+ ws[cellAddress].s = headerStyle
+ }
+
+ // 设置数据行样式(边框、对齐方式)
+ const dataStyle = {
+ border: {
+ top: { style: 'thin', color: { rgb: 'D9D9D9' } },
+ bottom: { style: 'thin', color: { rgb: 'D9D9D9' } },
+ left: { style: 'thin', color: { rgb: 'D9D9D9' } },
+ right: { style: 'thin', color: { rgb: 'D9D9D9' } }
+ },
+ alignment: { vertical: 'top', wrapText: true }
+ }
+
+ // 应用数据行样式
+ for (let row = 1; row <= exportData.length; row++) {
+ for (let col = 0; col <= lastCol; col++) {
+ const cellAddress = XLSX.utils.encode_cell({ r: row, c: col })
+ if (ws[cellAddress]) {
+ if (!ws[cellAddress].s) ws[cellAddress].s = {}
+
+ // 根据列配置设置对齐方式
+ const colConfig = columnConfig[col]
+ if (colConfig) {
+ ws[cellAddress].s.alignment = {
+ ...dataStyle.alignment,
+ horizontal: colConfig.align || 'left'
+ }
+ } else {
+ ws[cellAddress].s.alignment = dataStyle.alignment
+ }
+
+ // 设置边框
+ ws[cellAddress].s.border = dataStyle.border
+
+ // 数字列右对齐,并确保为数字格式
+ if (colConfig && colConfig.format === 'number') {
+ ws[cellAddress].s.alignment.horizontal = 'right'
+ // 确保单元格类型为数字
+ if (typeof ws[cellAddress].v === 'number') {
+ ws[cellAddress].t = 'n' // 数字类型
+ // 设置数字格式(不显示千分位,显示完整数字)
+ ws[cellAddress].z = '0'
+ }
+ }
+ }
+ }
+
+ // 斑马纹效果(偶数行浅灰色背景)
+ if (row % 2 === 0) {
+ for (let col = 0; col <= lastCol; col++) {
+ const cellAddress = XLSX.utils.encode_cell({ r: row, c: col })
+ if (ws[cellAddress]) {
+ if (!ws[cellAddress].s) ws[cellAddress].s = {}
+ ws[cellAddress].s.fill = { fgColor: { rgb: 'F2F2F2' } }
+ }
+ }
+ }
+ }
+
+ // 设置打印区域
+ const lastRow = exportData.length + 1
+ const lastColLetter = XLSX.utils.encode_col(lastCol)
+ ws['!print'] = {
+ area: `A1:${lastColLetter}${lastRow}`,
+ margins: {
+ left: 0.7,
+ right: 0.7,
+ top: 0.75,
+ bottom: 0.75,
+ header: 0.3,
+ footer: 0.3
+ },
+ orientation: 'landscape', // 横向打印
+ paperSize: 9, // A4
+ fitToPage: true,
+ fitToWidth: 1,
+ fitToHeight: 0
+ }
+
+ XLSX.utils.book_append_sheet(wb, ws, '博主主页数据')
+
+ // 生成文件名
+ const timestamp = dayjs().format('YYYY-MM-DD_HH-mm-ss')
+ const finalFilename = filename || `${platform}_博主主页_${timestamp}.xlsx`
+
+ // 导出文件
+ XLSX.writeFile(wb, finalFilename)
+
+ return {
+ success: true,
+ message: `已导出 ${exportData.length} 条数据`,
+ count: exportData.length
+ }
+ } catch (error) {
+ console.error('导出Excel失败:', error)
+ return {
+ success: false,
+ message: '导出失败,请稍后重试',
+ error: error.message
+ }
+ }
+}
+
+/**
+ * 通用 Excel 导出工具
+ * @param {Array} data - 要导出的数据数组
+ * @param {Object} options - 导出配置
+ * @param {string} options.sheetName - 工作表名称
+ * @param {string} options.filename - 文件名
+ * @param {Array} options.columns - 列配置 [{key, title, width}]
+ * @param {Function} options.formatter - 数据格式化函数
+ * @returns {boolean} 是否导出成功
+ */
+export function exportToExcel(data, options = {}) {
+ const {
+ sheetName = '数据',
+ filename = `导出数据_${dayjs().format('YYYY-MM-DD_HH-mm-ss')}.xlsx`,
+ columns,
+ formatter
+ } = options
+
+ if (!data || data.length === 0) {
+ return { success: false, message: '暂无数据可导出' }
+ }
+
+ try {
+ let exportData = data
+
+ // 如果有格式化函数,先格式化数据
+ if (formatter && typeof formatter === 'function') {
+ exportData = data.map(formatter)
+ } else if (columns && Array.isArray(columns)) {
+ // 如果有列配置,按配置转换数据
+ exportData = data.map((item, index) => {
+ const row = { '序号': index + 1 }
+ columns.forEach(col => {
+ const value = item[col.key]
+ row[col.title || col.key] = value !== undefined ? value : ''
+ })
+ return row
+ })
+ }
+
+ // 创建工作簿和工作表
+ const wb = XLSX.utils.book_new()
+ const ws = XLSX.utils.json_to_sheet(exportData)
+
+ // 设置列宽
+ if (columns && Array.isArray(columns)) {
+ ws['!cols'] = columns.map(col => ({ wch: col.width || 15 }))
+ }
+
+ XLSX.utils.book_append_sheet(wb, ws, sheetName)
+
+ // 导出文件
+ XLSX.writeFile(wb, filename)
+
+ return {
+ success: true,
+ message: `已导出 ${exportData.length} 条数据`,
+ count: exportData.length
+ }
+ } catch (error) {
+ console.error('导出Excel失败:', error)
+ return {
+ success: false,
+ message: '导出失败,请稍后重试',
+ error: error.message
+ }
+ }
+}
+
diff --git a/frontend/app/web-gold/src/utils/streamChat.js b/frontend/app/web-gold/src/utils/streamChat.js
new file mode 100644
index 0000000000..3484b85b21
--- /dev/null
+++ b/frontend/app/web-gold/src/utils/streamChat.js
@@ -0,0 +1,286 @@
+import { ChatMessageApi } from '@/api/chat'
+import { message } from 'ant-design-vue'
+
+/**
+ * 打字机效果管理器
+ * 实现平滑的逐字符显示效果
+ */
+class TypewriterManager {
+ constructor(updateFn, options = {}) {
+ this.updateFn = updateFn
+ this.speed = options.speed || 10 // 每个字符的显示间隔(毫秒)
+ this.batchSize = options.batchSize || 2 // 每次显示的字符数
+ this.queue = [] // 待显示的字符队列
+ this.displayedText = '' // 已显示的文本
+ this.isTyping = false
+ this.rafId = null
+ this.timeoutId = null
+ }
+
+ /**
+ * 添加新文本到队列
+ */
+ addText(newText) {
+ // 计算新增的文本
+ if (newText.length > this.displayedText.length) {
+ const newChars = newText.slice(this.displayedText.length)
+ this.queue.push(...newChars.split(''))
+ this.startTyping()
+ }
+ }
+
+ /**
+ * 开始打字机效果
+ */
+ startTyping() {
+ if (this.isTyping) return
+ this.isTyping = true
+ this.typeNext()
+ }
+
+ /**
+ * 打字下一个批次
+ */
+ typeNext() {
+ if (this.queue.length === 0) {
+ this.isTyping = false
+ return
+ }
+
+ // 取出一批字符
+ const batch = this.queue.splice(0, this.batchSize)
+ this.displayedText += batch.join('')
+
+ // 更新显示
+ this.updateFn(this.displayedText)
+
+ // 继续下一批
+ this.timeoutId = setTimeout(() => {
+ this.typeNext()
+ }, this.speed)
+ }
+
+ /**
+ * 立即完成所有待显示文本
+ */
+ flush() {
+ if (this.timeoutId) {
+ clearTimeout(this.timeoutId)
+ this.timeoutId = null
+ }
+ if (this.rafId) {
+ cancelAnimationFrame(this.rafId)
+ this.rafId = null
+ }
+ if (this.queue.length > 0) {
+ this.displayedText += this.queue.join('')
+ this.queue = []
+ this.updateFn(this.displayedText)
+ }
+ this.isTyping = false
+ }
+
+ /**
+ * 重置
+ */
+ reset() {
+ if (this.timeoutId) {
+ clearTimeout(this.timeoutId)
+ this.timeoutId = null
+ }
+ if (this.rafId) {
+ cancelAnimationFrame(this.rafId)
+ this.rafId = null
+ }
+ this.queue = []
+ this.displayedText = ''
+ this.isTyping = false
+ }
+}
+
+/**
+ * 统一的 AI 流式聊天工具
+ * 支持打字机效果和平滑更新
+ *
+ * @param {Object} options 配置选项
+ * @param {number|string} options.conversationId - 对话ID
+ * @param {string} options.content - 发送的内容
+ * @param {Function} options.onUpdate - 更新回调 (text) => void
+ * @param {Function} options.onComplete - 完成回调 (fullText) => void
+ * @param {Function} options.onError - 错误回调 (error) => void
+ * @param {boolean} options.enableTypewriter - 是否启用打字机效果,默认 true
+ * @param {number} options.typewriterSpeed - 打字机速度(毫秒),默认 10
+ * @param {number} options.typewriterBatchSize - 每次显示的字符数,默认 2
+ * @param {boolean} options.enableContext - 是否使用上下文,默认 true
+ * @param {boolean} options.enableWebSearch - 是否启用网络搜索,默认 false
+ * @param {Array} options.attachmentUrls - 附件URL列表
+ * @param {number} options.timeout - 超时时间(毫秒),默认 120000
+ * @returns {Promise} 返回完整文本
+ */
+export async function streamChat(options = {}) {
+ const {
+ conversationId,
+ content,
+ onUpdate,
+ onComplete,
+ onError,
+ enableTypewriter = true,
+ typewriterSpeed = 10,
+ typewriterBatchSize = 2,
+ enableContext = true,
+ enableWebSearch = false,
+ attachmentUrls = [],
+ timeout = 120000
+ } = options
+
+ if (!conversationId || !content) {
+ throw new Error('conversationId 和 content 是必需的')
+ }
+
+ const ctrl = new AbortController()
+ let fullText = ''
+ let errorOccurred = false
+ let isResolved = false
+
+ // 创建打字机管理器
+ const typewriter = enableTypewriter
+ ? new TypewriterManager(
+ (text) => {
+ if (typeof onUpdate === 'function') {
+ onUpdate(text)
+ }
+ },
+ {
+ speed: typewriterSpeed,
+ batchSize: typewriterBatchSize
+ }
+ )
+ : null
+
+ // 平滑更新函数(使用 requestAnimationFrame)
+ let pendingUpdate = null
+ const smoothUpdate = (text) => {
+ if (pendingUpdate !== null) {
+ cancelAnimationFrame(pendingUpdate)
+ }
+ pendingUpdate = requestAnimationFrame(() => {
+ if (enableTypewriter && typewriter) {
+ typewriter.addText(text)
+ } else {
+ if (typeof onUpdate === 'function') {
+ onUpdate(text)
+ }
+ }
+ pendingUpdate = null
+ })
+ }
+
+ return new Promise((resolve, reject) => {
+ const timeoutId = setTimeout(() => {
+ if (!isResolved) {
+ ctrl.abort()
+ if (typewriter) typewriter.flush()
+ const error = new Error('请求超时,请稍后重试')
+ if (typeof onError === 'function') {
+ onError(error)
+ } else {
+ message.error('请求超时,请稍后重试')
+ }
+ reject(error)
+ }
+ }, timeout)
+
+ ChatMessageApi.sendChatMessageStream({
+ conversationId,
+ content,
+ ctrl,
+ enableContext,
+ enableWebSearch,
+ attachmentUrls,
+ 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
+ }
+
+ // 获取内容片段
+ const piece = responseData.receive?.content || responseData.receive?.reasoningContent || ''
+ if (piece) {
+ fullText += piece
+ // 使用平滑更新
+ smoothUpdate(fullText)
+ }
+ } catch {
+ // 兼容其他格式
+ try {
+ const obj = JSON.parse(dataStr)
+ const piece = obj?.content || obj?.data || obj?.text || ''
+ if (piece) {
+ fullText += piece
+ smoothUpdate(fullText)
+ }
+ } catch (parseErr) {
+ console.warn('解析流数据异常:', parseErr)
+ }
+ }
+ } catch (e) {
+ console.warn('处理流数据异常:', e)
+ }
+ },
+ onError: (err) => {
+ clearTimeout(timeoutId)
+ if (!isResolved) {
+ errorOccurred = true
+ ctrl.abort()
+ if (typewriter) typewriter.flush()
+ const errorMsg = err?.message || '网络请求失败'
+ console.error('SSE请求错误:', err)
+
+ if (typeof onError === 'function') {
+ onError(err)
+ } else {
+ message.error(errorMsg)
+ }
+
+ reject(new Error(errorMsg))
+ }
+ },
+ onClose: () => {
+ clearTimeout(timeoutId)
+ if (!isResolved) {
+ isResolved = true
+ // 确保所有文本都显示完成
+ if (typewriter) {
+ typewriter.flush()
+ }
+ const finalText = fullText.trim()
+
+ if (typeof onComplete === 'function') {
+ onComplete(finalText)
+ }
+
+ resolve(finalText)
+ }
+ }
+ })
+ })
+}
+
+/**
+ * 简化版:快速流式更新(无打字机效果,但更平滑)
+ */
+export async function streamChatFast(options = {}) {
+ return streamChat({
+ ...options,
+ enableTypewriter: false
+ })
+}
+
diff --git a/frontend/app/web-gold/src/views/content-style/Benchmark.vue b/frontend/app/web-gold/src/views/content-style/Benchmark.vue
index e12e73826c..cd547e02a9 100644
--- a/frontend/app/web-gold/src/views/content-style/Benchmark.vue
+++ b/frontend/app/web-gold/src/views/content-style/Benchmark.vue
@@ -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' })