优化前端

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

View File

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

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

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

View 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
}
}
}

View 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
})
}

View File

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

View File

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