Files
sionrui/frontend/app/web-gold/src/views/content-style/Benchmark.vue
2025-11-10 23:53:05 +08:00

1446 lines
41 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup>
import dayjs from 'dayjs'
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 } from 'vue'
import { message } from 'ant-design-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 GlobalLoading from '@/components/GlobalLoading.vue'
import { streamChat } from '@/utils/streamChat'
import ChatMessageRenderer from '@/components/ChatMessageRenderer.vue'
// ==================== 初始化 ====================
const router = useRouter()
const promptStore = usePromptStore()
const { getVoiceText } = useVoiceText()
// ==================== 常量定义 ====================
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([])
const batchPrompt = ref('')
const modalVisible = ref(false)
const editingRowId = ref(null)
const batchPromptEditMode = ref(false)
const batchPromptGenerating = ref(false)
const form = ref({
platform: '抖音',
url: '',
count: 20,
sort_type: 0,
})
// ==================== 表格配置 ====================
const defaultColumns = [
{ title: '封面', key: 'cover', dataIndex: 'cover', width: 120, resizable: true },
{ title: '描述', key: 'desc', dataIndex: 'desc', width: 280, resizable: true, ellipsis: true },
{ title: '点赞', key: 'digg_count', dataIndex: 'digg_count', width: 90, resizable: true,
sorter: (a, b) => (a.digg_count || 0) - (b.digg_count || 0), defaultSortOrder: 'descend' },
{ title: '评论', key: 'comment_count', dataIndex: 'comment_count', width: 90, resizable: true,
sorter: (a, b) => (a.comment_count || 0) - (b.comment_count || 0) },
{ title: '分享', key: 'share_count', dataIndex: 'share_count', width: 90, resizable: true,
sorter: (a, b) => (a.share_count || 0) - (b.share_count || 0) },
{ title: '收藏', key: 'collect_count', dataIndex: 'collect_count', width: 90, resizable: true,
sorter: (a, b) => (a.collect_count || 0) - (b.collect_count || 0) },
{ title: '时长(s)', key: 'duration_s', dataIndex: 'duration_s', width: 90, resizable: true,
sorter: (a, b) => (a.duration_s || 0) - (b.duration_s || 0) },
{ title: '置顶', key: 'is_top', dataIndex: 'is_top', width: 70, resizable: true },
{ title: '创建时间', key: 'create_time', dataIndex: 'create_time', width: 160, resizable: true,
sorter: (a, b) => (a.create_time || 0) - (b.create_time || 0) },
{ title: '链接', key: 'share_url', dataIndex: 'share_url', width: 80, resizable: true,
customRender: ({ record }) => record.share_url ? h('a', { href: record.share_url, target: '_blank' }, '打开') : null },
{ title: '操作', key: 'action', width: 100, resizable: true, fixed: 'right' },
]
const columns = reactive([...defaultColumns])
// ==================== 计算属性 ====================
// batchPromptRendered 已移除,使用 ChatMessageRenderer 组件渲染
// ==================== 数据处理函数 ====================
/**
* 映射抖音数据结构
*/
function mapFromDouyin(awemeList) {
return awemeList.map((item, idx) => ({
id: item?.statistics?.aweme_id || item?.aweme_id || idx + 1,
cover: item?.video?.origin_cover?.url_list?.[0] || item?.video?.cover?.url_list?.[0]
|| item?.video?.dynamic_cover?.url_list?.[0] || item?.video?.animated_cover?.url_list?.[0] || '',
is_top: item?.is_top ? 1 : 0,
create_time: item?.create_time,
audio_url: item?.video?.play_addr?.url_list?.reverse()[0] || '',
desc: item?.desc || item?.caption || '',
duration_s: Math.round((item?.video?.duration ?? 0) / 1000),
digg_count: item?.statistics?.digg_count ?? 0,
comment_count: item?.statistics?.comment_count ?? 0,
share_count: item?.statistics?.share_count ?? 0,
collect_count: item?.statistics?.collect_count ?? 0,
play_count: item?.statistics?.play_count ?? 0,
share_url: item?.share_info?.share_url || '',
}))
}
/**
* 映射小红书数据结构
*/
function mapFromXhs(notes) {
return notes.map((note, idx) => ({
id: note?.note_id || note?.id || idx + 1,
cover: note?.cover?.url || note?.image_list?.[0]?.url || '',
is_top: 0,
create_time: note?.time || note?.create_time,
desc: note?.desc || note?.title || '',
duration_s: 0,
digg_count: note?.liked_count ?? note?.likes ?? 0,
comment_count: note?.comment_count ?? 0,
share_count: note?.share_count ?? 0,
play_count: note?.view_count ?? 0,
share_url: note?.link || '',
}))
}
/**
* 格式化时间戳
*/
function formatTime(ts) {
if (!ts) return ''
const ms = ts > 1e12 ? ts : ts * 1000
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 {
// 过滤掉不需要持久化的临时字段(如 _analyzing
const persistData = (data.value || []).map((item) => {
const rest = { ...(item || {}) }
delete rest._analyzing
return rest
})
await storage.setJSON(TABLE_DATA_STORAGE_KEY, persistData)
} catch (error) {
console.warn('保存表格数据到session失败:', error)
}
}
async function loadTableDataFromSession() {
try {
const savedData = await storage.getJSON(TABLE_DATA_STORAGE_KEY)
if (savedData && Array.isArray(savedData) && savedData.length > 0) {
// 强制恢复临时字段的初始状态
data.value = savedData.map((item) => ({ ...item, _analyzing: false }))
console.log('从session加载了表格数据:', savedData.length, '条')
}
} catch (error) {
console.warn('从session加载表格数据失败:', error)
}
}
// 列宽持久化能力已移除
// ==================== AI 相关工具函数 ====================
/**
* 根据转写内容构建 AI 提示
*/
function buildPromptFromTranscription(text) {
if (text && text.trim()) return `${text}`
return '没有可用的语音转写内容,请给出一份适合短视频脚本创作的通用高质量提示词模板(包含框架、角色、语气、风格、内容要点等)。'
}
/**
* 通用SSE 流式发送并累计文本,支持打字机效果和平滑更新
* @deprecated 使用 streamChat 替代,此函数保留用于兼容
*/
async function sendPromptWithStream(conversationId, content, onChunk) {
return await streamChat({
conversationId,
content,
onUpdate: onChunk,
enableTypewriter: true,
typewriterSpeed: 10,
typewriterBatchSize: 2
})
}
// ==================== API 调用函数 ====================
/**
* 分析用户主页,获取视频列表
*/
async function analyzeUser() {
const sec_user_id = resolveId(form.value.url, {
queryKeys: ['user'],
pathPatterns: ['/user/:id'],
})
if (!sec_user_id) {
return
}
loading.value = true
const isDouyin = form.value.platform === '抖音'
const req = await TikhubService.postTikHup({
type: InterfaceType.DOUYIN_WEB_USER_POST_VIDEOS,
methodType: MethodType.GET,
urlParams: {
sec_user_id,
max_cursor: 0,
type: 'tik-app',
sort_type: form.value.sort_type,
count: form.value.count || 20,
},
})
try {
const resp = await req
if (isDouyin) {
const awemeList = resp?.data?.aweme_list || []
console.log('抖音返回的原始数据:', awemeList[0])
data.value = mapFromDouyin(awemeList)
console.log('映射后的第一条数据:', data.value[0])
} else {
const notes = resp?.data?.notes || resp?.data?.data || []
data.value = mapFromXhs(notes)
}
await saveTableDataToSession()
message.success('分析完成')
} catch (err) {
console.error(err)
message.error('请求失败,请稍后重试')
} finally {
loading.value = false
}
}
/**
* 分析单个视频,获取提示词
*/
async function analyzeVideo(row) {
try {
if (row._analyzing) return
row._analyzing = true
// 1) 获取音频转写
message.info('正在获取音频转写...')
const transcriptions = await getVoiceText([row])
row.transcriptions = transcriptions.find(item => item.audio_url === row.audio_url)?.value
// 2) 检查是否有语音文案
if (!row.transcriptions || !row.transcriptions.trim()) {
message.warning('未提取到语音内容,请检查音频文件或稍后重试')
row._analyzing = false
return false
}
// 3) 创建对话
message.info('正在创建对话...')
const createPayload = { roleId: 20, role_id: 20 }
console.debug('createChatConversationMy payload:', createPayload)
const conversationResp = await ChatMessageApi.createChatConversationMy(createPayload)
let conversationId = null
if (conversationResp?.data) {
conversationId = typeof conversationResp.data === 'object' ? conversationResp.data.id : conversationResp.data
}
if (!conversationId) {
throw new Error('创建对话失败:未获取到 conversationId')
}
// 4) 基于转写构建提示,流式生成并实时写入 UI
message.info('正在生成提示词...')
const content = buildPromptFromTranscription(row.transcriptions)
const index = data.value.findIndex(item => item.id === row.id)
const aiContent = await sendPromptWithStream(conversationId, content, (fullText) => {
if (index !== -1) data.value[index].prompt = fullText
})
// 5) 兜底处理
const finalPrompt = aiContent || row.transcriptions || ''
if (index !== -1) data.value[index].prompt = finalPrompt
// 6) 分析完成后自动展开该行
const rowId = String(row.id) // 确保类型一致
if (!expandedRowKeys.value.includes(rowId)) {
expandedRowKeys.value.push(rowId)
}
// 7) 保存数据到 session
await saveTableDataToSession()
message.success('分析完成')
return true
} catch (error) {
console.error('分析视频失败:', error)
message.error('分析失败,请稍后重试')
return false
} finally {
row._analyzing = false
}
}
/**
* 批量分析选中的视频
*/
async function batchAnalyze() {
if (selectedRowKeys.value.length === 0) {
message.warning('请先选择要分析的视频')
return
}
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)
// 2. 收集所有转写内容
const allTexts = []
for (const id of selectedRowKeys.value) {
const row = data.value.find(item => item.id === id)
if (row && row.audio_url) {
const transcription = transcriptions.find(item => item.audio_url === row.audio_url)
if (transcription && transcription.value && transcription.value.trim()) {
allTexts.push({ id: row.id, url: row.audio_url, text: transcription.value })
row.transcriptions = transcription.value
}
}
}
// 3. 检查是否有可用的语音内容
if (allTexts.length === 0) {
message.warning('未提取到任何语音内容,请检查音频文件或稍后重试')
batchAnalyzeLoading.value = false
globalLoading.value = false
globalLoadingText.value = ''
return
}
await saveTableDataToSession()
const mergedText = allTexts.map(item => item.text).join('\n\n---\n\n')
// 4. 打开弹窗
modalVisible.value = true
batchAnalyzeLoading.value = false
globalLoading.value = false
globalLoadingText.value = ''
// 5. 在弹窗中流式生成 AI 内容
try {
batchPromptGenerating.value = true
const createPayload2 = { roleId: 20,}
console.debug('createChatConversationMy payload(batch):', createPayload2)
const conversationResp = await ChatMessageApi.createChatConversationMy(createPayload2)
let conversationId = null
if (conversationResp?.data) {
conversationId = typeof conversationResp.data === 'object' ? conversationResp.data.id : conversationResp.data
}
if (!conversationId) {
throw new Error('创建对话失败:未获取到 conversationId')
}
const aiContent = await streamChat({
conversationId,
content: mergedText,
onUpdate: (fullText) => {
batchPrompt.value = fullText
},
enableTypewriter: true,
typewriterSpeed: 10,
typewriterBatchSize: 2
})
// streamChat 已经通过 onUpdate 更新了 batchPrompt这里确保最终值正确
if (aiContent && aiContent !== batchPrompt.value) {
batchPrompt.value = aiContent
}
message.success(`批量分析完成:已基于 ${allTexts.length} 个视频的文案生成综合提示词`)
} catch (aiError) {
console.error('AI生成失败:', aiError)
message.error('AI生成失败请稍后重试')
} finally {
batchPromptGenerating.value = false
}
} catch (error) {
console.error('批量分析失败:', error)
message.error('批量分析失败,请稍后重试')
batchAnalyzeLoading.value = false
globalLoading.value = false
globalLoadingText.value = ''
} finally {
selectedRowKeys.value = []
}
}
// ==================== UI 交互函数 ====================
function onSelectChange(selectedKeys) {
selectedRowKeys.value = selectedKeys
}
function onView(row) {
analyzeVideo(row)
}
function toggleEditMode(row) {
if (editingRowId.value === row.id) {
editingRowId.value = null
} else {
editingRowId.value = row.id
}
}
async function resetForm() {
form.value = { platform: '抖音', url: '', count: 20, sort_type: 0 }
data.value = []
selectedRowKeys.value = []
expandedRowKeys.value = []
await storage.remove(TABLE_DATA_STORAGE_KEY)
}
// ==================== 创作相关函数 ====================
function onCreateContent(row) {
promptStore.setPrompt(row.prompt, row)
router.push('/content-style/copywriting')
}
function useBatchPrompt() {
if (!batchPrompt.value.trim()) {
message.warning('暂无批量生成的提示词')
return
}
promptStore.setPrompt(batchPrompt.value, { batch: true })
router.push('/content-style/copywriting')
}
// ==================== 提示词操作函数 ====================
async function savePrompt(row) {
if (!row || !row.id) return
try {
const key = `video_prompt_${row.id}`
await storage.setJSON(key, { id: row.id, prompt: row.prompt || '', ts: Date.now() })
const index = data.value.findIndex(item => item.id === row.id)
if (index !== -1) {
data.value[index].prompt = row.prompt
await saveTableDataToSession()
}
message.success('提示词已保存')
} catch (e) {
console.error(e)
message.error('保存失败,请稍后重试')
}
}
function copyPrompt(row) {
if (!row.prompt) {
message.warning('没有提示词可复制')
return
}
navigator.clipboard.writeText(row.prompt).then(() => {
message.success('提示词已复制到剪贴板')
}).catch(() => {
message.error('复制失败')
})
}
function copyBatchPrompt() {
if (!batchPrompt.value.trim()) {
message.warning('没有提示词可复制')
return
}
navigator.clipboard.writeText(batchPrompt.value).then(() => {
message.success('提示词已复制到剪贴板')
}).catch(() => {
message.error('复制失败')
})
}
// ==================== 生命周期 ====================
onMounted(async () => {
await loadTableDataFromSession()
})
defineOptions({ name: 'ContentStyleBenchmark' })
</script>
<template>
<div class="page">
<a-spin :spinning="batchAnalyzeLoading" tip="批量分析中,请稍候..." wrapperClassName="batch-analyze-spin-wrapper">
<div class="stack">
<!-- 表单区域 -->
<section class="card">
<a-form :model="form" layout="vertical">
<a-form-item label="平台">
<a-radio-group v-model:value="form.platform" button-style="solid">
<a-radio-button value="抖音">抖音</a-radio-button>
</a-radio-group>
</a-form-item>
<a-form-item label="主页/视频链接">
<a-input
v-model:value="form.url"
placeholder="粘贴抖音主页或视频链接,或点击下方示例试一试"
allow-clear
size="large"
/>
</a-form-item>
<a-form-item label="最大数量建议保持默认值20">
<div class="slider-row">
<a-slider v-model:value="form.count" :min="1" :max="100" :tooltip-open="true" style="flex:1" />
<a-input-number v-model:value="form.count" :min="1" :max="100" style="width:96px; margin-left:12px;" />
</div>
<div class="form-hint">数量越大越全面但分析时间更长建议 2030</div>
</a-form-item>
<!-- <a-form-item label="排序类型">
<a-radio-group v-model:value="form.sort_type">
<a-radio :value="0">最新</a-radio>
<a-radio :value="1">热门</a-radio>
</a-radio-group>
</a-form-item> -->
<a-space>
<a-button type="primary" :loading="loading" @click="analyzeUser">
{{ loading ? '分析中' : '开始分析' }}
</a-button>
<a-button @click="resetForm">清空</a-button>
</a-space>
</a-form>
</section>
<!-- 表格区域 -->
<section class="card results-card" v-if="data.length > 0">
<div class="section-header">
<div class="section-title">分析结果</div>
<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>
</a-space>
</div>
<a-table
:dataSource="data"
:columns="columns"
:pagination="false"
:row-selection="{ selectedRowKeys, onChange: onSelectChange, hideSelectAll: true }"
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 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>
<div class="transcript-box" v-if="record.transcriptions">
<div class="transcript-content">{{ record.transcriptions }}</div>
</div>
<div v-else class="no-transcript">暂无转写文本请先点击"分析"获取</div>
</section>
<!-- 右侧提示词可编辑 -->
<section class="col right-col">
<div class="sub-title">
提示词可编辑
<a-button
v-if="editingRowId !== record.id"
size="small"
type="text"
class="edit-hint"
:title="'编辑'"
@click="toggleEditMode(record)">
<template #icon>
<EditOutlined />
</template>
</a-button>
</div>
<div v-if="editingRowId !== record.id" class="prompt-display-wrapper">
<ChatMessageRenderer
:content="record.prompt || ''"
:is-streaming="record._analyzing || false"
/>
<div v-if="!record.prompt" class="no-prompt">暂无提示词</div>
</div>
<div v-else>
<a-textarea v-model:value="record.prompt" :rows="12" placeholder="这里编辑/完善提示词..." />
</div>
<div class="right-actions">
<a-space>
<a-button v-if="editingRowId === record.id" type="primary" @click="savePrompt(record); toggleEditMode(record)">
保存提示词
</a-button>
<a-button
v-if="editingRowId !== record.id"
size="small"
type="text"
class="copy-btn"
:title="'复制'"
@click="copyPrompt(record)">
<template #icon>
<CopyOutlined />
</template>
</a-button>
<a-button
type="dashed"
:disabled="!record.prompt || record._analyzing"
@click="onCreateContent(record)">基于提示词去创作</a-button>
</a-space>
</div>
</section>
</div>
</div>
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'cover'">
<img v-if="record.cover" :src="record.cover" alt="cover" loading="lazy"
style="width:120px;height:68px;object-fit:cover;border-radius:6px;border:1px solid #eee;" />
<span v-else style="color:#999;font-size:12px;">无封面</span>
</template>
<template v-else-if="column.key === 'desc'">
<span :title="record.desc">{{ record.desc || '-' }}</span>
</template>
<template v-else-if="column.key === 'play_count'">
{{ 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.toLocaleString('zh-CN') : '0' }}
</template>
<template v-else-if="column.key === 'comment_count'">
{{ 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.toLocaleString('zh-CN') : '0' }}
</template>
<template v-else-if="column.key === 'collect_count'">
{{ 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>
<span v-else>-</span>
</template>
<template v-else-if="column.key === 'create_time'">
{{ formatTime(record.create_time) }}
</template>
<template v-else-if="column.key === 'share_url'">
<a v-if="record.share_url" :href="record.share_url" target="_blank" class="link-btn">打开</a>
<span v-else>-</span>
</template>
<template v-else-if="column.key === 'action'">
<a-space>
<a-button size="small" type="primary" :loading="record._analyzing" :disabled="record._analyzing" @click="onView(record)">
{{ record._analyzing ? '分析中' : '分析' }}
</a-button>
</a-space>
</template>
</template>
</a-table>
</section>
<!-- 空态显示 -->
<section class="card results-card empty-state" v-if="data.length === 0 && !loading">
<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"/>
<line x1="32" y1="75" x2="88" y2="75" stroke="currentColor" stroke-width="2" opacity="0.3"/>
<line x1="32" y1="82" x2="88" y2="82" stroke="currentColor" stroke-width="2" opacity="0.3"/>
<line x1="32" y1="89" x2="72" y2="89" stroke="currentColor" stroke-width="2" opacity="0.3"/>
</svg>
</template>
</a-empty>
</section>
</div>
<!-- 批量分析结果弹窗 -->
<a-modal
v-model:open="modalVisible"
title="综合分析结果"
:width="800"
:maskClosable="false"
:keyboard="false">
<div class="batch-prompt-modal">
<div v-if="!batchPromptEditMode" class="batch-prompt-display">
<ChatMessageRenderer
:content="batchPrompt"
:is-streaming="batchPromptGenerating"
/>
</div>
<a-textarea
v-else
v-model:value="batchPrompt"
:rows="15"
placeholder="内容将在这里显示..." />
</div>
<template #footer>
<a-space>
<a-button size="small" :title="batchPromptEditMode ? '取消编辑' : '编辑'" @click="batchPromptEditMode = !batchPromptEditMode">
<template #icon>
<EditOutlined />
</template>
</a-button>
<a-button size="small" title="复制" @click="copyBatchPrompt">
<template #icon>
<CopyOutlined />
</template>
</a-button>
<a-button @click="modalVisible = false">取消</a-button>
<a-button
type="primary"
:disabled="batchPromptGenerating || !batchPrompt.trim()"
@click="useBatchPrompt">去创作</a-button>
</a-space>
</template>
</a-modal>
</a-spin>
<!-- 全局 Loading 遮罩 -->
<GlobalLoading :visible="globalLoading" :text="globalLoadingText" />
</div>
</template>
<style scoped>
/* 页面垂直堆叠间距 */
.stack>*+* {
margin-top: 16px;
}
/* 稳定滚动条,避免内容高度变化导致页面左右抖动 */
.page {
scrollbar-gutter: stable both-edges;
}
/* 卡片样式(不依赖 tailwind */
.card {
background: var(--color-surface);
border-radius: 8px;
padding: 16px;
box-shadow: var(--shadow-inset-card);
border: 1px solid var(--color-border);
}
/* 顶部引导 */
.guide {
background: rgba(26,102,224,0.10);
border: 1px solid rgba(26,102,224,0.35);
border-radius: 8px;
padding: 12px;
}
.guide-steps {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
}
.guide-step {
display: flex;
align-items: center;
gap: 8px;
color: var(--color-text-secondary);
}
.step-badge {
width: 20px;
height: 20px;
display: inline-flex;
align-items: center;
justify-content: center;
background: var(--color-blue);
color: #fff;
border-radius: 50%;
font-size: 12px;
box-shadow: 0 0 0 2px rgba(26,102,224,0.2);
}
/* 结果区预留最小高度,切换视图时避免高度突变 */
.results-card {
min-height: 420px;
}
/* 空态卡片样式 */
.empty-state {
display: flex;
align-items: center;
justify-content: center;
min-height: 420px;
}
.empty-state :deep(.ant-empty) {
padding: 40px 0;
}
.empty-state :deep(.ant-empty-description) {
color: var(--color-text-secondary);
font-size: 14px;
}
/* 两列网格 */
.grid-2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
/* 标题文本(次要说明) */
.section-title {
font-size: 12px;
color: var(--color-text-secondary);
margin-bottom: 12px;
}
/* 区域头部布局 */
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
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;
}
.batch-btn:hover {
box-shadow: var(--glow-primary);
filter: brightness(1.03);
}
/* 滑块行布局 */
.slider-row {
display: flex;
align-items: center;
}
/* 表单辅助说明与动作 */
.form-hint {
margin-top: 6px;
font-size: 12px;
color: var(--color-text-secondary);
}
.form-actions {
margin-top: 8px;
}
/* 输入框在暗色下的可见度增强 */
:deep(.ant-input), :deep(.ant-input-affix-wrapper), :deep(textarea) {
background: #0f0f0f;
border-color: var(--color-border);
}
:deep(.ant-input:hover), :deep(.ant-input-affix-wrapper:hover), :deep(textarea:hover) {
border-color: color-mix(in oklab, var(--color-primary) 60%, var(--color-border));
}
:deep(.ant-input:focus), :deep(.ant-input-affix-wrapper-focused), :deep(textarea:focus) {
border-color: var(--color-primary);
box-shadow: 0 0 0 2px color-mix(in oklab, var(--color-primary) 25%, transparent);
}
/* 滑块在暗色下的可见性增强(仅本页) */
:deep(.ant-slider) {
padding: 10px 0;
}
:deep(.ant-slider-rail) {
background-color: #252525;
height: 4px;
}
:deep(.ant-slider-track) {
background-color: var(--color-primary);
height: 4px;
}
:deep(.ant-slider:hover .ant-slider-track) {
background-color: var(--color-primary);
}
:deep(.ant-slider-handle::after) {
box-shadow: 0 0 0 2px var(--color-primary);
}
:deep(.ant-slider-handle:focus-visible::after),
:deep(.ant-slider-handle:hover::after),
:deep(.ant-slider-handle:active::after) {
box-shadow: 0 0 0 3px var(--color-primary);
}
/* 分段控件蓝色选中风格 */
.seg-blue :deep(.ant-segmented-item) {
color: #000;
}
.seg-blue :deep(.ant-segmented-item-selected) {
background-color: #1677ff;
color: #fff;
}
/* 展开行内容样式 */
.expanded-content {
padding: 16px;
background: #161616;
border-radius: 6px;
margin: 8px 0;
}
/* 左右两列布局 */
.two-col {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.col {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 6px;
padding: 12px;
}
.left-col .transcript-content {
white-space: pre-wrap;
line-height: 1.6;
color: var(--color-text-secondary);
background: #0d0d0d;
border: 1px dashed var(--color-border);
border-radius: 6px;
padding: 10px;
}
.sub-title {
font-size: 14px;
font-weight: 600;
color: var(--color-text);
margin-bottom: 8px;
}
.sub-title-2 {
font-size: 12px;
font-weight: 600;
color: var(--color-text-secondary);
margin: 12px 0 6px 0;
}
.no-transcript {
font-size: 12px;
color: var(--color-text-secondary);
}
.prompt-display-wrapper {
min-height: 200px;
max-height: 500px;
overflow-y: auto;
background: #0d0d0d;
border: 1px dashed var(--color-border);
border-radius: 6px;
padding: 12px;
margin-bottom: 8px;
}
.prompt-display {
color: var(--color-text);
line-height: 1.6;
}
.prompt-display :deep(h1) {
font-size: 18px;
font-weight: 600;
margin-bottom: 12px;
color: var(--color-text);
}
.prompt-display :deep(h2) {
font-size: 16px;
font-weight: 600;
margin: 16px 0 8px 0;
color: var(--color-text);
}
.prompt-display :deep(h3) {
font-size: 14px;
font-weight: 600;
margin: 12px 0 6px 0;
color: var(--color-text-secondary);
}
.prompt-display :deep(p) {
margin: 8px 0;
color: var(--color-text-secondary);
}
.prompt-display :deep(ul) {
margin: 8px 0;
padding-left: 20px;
}
.prompt-display :deep(ol) {
margin: 8px 0;
padding-left: 20px;
}
.prompt-display :deep(li) {
margin: 4px 0;
color: var(--color-text-secondary);
}
.prompt-display :deep(strong) {
font-weight: 600;
color: var(--color-text);
}
.prompt-display :deep(code) {
background: #1a1a1a;
padding: 2px 6px;
border-radius: 4px;
font-family: 'Courier New', monospace;
font-size: 12px;
color: #e11d48;
}
.prompt-display :deep(pre) {
background: #1a1a1a;
padding: 12px;
border-radius: 6px;
overflow-x: auto;
margin: 8px 0;
}
.prompt-display :deep(pre code) {
background: transparent;
padding: 0;
}
.prompt-display :deep(blockquote) {
border-left: 3px solid var(--color-primary);
padding-left: 12px;
margin: 8px 0;
color: var(--color-text-secondary);
}
.copy-btn {
display: inline-flex;
align-items: center;
gap: 4px;
color: var(--color-primary);
cursor: pointer;
}
.copy-btn:hover {
opacity: 0.8;
}
.copy-btn svg {
vertical-align: middle;
}
.edit-hint {
margin-left: 8px;
color: var(--color-text-secondary);
font-size: 12px;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 4px;
transition: all 0.2s;
}
.edit-hint:hover {
color: var(--color-primary);
background: rgba(22, 119, 255, 0.1);
}
.edit-hint svg {
vertical-align: middle;
}
.right-actions {
margin-top: 8px;
}
.preview-box {
margin-top: 12px;
border-top: 1px solid var(--color-border);
padding-top: 12px;
}
.no-prompt {
padding: 16px;
text-align: center;
color: var(--color-text-secondary);
}
.prompt-display {
margin-bottom: 16px;
line-height: 1.6;
}
.action-bar {
text-align: right;
border-top: 1px solid var(--color-border);
padding-top: 12px;
}
.expanded-content :deep(h1) {
font-size: 18px;
font-weight: 600;
margin-bottom: 12px;
color: var(--color-text);
}
.expanded-content :deep(h2) {
font-size: 16px;
font-weight: 600;
margin: 16px 0 8px 0;
color: var(--color-text);
}
.expanded-content :deep(h3) {
font-size: 14px;
font-weight: 600;
margin: 12px 0 6px 0;
color: var(--color-text-secondary);
}
.expanded-content :deep(p) {
margin: 8px 0;
color: var(--color-text-secondary);
}
.expanded-content :deep(ul) {
margin: 8px 0;
padding-left: 20px;
}
.expanded-content :deep(ol) {
margin: 8px 0;
padding-left: 20px;
}
.expanded-content :deep(li) {
margin: 4px 0;
color: var(--color-text-secondary);
}
.expanded-content :deep(strong) {
font-weight: 600;
color: var(--color-text);
}
.expanded-content :deep(code) {
background: #1a1a1a;
padding: 2px 6px;
border-radius: 4px;
font-family: 'Courier New', monospace;
font-size: 12px;
color: #e11d48;
}
/* Modal 中的 textarea 样式 */
:deep(.ant-modal-body .ant-input) {
font-family: monospace;
}
/* 链接按钮样式 */
.link-btn {
color: #1677ff;
text-decoration: none;
transition: opacity 0.2s;
}
.link-btn:hover {
opacity: 0.8;
text-decoration: underline;
}
/* 批量提示词弹窗样式 */
.batch-prompt-modal {
min-height: 200px;
}
.batch-prompt-display {
min-height: 300px;
max-height: 500px;
overflow-y: auto;
padding: 12px;
background: #0d0d0d;
border: 1px solid var(--color-border);
border-radius: 6px;
line-height: 1.6;
}
.batch-prompt-display :deep(h1) {
font-size: 18px;
font-weight: 600;
margin: 12px 0;
color: var(--color-text);
}
.batch-prompt-display :deep(h2) {
font-size: 16px;
font-weight: 600;
margin: 16px 0 8px 0;
color: var(--color-text);
}
.batch-prompt-display :deep(h3) {
font-size: 14px;
font-weight: 600;
margin: 12px 0 6px 0;
color: var(--color-text-secondary);
}
.batch-prompt-display :deep(p) {
margin: 8px 0;
color: var(--color-text-secondary);
}
.batch-prompt-display :deep(ul),
.batch-prompt-display :deep(ol) {
margin: 8px 0;
padding-left: 20px;
}
.batch-prompt-display :deep(li) {
margin: 4px 0;
color: var(--color-text-secondary);
}
.batch-prompt-display :deep(strong) {
font-weight: 600;
color: var(--color-text);
}
.batch-prompt-display :deep(code) {
background: #1a1a1a;
padding: 2px 6px;
border-radius: 4px;
font-family: 'Courier New', monospace;
font-size: 12px;
color: #e11d48;
}
.batch-prompt-display :deep(pre) {
background: #1a1a1a;
padding: 12px;
border-radius: 6px;
overflow-x: auto;
margin: 8px 0;
}
.batch-prompt-display :deep(pre code) {
background: transparent;
padding: 0;
}
.batch-prompt-display :deep(blockquote) {
border-left: 3px solid var(--color-primary);
padding-left: 12px;
margin: 8px 0;
color: var(--color-text-secondary);
}
.batch-prompt-actions {
margin-top: 12px;
display: flex;
justify-content: space-between;
align-items: center;
}
.batch-prompt-actions .ant-space {
width: 100%;
justify-content: space-between;
}
/* 全屏 Spin 遮罩样式 */
:deep(.batch-analyze-spin-wrapper) {
width: 100%;
min-height: calc(100vh - 120px);
}
: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>