优化前端

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

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