优化前端
This commit is contained in:
@@ -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",
|
||||
|
||||
221
frontend/app/web-gold/src/components/ChatMessageRenderer.vue
Normal file
221
frontend/app/web-gold/src/components/ChatMessageRenderer.vue
Normal file
@@ -0,0 +1,221 @@
|
||||
<template>
|
||||
<div class="prompt-display" v-html="renderedContent"></div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import { renderMarkdown } from '@/utils/markdown'
|
||||
|
||||
const props = defineProps({
|
||||
content: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
isStreaming: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
// 已完成的 chunks(包含已渲染的 HTML)
|
||||
const chunks = ref([])
|
||||
// chunk ID 计数器
|
||||
const chunkIdCounter = ref(0)
|
||||
|
||||
/**
|
||||
* 创建 chunk 对象(包含预渲染的 HTML)
|
||||
*/
|
||||
function createChunk(text) {
|
||||
return {
|
||||
id: chunkIdCounter.value++,
|
||||
text: text.trim(),
|
||||
rendered: renderMarkdown(text.trim()) // 预渲染,避免重复渲染
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 智能分块:业界成熟方案
|
||||
*/
|
||||
function splitIntoChunks(text) {
|
||||
if (!text || !text.trim()) return []
|
||||
|
||||
const result = []
|
||||
|
||||
// 第一级:按段落分割(双换行)
|
||||
const paragraphs = text.split(/\n\s*\n/)
|
||||
|
||||
for (const paragraph of paragraphs) {
|
||||
if (!paragraph.trim()) continue
|
||||
|
||||
// 如果段落长度适中(< 500字符),直接作为一个 chunk
|
||||
if (paragraph.trim().length < 500) {
|
||||
result.push(createChunk(paragraph))
|
||||
continue
|
||||
}
|
||||
|
||||
// 第二级:段落太长,按句子分割
|
||||
const sentences = paragraph.split(/([。!?.!?]\s*)/)
|
||||
let currentChunk = ''
|
||||
|
||||
for (let i = 0; i < sentences.length; i++) {
|
||||
currentChunk += sentences[i]
|
||||
|
||||
// 如果累积的句子达到一定长度(> 300字符),创建一个 chunk
|
||||
if (currentChunk.trim().length > 300) {
|
||||
result.push(createChunk(currentChunk))
|
||||
currentChunk = ''
|
||||
}
|
||||
}
|
||||
|
||||
// 处理剩余的文本
|
||||
if (currentChunk.trim()) {
|
||||
if (currentChunk.trim().length > 500) {
|
||||
// 第三级:按固定长度分割
|
||||
const fixedLength = 400
|
||||
let start = 0
|
||||
while (start < currentChunk.length) {
|
||||
const end = Math.min(start + fixedLength, currentChunk.length)
|
||||
const chunkText = currentChunk.slice(start, end).trim()
|
||||
if (chunkText) {
|
||||
result.push(createChunk(chunkText))
|
||||
}
|
||||
start = end
|
||||
}
|
||||
} else {
|
||||
result.push(createChunk(currentChunk))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算流式传输中的文本
|
||||
*/
|
||||
const streamingText = computed(() => {
|
||||
if (!props.isStreaming || !props.content) return ''
|
||||
|
||||
const text = props.content
|
||||
|
||||
// 优先查找最后一个段落分隔符
|
||||
const lastParagraphIndex = text.lastIndexOf('\n\n')
|
||||
if (lastParagraphIndex !== -1) {
|
||||
return text.slice(lastParagraphIndex + 2)
|
||||
}
|
||||
|
||||
// 查找最后一个句子结束符
|
||||
const lastSentenceMatch = text.match(/([。!?.!?]\s*)(?!.*[。!?.!?]\s*)/)
|
||||
if (lastSentenceMatch) {
|
||||
return text.slice(lastSentenceMatch.index + lastSentenceMatch[0].length)
|
||||
}
|
||||
|
||||
// 全部作为流式文本
|
||||
return text
|
||||
})
|
||||
|
||||
/**
|
||||
* 计算最终渲染的内容(合并所有 chunks 和流式文本)
|
||||
*/
|
||||
const renderedContent = computed(() => {
|
||||
let html = ''
|
||||
|
||||
// 添加已完成的 chunks
|
||||
for (const chunk of chunks.value) {
|
||||
html += chunk.rendered
|
||||
}
|
||||
|
||||
// 添加流式传输中的文本
|
||||
if (props.isStreaming && streamingText.value) {
|
||||
html += renderMarkdown(streamingText.value)
|
||||
}
|
||||
|
||||
return html
|
||||
})
|
||||
|
||||
/**
|
||||
* 增量更新 chunks(避免全量重新渲染)
|
||||
*/
|
||||
function updateChunksIncremental(content) {
|
||||
if (!content) {
|
||||
chunks.value = []
|
||||
return
|
||||
}
|
||||
|
||||
const text = content
|
||||
|
||||
// 查找最后一个段落分隔符
|
||||
const lastParagraphIndex = text.lastIndexOf('\n\n')
|
||||
let completedText = ''
|
||||
|
||||
if (lastParagraphIndex !== -1) {
|
||||
completedText = text.slice(0, lastParagraphIndex)
|
||||
} else {
|
||||
// 没有段落分隔符,查找最后一个句子结束符
|
||||
const lastSentenceMatch = text.match(/([。!?.!?]\s*)(?!.*[。!?.!?]\s*)/)
|
||||
if (lastSentenceMatch && lastSentenceMatch.index > 300) {
|
||||
completedText = text.slice(0, lastSentenceMatch.index + lastSentenceMatch[0].length)
|
||||
} else {
|
||||
// 流式传输中,还没有完整的段落或句子
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 分割已完成的文本
|
||||
const newChunks = splitIntoChunks(completedText)
|
||||
|
||||
// 增量更新:只添加新的 chunks,保留已存在的
|
||||
if (newChunks.length > chunks.value.length) {
|
||||
// 只添加新增的 chunks
|
||||
const existingTexts = new Set(chunks.value.map(c => c.text))
|
||||
for (let i = chunks.value.length; i < newChunks.length; i++) {
|
||||
if (!existingTexts.has(newChunks[i].text)) {
|
||||
chunks.value.push(newChunks[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理内容更新
|
||||
*/
|
||||
function updateChunks(content) {
|
||||
if (!content) {
|
||||
chunks.value = []
|
||||
return
|
||||
}
|
||||
|
||||
if (props.isStreaming) {
|
||||
// 流式模式:增量更新
|
||||
updateChunksIncremental(content)
|
||||
} else {
|
||||
// 非流式模式:完整分割
|
||||
chunks.value = splitIntoChunks(content)
|
||||
}
|
||||
}
|
||||
|
||||
// 监听 content 变化
|
||||
watch(() => props.content, (newContent) => {
|
||||
updateChunks(newContent)
|
||||
}, { immediate: true })
|
||||
|
||||
// 监听 isStreaming 变化:流式结束时处理最后一个未完成的块
|
||||
watch(() => props.isStreaming, (newVal, oldVal) => {
|
||||
if (!newVal && oldVal && props.content) {
|
||||
// 流式结束,完整重新分割
|
||||
const newChunks = splitIntoChunks(props.content)
|
||||
chunks.value = newChunks
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 修复 pre 标签撑开容器的问题 */
|
||||
.prompt-display :deep(pre) {
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
word-break: break-word;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
</style>
|
||||
193
frontend/app/web-gold/src/components/GlobalLoading.vue
Normal file
193
frontend/app/web-gold/src/components/GlobalLoading.vue
Normal file
@@ -0,0 +1,193 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="fade">
|
||||
<div v-if="visible" class="global-loading-overlay" @click.self="handleClick">
|
||||
<div class="global-loading-content">
|
||||
<div class="loading-spinner">
|
||||
<div class="spinner-ring"></div>
|
||||
<div class="spinner-ring"></div>
|
||||
<div class="spinner-ring"></div>
|
||||
<div class="spinner-ring"></div>
|
||||
</div>
|
||||
<div v-if="text" class="loading-text">{{ text }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { defineProps, defineEmits, watch, onUnmounted } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
visible: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
text: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
clickable: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['click'])
|
||||
|
||||
function handleClick() {
|
||||
if (props.clickable) {
|
||||
emit('click')
|
||||
}
|
||||
}
|
||||
|
||||
// 禁用/启用页面滚动
|
||||
function disableBodyScroll() {
|
||||
const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth
|
||||
document.body.classList.add('global-loading-no-scroll')
|
||||
// 如果有滚动条,使用 CSS 变量存储宽度,防止页面抖动
|
||||
if (scrollbarWidth > 0) {
|
||||
document.documentElement.style.setProperty('--global-loading-scrollbar-width', `${scrollbarWidth}px`)
|
||||
}
|
||||
}
|
||||
|
||||
function enableBodyScroll() {
|
||||
document.body.classList.remove('global-loading-no-scroll')
|
||||
document.documentElement.style.removeProperty('--global-loading-scrollbar-width')
|
||||
}
|
||||
|
||||
// 监听 visible 变化,控制页面滚动
|
||||
watch(() => props.visible, (newVal) => {
|
||||
if (newVal) {
|
||||
disableBodyScroll()
|
||||
} else {
|
||||
enableBodyScroll()
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// 组件卸载时恢复滚动
|
||||
onUnmounted(() => {
|
||||
enableBodyScroll()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.global-loading-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
backdrop-filter: blur(4px);
|
||||
z-index: 99999;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.global-loading-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* Loading 动画 */
|
||||
.loading-spinner {
|
||||
position: relative;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
}
|
||||
|
||||
.spinner-ring {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 3px solid transparent;
|
||||
border-top-color: var(--color-primary, #3B82F6);
|
||||
border-radius: 50%;
|
||||
animation: spin 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite;
|
||||
}
|
||||
|
||||
.spinner-ring:nth-child(1) {
|
||||
animation-delay: -0.45s;
|
||||
border-top-color: var(--color-primary, #3B82F6);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.spinner-ring:nth-child(2) {
|
||||
animation-delay: -0.3s;
|
||||
border-top-color: rgba(59, 130, 246, 0.8);
|
||||
opacity: 0.8;
|
||||
width: 80%;
|
||||
height: 80%;
|
||||
top: 10%;
|
||||
left: 10%;
|
||||
}
|
||||
|
||||
.spinner-ring:nth-child(3) {
|
||||
animation-delay: -0.15s;
|
||||
border-top-color: rgba(59, 130, 246, 0.6);
|
||||
opacity: 0.6;
|
||||
width: 60%;
|
||||
height: 60%;
|
||||
top: 20%;
|
||||
left: 20%;
|
||||
}
|
||||
|
||||
.spinner-ring:nth-child(4) {
|
||||
animation-delay: 0s;
|
||||
border-top-color: rgba(59, 130, 246, 0.4);
|
||||
opacity: 0.4;
|
||||
width: 40%;
|
||||
height: 40%;
|
||||
top: 30%;
|
||||
left: 30%;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
color: var(--color-text, #F2F2F2);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* 淡入淡出动画 */
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.fade-enter-to,
|
||||
.fade-leave-from {
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
/* 全局样式:禁用页面滚动 */
|
||||
body.global-loading-no-scroll {
|
||||
overflow: hidden !important;
|
||||
padding-right: var(--global-loading-scrollbar-width, 0);
|
||||
}
|
||||
</style>
|
||||
|
||||
331
frontend/app/web-gold/src/utils/excel.js
Normal file
331
frontend/app/web-gold/src/utils/excel.js
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
286
frontend/app/web-gold/src/utils/streamChat.js
Normal file
286
frontend/app/web-gold/src/utils/streamChat.js
Normal file
@@ -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<string>} 返回完整文本
|
||||
*/
|
||||
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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user