优化前端
This commit is contained in:
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
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user