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' })
分析结果
- + + + + 导出Excel ({{ selectedRowKeys.length }}/10) + 批量分析 ({{ selectedRowKeys.length }}) @@ -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"> @@ -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; +} diff --git a/frontend/package.json b/frontend/package.json index 1091ec9b32..88fa1dd2fd 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -5,7 +5,7 @@ "scripts": { "test": "echo \"Error: no test specified\" && exit 1", "gold": "pnpm --filter ./app/web-gold run dev", - "build:gold:prod": "pnpm --filter ./app/web-gold run build" + "build:gold": "pnpm --filter ./app/web-gold run build" }, "author": "", "license": "ISC",