Files
WebAI2API/webui/src/components/tools/request.vue
T
daidai a5dd734c79 fix: 修复媒体文件路径缺失目录前缀 & WebUI 请求历史改进 (ref #70)
- 修复媒体文件服务接口未拼接 mediaDir 导致图片无法显示的问题
- 历史模块路径改为绝对路径,与 config 模块保持一致
- Prompt 列支持点击弹窗预览完整内容
- 预览弹窗新增「复制全文」按钮
- 重发失败记录时自动删除旧的失败条目
2026-04-15 08:49:53 +08:00

1576 lines
47 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 { ref, computed, onMounted, onUnmounted, watch } from 'vue';
import { useSettingsStore } from '@/stores/settings';
import {
ReloadOutlined,
DeleteOutlined,
DownloadOutlined,
EyeOutlined,
CheckCircleOutlined,
CloseCircleOutlined,
ClockCircleOutlined,
PictureOutlined,
PlayCircleOutlined,
FileTextOutlined,
RocketOutlined,
RedoOutlined,
InboxOutlined,
LoadingOutlined,
CopyOutlined
} from '@ant-design/icons-vue';
import { message, Modal } from 'ant-design-vue';
const settingsStore = useSettingsStore();
// 数据状态
const loading = ref(false);
const records = ref([]);
const total = ref(0);
const page = ref(1);
const pageSize = ref(50);
// 筛选状态
const dateRange = ref([]);
const statusFilter = ref('all');
const modelFilter = ref('');
const searchText = ref('');
const modelOptions = ref([]);
// 多选状态
const selectedRowKeys = ref([]);
const selectedRows = ref([]);
// 统计摘要
const stats = ref({ total: 0, success: 0, failed: 0, avgDuration: 0 });
// 详情抽屉
const drawerVisible = ref(false);
const currentRecord = ref(null);
const detailLoading = ref(false);
// 快速预览弹窗
const previewModalVisible = ref(false);
const previewContent = ref('');
const previewMediaType = ref('text'); // text, image, video
const previewMediaUrl = ref('');
const previewTitle = ref('快速预览');
// 媒体数据缓存 (blob URLs)
const mediaCache = ref({});
// 发送请求相关
const sendModelList = ref([]);
const sendModel = ref('');
const sendPrompt = ref('');
const sendImageList = ref([]);
const sendStreamMode = ref(false);
const sendReasoningMode = ref(true);
const sending = ref(false);
// 当前模型是否支持图片输入
const currentModelSupportsImage = computed(() => {
if (!sendModel.value) return false;
const model = sendModelList.value.find(m => m.id === sendModel.value);
if (!model) return false;
return model.image_policy !== 'forbidden';
});
// 自动刷新
let autoRefreshInterval = null;
// 移动端检测
const isMobile = ref(window.innerWidth <= 768);
let resizeHandler = null;
// 状态配置
const statusConfig = {
success: { color: '#52c41a', text: '成功', icon: CheckCircleOutlined },
failed: { color: '#ff4d4f', text: '失败', icon: CloseCircleOutlined },
pending: { color: '#faad14', text: '处理中', icon: ClockCircleOutlined }
};
// 获取历史列表
const fetchHistory = async () => {
loading.value = true;
try {
const params = new URLSearchParams({
page: page.value,
pageSize: pageSize.value
});
if (statusFilter.value && statusFilter.value !== 'all') {
params.append('status', statusFilter.value);
}
if (modelFilter.value) {
params.append('model', modelFilter.value);
}
if (searchText.value) {
params.append('search', searchText.value);
}
if (dateRange.value && dateRange.value.length === 2) {
params.append('startDate', dateRange.value[0].format('YYYY-MM-DD'));
params.append('endDate', dateRange.value[1].format('YYYY-MM-DD'));
}
const res = await fetch(`/admin/history?${params.toString()}`, {
headers: settingsStore.getHeaders()
});
if (res.ok) {
const data = await res.json();
records.value = data.items || [];
total.value = data.total || 0;
// 预加载缩略图
preloadThumbnails();
}
} catch (e) {
message.error('获取历史记录失败');
} finally {
loading.value = false;
}
};
// 预加载列表中的缩略图
const preloadThumbnails = async () => {
for (const record of records.value) {
if (record.responseMedia && record.responseMedia.length > 0) {
const media = record.responseMedia[0];
if (media.localPath && media.status === 'downloaded') {
await getMediaBlobUrl(media);
}
}
}
};
// 获取统计摘要
const fetchStats = async () => {
try {
const params = new URLSearchParams();
if (dateRange.value && dateRange.value.length === 2) {
params.append('startDate', dateRange.value[0].format('YYYY-MM-DD'));
params.append('endDate', dateRange.value[1].format('YYYY-MM-DD'));
}
const res = await fetch(`/admin/history/stats?${params.toString()}`, {
headers: settingsStore.getHeaders()
});
if (res.ok) {
stats.value = await res.json();
}
} catch (e) {
console.error('获取统计失败', e);
}
};
// 获取模型列表
const fetchModels = async () => {
try {
const res = await fetch('/admin/history/models', {
headers: settingsStore.getHeaders()
});
if (res.ok) {
modelOptions.value = await res.json();
}
} catch (e) {
console.error('获取模型列表失败', e);
}
};
// 查看详情
const viewDetail = async (record) => {
drawerVisible.value = true;
detailLoading.value = true;
try {
const res = await fetch(`/admin/history/${record.id}`, {
headers: settingsStore.getHeaders()
});
if (res.ok) {
currentRecord.value = await res.json();
// 预加载详情中的媒体
if (currentRecord.value.responseMedia) {
for (const media of currentRecord.value.responseMedia) {
if (media.localPath && media.status === 'downloaded') {
await getMediaBlobUrl(media);
}
}
}
}
} catch (e) {
message.error('获取详情失败');
} finally {
detailLoading.value = false;
}
};
// 获取媒体 Blob URL(带认证)
const getMediaBlobUrl = async (media) => {
if (!media.localPath) return null;
const filename = media.localPath.split('/').pop();
const cacheKey = filename;
// 检查缓存
if (mediaCache.value[cacheKey]) {
return mediaCache.value[cacheKey];
}
try {
const res = await fetch(`/admin/history/media/${filename}`, {
headers: settingsStore.getHeaders()
});
if (res.ok) {
const blob = await res.blob();
const blobUrl = URL.createObjectURL(blob);
mediaCache.value[cacheKey] = blobUrl;
return blobUrl;
}
} catch (e) {
console.error('获取媒体失败', e);
}
return null;
};
// 获取缓存的 blob URL
const getCachedMediaUrl = (media) => {
if (!media || !media.localPath) return null;
const filename = media.localPath.split('/').pop();
return mediaCache.value[filename] || null;
};
// 重试下载媒体
const retryMedia = async (recordId, mediaIndex) => {
try {
const res = await fetch(`/admin/history/${recordId}/retry-media`, {
method: 'POST',
headers: {
...settingsStore.getHeaders(),
'Content-Type': 'application/json'
},
body: JSON.stringify({ mediaIndex })
});
if (res.ok) {
message.success('下载成功');
fetchHistory();
if (currentRecord.value && currentRecord.value.id === recordId) {
viewDetail(currentRecord.value);
}
} else {
const data = await res.json();
message.error(data.message || '下载失败');
}
} catch (e) {
message.error('请求失败');
}
};
// 删除记录
const deleteRecords = (ids) => {
Modal.confirm({
title: '确认删除',
content: `确定要删除这 ${ids.length} 条记录吗?关联的媒体文件也会被删除。`,
okText: '删除',
okType: 'danger',
cancelText: '取消',
async onOk() {
try {
const res = await fetch('/admin/history', {
method: 'DELETE',
headers: {
...settingsStore.getHeaders(),
'Content-Type': 'application/json'
},
body: JSON.stringify({ ids })
});
if (res.ok) {
message.success('删除成功');
clearSelection();
fetchHistory();
fetchStats();
} else {
message.error('删除失败');
}
} catch (e) {
message.error('请求失败');
}
}
});
};
// 按日期范围删除
const deleteByDateRange = () => {
if (!dateRange.value || dateRange.value.length !== 2) {
message.warning('请先选择日期范围');
return;
}
Modal.confirm({
title: '确认删除',
content: `确定要删除 ${dateRange.value[0].format('YYYY-MM-DD')}${dateRange.value[1].format('YYYY-MM-DD')} 的所有记录吗?`,
okText: '删除',
okType: 'danger',
cancelText: '取消',
async onOk() {
try {
const params = new URLSearchParams({
startDate: dateRange.value[0].format('YYYY-MM-DD'),
endDate: dateRange.value[1].format('YYYY-MM-DD')
});
const res = await fetch(`/admin/history?${params.toString()}`, {
method: 'DELETE',
headers: settingsStore.getHeaders()
});
if (res.ok) {
const data = await res.json();
message.success(`已删除 ${data.deleted} 条记录`);
clearSelection();
fetchHistory();
fetchStats();
} else {
message.error('删除失败');
}
} catch (e) {
message.error('请求失败');
}
}
});
};
// 格式化时间
const formatTime = (timestamp) => {
if (!timestamp) return '-';
const date = new Date(timestamp);
return date.toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
};
// 格式化耗时
const formatDuration = (ms) => {
if (!ms) return '-';
if (ms < 1000) return `${ms}ms`;
return `${(ms / 1000).toFixed(1)}s`;
};
// 截断文本
const truncateText = (text, maxLen = 120) => {
if (!text) return '-';
return text.length > maxLen ? text.substring(0, maxLen) + '...' : text;
};
// 判断响应是否有媒体内容
const hasMedia = (record) => {
return record.responseMedia && record.responseMedia.length > 0;
};
// 获取第一个媒体
const getFirstMedia = (record) => {
if (!hasMedia(record)) return null;
return record.responseMedia[0];
};
// 表格列定义
const columns = [
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 70,
align: 'center'
},
{
title: 'Prompt',
dataIndex: 'prompt',
key: 'prompt',
width: 200
},
{
title: '模型',
dataIndex: 'model_name',
key: 'model_name',
width: 150,
ellipsis: true
},
{
title: '响应',
key: 'response',
width: 220
},
{
title: '媒体',
key: 'media',
width: 180,
align: 'center'
},
{
title: '时间',
dataIndex: 'created_at',
key: 'created_at',
width: 100,
customRender: ({ value }) => formatTime(value)
},
{
title: '耗时',
dataIndex: 'duration_ms',
key: 'duration_ms',
width: 60,
align: 'right',
customRender: ({ value }) => formatDuration(value)
},
{
title: '',
key: 'action',
width: 100,
align: 'center',
fixed: 'right'
}
];
// 监听筛选变化
watch([statusFilter, modelFilter, dateRange], () => {
page.value = 1;
fetchHistory();
fetchStats();
});
// 切换模型时清空已选图片
watch(sendModel, () => {
sendImageList.value = [];
});
// 搜索防抖
let searchTimeout = null;
watch(searchText, () => {
if (searchTimeout) clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
page.value = 1;
fetchHistory();
}, 300);
});
// 分页变化
const handleTableChange = (pagination) => {
page.value = pagination.current;
pageSize.value = pagination.pageSize;
clearSelection();
fetchHistory();
};
// 刷新
const handleRefresh = () => {
fetchHistory();
fetchModels();
};
// 快速预览响应内容
const previewResponse = async (record) => {
previewModalVisible.value = true;
previewMediaType.value = 'text';
previewTitle.value = '响应预览';
if (record.status === 'failed') {
previewContent.value = record.error_message || '未知错误';
} else {
previewContent.value = record.response_text || '无响应';
}
};
// 快速预览 Prompt 内容
const previewPrompt = (record) => {
previewModalVisible.value = true;
previewMediaType.value = 'text';
previewTitle.value = 'Prompt 预览';
previewContent.value = record.prompt || '无内容';
};
// 复制预览内容到剪贴板
const copyPreviewContent = async () => {
try {
await navigator.clipboard.writeText(previewContent.value);
message.success('已复制到剪贴板');
} catch (e) {
message.error('复制失败');
}
};
// 快速预览媒体
const previewMedia = async (record) => {
const media = getFirstMedia(record);
if (!media) return;
if (media.type === 'image') {
previewMediaType.value = 'image';
} else if (media.type === 'video') {
previewMediaType.value = 'video';
} else {
previewMediaType.value = 'text';
previewContent.value = media.originalUrl || '无预览';
previewModalVisible.value = true;
return;
}
if (media.status === 'downloaded') {
const url = await getMediaBlobUrl(media);
if (url) {
previewMediaUrl.value = url;
previewModalVisible.value = true;
} else {
message.error('预览加载失败');
}
} else {
previewContent.value = '媒体未下载或下载失败,请查看详情并重试下载';
previewMediaType.value = 'text';
previewModalVisible.value = true;
}
};
// 关闭预览弹窗
const closePreview = () => {
previewModalVisible.value = false;
previewContent.value = '';
previewMediaUrl.value = '';
previewMediaType.value = 'text';
previewTitle.value = '快速预览';
};
// 多选变化
const onSelectChange = (keys, rows) => {
selectedRowKeys.value = keys;
selectedRows.value = rows;
};
// 批量删除选中
const deleteSelected = () => {
if (selectedRowKeys.value.length === 0) {
message.warning('请先选择要删除的记录');
return;
}
deleteRecords(selectedRowKeys.value);
};
// 清空选择
const clearSelection = () => {
selectedRowKeys.value = [];
selectedRows.value = [];
};
// === 发送请求功能 ===
// 获取可用模型列表
const fetchSendModelList = async () => {
try {
const res = await fetch('/v1/models', { headers: settingsStore.getHeaders() });
if (res.ok) {
const data = await res.json();
sendModelList.value = data.data || [];
if (sendModelList.value.length > 0 && !sendModel.value) {
sendModel.value = sendModelList.value[0].id;
}
}
} catch (e) {
console.error('获取模型列表失败', e);
}
};
// 图片转 base64
const fileToBase64 = (file) => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => resolve(reader.result);
reader.onerror = reject;
});
};
// 图片上传前检查
const beforeUpload = (file) => {
const allowedTypes = ['image/png', 'image/jpeg', 'image/gif', 'image/webp'];
if (!allowedTypes.includes(file.type)) {
message.error('仅支持 PNG, JPEG, GIF, WebP 格式');
return false;
}
if (sendImageList.value.length >= 10) {
message.error('最多上传 10 张图片');
return false;
}
return false;
};
// 处理图片选择
const handleSendImageChange = async (info) => {
const file = info.file;
if (file.status === 'removed') {
sendImageList.value = sendImageList.value.filter(f => f.uid !== file.uid);
return;
}
try {
const base64 = await fileToBase64(file.originFileObj || file);
sendImageList.value.push({ uid: file.uid, name: file.name, base64 });
} catch (e) {
message.error('图片读取失败');
}
};
// 发送请求(fire-and-forget,不阻塞 UI
const sendRequest = () => {
if (!sendModel.value) {
message.warning('请选择模型');
return;
}
if (!sendPrompt.value.trim()) {
message.warning('请输入提示词');
return;
}
let content;
if (sendImageList.value.length > 0) {
content = [{ type: 'text', text: sendPrompt.value }];
for (const img of sendImageList.value) {
content.push({ type: 'image_url', image_url: { url: img.base64 } });
}
} else {
content = sendPrompt.value;
}
const body = {
model: sendModel.value,
messages: [{ role: 'user', content }],
stream: sendStreamMode.value
};
if (sendReasoningMode.value) {
body.reasoning = true;
}
// 发射后不等待
fetch('/v1/chat/completions', {
method: 'POST',
headers: { ...settingsStore.getHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify(body)
}).catch(() => { /* 网络错误静默处理,列表会显示失败状态 */ });
message.success('请求已发送');
// 清空输入,允许立即发下一个
sendPrompt.value = '';
sendImageList.value = [];
// 启动自动刷新 + 1秒后立即刷一次以快速显示新记录
startAutoRefresh();
setTimeout(() => {
silentFetchHistory();
silentFetchStats();
}, 1000);
};
// 静默删除记录(不弹确认框)
const silentDeleteRecord = async (id) => {
try {
await fetch('/admin/history', {
method: 'DELETE',
headers: {
...settingsStore.getHeaders(),
'Content-Type': 'application/json'
},
body: JSON.stringify({ ids: [id] })
});
} catch (e) { /* 静默失败 */ }
};
// 从历史记录重发
const resendFromRecord = (record) => {
const modelId = record.model_id || record.model_name;
if (modelId) {
sendModel.value = modelId;
}
if (record.prompt) {
sendPrompt.value = record.prompt;
}
sendImageList.value = [];
// 如果原记录是失败状态(没有生成回复或图片),重发后删除旧记录
const shouldDelete = record.status === 'failed';
sendRequest();
if (shouldDelete) {
silentDeleteRecord(record.id);
}
};
// === 自动刷新 ===
const silentFetchHistory = async () => {
try {
const params = new URLSearchParams({ page: page.value, pageSize: pageSize.value });
if (statusFilter.value && statusFilter.value !== 'all') params.append('status', statusFilter.value);
if (modelFilter.value) params.append('model', modelFilter.value);
if (searchText.value) params.append('search', searchText.value);
if (dateRange.value && dateRange.value.length === 2) {
params.append('startDate', dateRange.value[0].format('YYYY-MM-DD'));
params.append('endDate', dateRange.value[1].format('YYYY-MM-DD'));
}
const res = await fetch(`/admin/history?${params.toString()}`, { headers: settingsStore.getHeaders() });
if (res.ok) {
const data = await res.json();
records.value = data.items || [];
total.value = data.total || 0;
preloadThumbnails();
}
} catch (e) { /* 静默失败 */ }
};
const silentFetchStats = async () => {
try {
const params = new URLSearchParams();
if (dateRange.value && dateRange.value.length === 2) {
params.append('startDate', dateRange.value[0].format('YYYY-MM-DD'));
params.append('endDate', dateRange.value[1].format('YYYY-MM-DD'));
}
const res = await fetch(`/admin/history/stats?${params.toString()}`, { headers: settingsStore.getHeaders() });
if (res.ok) { stats.value = await res.json(); }
} catch (e) { /* 静默失败 */ }
};
const startAutoRefresh = () => {
if (autoRefreshInterval) return;
autoRefreshInterval = setInterval(() => {
silentFetchHistory();
silentFetchStats();
}, 5000);
};
const stopAutoRefresh = () => {
if (autoRefreshInterval) {
clearInterval(autoRefreshInterval);
autoRefreshInterval = null;
}
};
onMounted(() => {
resizeHandler = () => { isMobile.value = window.innerWidth <= 768; };
window.addEventListener('resize', resizeHandler);
fetchHistory();
fetchStats();
fetchModels();
fetchSendModelList();
});
onUnmounted(() => {
stopAutoRefresh();
if (resizeHandler) window.removeEventListener('resize', resizeHandler);
});
</script>
<template>
<!-- 发送请求 -->
<a-card title="发送请求" :bordered="false" style="margin-bottom: 24px">
<div style="display: flex; gap: 16px; flex-wrap: wrap;">
<!-- 左侧模型 + 提示词 -->
<div style="flex: 1; min-width: 280px;">
<!-- 模型选择 -->
<div style="margin-bottom: 12px;">
<div style="font-size: 12px; color: #8c8c8c; margin-bottom: 4px;">模型</div>
<a-select v-model:value="sendModel" style="width: 100%" size="small" placeholder="选择模型" show-search>
<a-select-option v-for="model in sendModelList" :key="model.id" :value="model.id">
{{ model.id }}
</a-select-option>
</a-select>
</div>
<!-- 提示词 -->
<div style="margin-bottom: 12px;">
<div style="font-size: 12px; color: #8c8c8c; margin-bottom: 4px;">提示词</div>
<a-textarea v-model:value="sendPrompt" placeholder="输入提示词" :rows="3" size="small" />
</div>
<!-- 选项 + 发送按钮 -->
<div style="display: flex; align-items: center; gap: 16px; flex-wrap: wrap;">
<a-checkbox v-model:checked="sendStreamMode">流式响应</a-checkbox>
<a-checkbox v-model:checked="sendReasoningMode">返回思考</a-checkbox>
<a-button type="primary" @click="sendRequest" :disabled="!sendModel">
<template #icon><RocketOutlined /></template>
发送
</a-button>
</div>
</div>
<!-- 右侧图片上传仅支持图片的模型显示 -->
<div v-if="currentModelSupportsImage" class="send-upload-area">
<div style="font-size: 12px; color: #8c8c8c; margin-bottom: 4px;">
附加图片 ({{ sendImageList.length }}/10)
</div>
<a-upload-dragger :file-list="[]" :multiple="true" :before-upload="beforeUpload"
@change="handleSendImageChange" accept=".png,.jpg,.jpeg,.gif,.webp" :show-upload-list="false">
<p style="margin: 0;">
<InboxOutlined style="font-size: 20px; color: #1890ff;" />
</p>
<p style="font-size: 12px; margin: 2px 0 0 0; color: #8c8c8c;">
点击或拖拽上传图片
</p>
</a-upload-dragger>
<div v-if="sendImageList.length > 0" style="margin-top: 8px; display: flex; flex-wrap: wrap; gap: 4px;">
<a-tag v-for="img in sendImageList" :key="img.uid" closable
@close="sendImageList = sendImageList.filter(i => i.uid !== img.uid)">
<PictureOutlined /> {{ img.name.slice(0, 15) }}{{ img.name.length > 15 ? '...' : '' }}
</a-tag>
</div>
</div>
</div>
</a-card>
<!-- 统计摘要 -->
<a-card title="请求记录" :bordered="false">
<template #extra>
<a-button type="link" danger size="small" @click="deleteByDateRange"
:disabled="!dateRange || dateRange.length !== 2">
<template #icon>
<DeleteOutlined />
</template>
删除所选范围
</a-button>
</template>
<div class="stats-content">
<a-range-picker v-model:value="dateRange" :format="'YYYY-MM-DD'" :placeholder="['开始日期', '结束日期']"
size="small" class="stats-date-picker" />
<a-divider type="vertical" style="height: 32px; margin: 0 16px" />
<div class="stats-numbers">
<div class="stat-item neutral">
<FileTextOutlined />
<span class="stat-value">{{ stats.total }}</span>
<span class="stat-label">总数</span>
</div>
<div class="stat-item success">
<CheckCircleOutlined />
<span class="stat-value">{{ stats.success }}</span>
<span class="stat-label">成功</span>
</div>
<div class="stat-item error">
<CloseCircleOutlined />
<span class="stat-value">{{ stats.failed }}</span>
<span class="stat-label">失败</span>
</div>
<div class="stat-item neutral">
<ClockCircleOutlined />
<span class="stat-value">{{ formatDuration(stats.avgDuration) }}</span>
<span class="stat-label">平均耗时</span>
</div>
</div>
</div>
</a-card>
<!-- 历史记录表格 -->
<a-card :bordered="false" style="margin-top: 24px">
<!-- 筛选工具栏 -->
<div class="toolbar">
<div class="toolbar-row">
<a-select v-model:value="statusFilter" class="toolbar-status-select" size="small" placeholder="状态">
<a-select-option value="all">全部状态</a-select-option>
<a-select-option value="success">成功</a-select-option>
<a-select-option value="failed">失败</a-select-option>
<a-select-option value="pending">处理中</a-select-option>
</a-select>
<a-select v-model:value="modelFilter" class="toolbar-model-select" size="small" placeholder="全部模型"
allow-clear show-search>
<a-select-option v-for="model in modelOptions" :key="model" :value="model">
{{ model }}
</a-select-option>
</a-select>
<a-button size="small" @click="handleRefresh">
<template #icon>
<ReloadOutlined />
</template>
</a-button>
<a-button v-if="selectedRowKeys.length > 0" type="primary" danger size="small" @click="deleteSelected">
<template #icon>
<DeleteOutlined />
</template>
删除选中 ({{ selectedRowKeys.length }})
</a-button>
</div>
<div class="toolbar-row">
<a-input-search v-model:value="searchText" placeholder="搜索 Prompt 或响应内容" size="small"
allow-clear style="width: 100%;" />
</div>
</div>
<!-- 表格 -->
<a-table
:columns="columns"
:data-source="records"
:loading="loading"
:row-selection="{
selectedRowKeys: selectedRowKeys,
onChange: onSelectChange,
columnWidth: 40
}"
:pagination="{
current: page,
pageSize: pageSize,
total: total,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total) => `共 ${total} 条`,
pageSizeOptions: ['20', '50', '100', '200']
}"
row-key="id"
size="small"
:scroll="{ x: 1000 }"
@change="handleTableChange"
>
<template #bodyCell="{ column, record }">
<!-- Prompt 支持多行点击弹出预览 -->
<template v-if="column.key === 'prompt'">
<div class="multiline-text clickable" @click="previewPrompt(record)" title="点击查看完整内容">
{{ truncateText(record.prompt, 120) }}
</div>
</template>
<!-- 响应列 -->
<template v-else-if="column.key === 'response'">
<div v-if="record.status === 'failed'" class="multiline-text error-text clickable"
@click="previewResponse(record)" title="点击查看完整内容">
{{ truncateText(record.error_message, 120) || '错误' }}
</div>
<div v-else class="multiline-text response-text clickable"
@click="previewResponse(record)" title="点击查看完整内容">
{{ truncateText(record.response_text, 120) || '-' }}
</div>
</template>
<!-- 媒体列显示缩略图 -->
<template v-else-if="column.key === 'media'">
<div v-if="hasMedia(record)" class="media-thumb-cell" @click="previewMedia(record)" title="点击查看大图">
<template v-if="getFirstMedia(record).status === 'downloaded'">
<img
v-if="getFirstMedia(record).type === 'image'"
:src="getCachedMediaUrl(getFirstMedia(record))"
class="thumb-img"
loading="lazy"
/>
<div v-else-if="getFirstMedia(record).type === 'video'" class="thumb-video">
<PlayCircleOutlined />
</div>
</template>
<div v-else class="thumb-placeholder">
<PictureOutlined v-if="getFirstMedia(record).type === 'image'" />
<PlayCircleOutlined v-else />
</div>
<span v-if="record.responseMedia.length > 1" class="media-count">
+{{ record.responseMedia.length - 1 }}
</span>
</div>
<span v-else class="no-media">-</span>
</template>
<!-- 状态列 -->
<template v-else-if="column.key === 'status'">
<a-tag :color="statusConfig[record.status]?.color || '#8c8c8c'" size="small">
{{ statusConfig[record.status]?.text || record.status }}
</a-tag>
</template>
<!-- 操作列 -->
<template v-else-if="column.key === 'action'">
<a-space :size="0">
<a-tooltip title="重发">
<a-button type="link" size="small" @click="resendFromRecord(record)">
<template #icon>
<RedoOutlined />
</template>
</a-button>
</a-tooltip>
<a-tooltip title="详情">
<a-button type="link" size="small" @click="viewDetail(record)">
<template #icon>
<EyeOutlined />
</template>
</a-button>
</a-tooltip>
<a-tooltip title="删除">
<a-button type="link" size="small" danger @click="deleteRecords([record.id])">
<template #icon>
<DeleteOutlined />
</template>
</a-button>
</a-tooltip>
</a-space>
</template>
</template>
</a-table>
</a-card>
<!-- 详情抽屉 -->
<a-drawer v-model:open="drawerVisible" title="请求详情" placement="right" :width="isMobile ? '100%' : 700" :destroy-on-close="true">
<a-spin :spinning="detailLoading">
<template v-if="currentRecord">
<!-- 基本信息 -->
<a-descriptions :column="isMobile ? 1 : 2" size="small" bordered>
<a-descriptions-item label="请求 ID" :span="2">
<code>{{ currentRecord.id }}</code>
</a-descriptions-item>
<a-descriptions-item label="时间">
{{ new Date(currentRecord.created_at).toLocaleString('zh-CN') }}
</a-descriptions-item>
<a-descriptions-item label="状态">
<a-tag :color="statusConfig[currentRecord.status]?.color">
{{ statusConfig[currentRecord.status]?.text || currentRecord.status }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="模型" :span="2">
{{ currentRecord.model_name || currentRecord.model_id || '-' }}
</a-descriptions-item>
<a-descriptions-item label="耗时">
{{ formatDuration(currentRecord.duration_ms) }}
</a-descriptions-item>
<a-descriptions-item label="流式">
{{ currentRecord.isStreaming ? '是' : '否' }}
</a-descriptions-item>
</a-descriptions>
<!-- Prompt -->
<a-divider orientation="left">Prompt</a-divider>
<div class="content-box">
{{ currentRecord.prompt || '无' }}
</div>
<!-- 输入图片 -->
<template v-if="currentRecord.inputImages && currentRecord.inputImages.length > 0">
<a-divider orientation="left">输入图片</a-divider>
<div class="media-list">
<span v-for="(img, idx) in currentRecord.inputImages" :key="idx" class="media-item">
<a-tag>{{ img.split('/').pop() }}</a-tag>
</span>
</div>
</template>
<!-- 响应内容 -->
<a-divider orientation="left">响应内容</a-divider>
<div class="content-box" :class="{ 'error-box': currentRecord.status === 'failed' }">
<template v-if="currentRecord.status === 'failed'">
{{ currentRecord.error_message || '未知错误' }}
</template>
<template v-else>
{{ currentRecord.response_text || '无响应' }}
</template>
</div>
<!-- 思考过程 -->
<template v-if="currentRecord.reasoning_content">
<a-divider orientation="left">思考过程</a-divider>
<div class="content-box reasoning-box">
{{ currentRecord.reasoning_content }}
</div>
</template>
<!-- 媒体内容 -->
<template v-if="currentRecord.responseMedia && currentRecord.responseMedia.length > 0">
<a-divider orientation="left">媒体内容 ({{ currentRecord.responseMedia.length }})</a-divider>
<div class="media-gallery-large">
<div v-for="(media, idx) in currentRecord.responseMedia" :key="idx" class="media-card-large">
<div class="media-preview-large">
<template v-if="media.status === 'downloaded' && getCachedMediaUrl(media)">
<img v-if="media.type === 'image'" :src="getCachedMediaUrl(media)" alt="生成图片" />
<video v-else-if="media.type === 'video'" :src="getCachedMediaUrl(media)" controls />
</template>
<template v-else>
<div class="media-placeholder-large">
<PictureOutlined v-if="media.type === 'image'" />
<PlayCircleOutlined v-else-if="media.type === 'video'" />
<FileTextOutlined v-else />
<div class="media-status">
<a-tag v-if="media.status === 'failed'" color="red">下载失败</a-tag>
<a-tag v-else-if="media.status === 'external'" color="blue">外部链接</a-tag>
<a-tag v-else-if="media.status === 'pending'" color="orange">待下载</a-tag>
</div>
<a-button v-if="media.status === 'failed'" type="primary" size="small"
@click="retryMedia(currentRecord.id, idx)">
<template #icon>
<ReloadOutlined />
</template>
重试下载
</a-button>
</div>
</template>
</div>
</div>
</div>
</template>
</template>
</a-spin>
</a-drawer>
<!-- 快速预览弹窗 -->
<a-modal
v-model:open="previewModalVisible"
:footer="null"
:width="isMobile ? '95%' : (previewMediaType === 'image' || previewMediaType === 'video' ? '90%' : '70%')"
centered
@cancel="closePreview"
>
<template #title>
<div style="display: flex; align-items: center; gap: 8px;">
<span>{{ previewTitle }}</span>
<a-button v-if="previewMediaType === 'text'" type="text" size="small" @click="copyPreviewContent">
<template #icon><CopyOutlined /></template>
复制全文
</a-button>
</div>
</template>
<div v-if="previewMediaType === 'text'" class="preview-text-content">
{{ previewContent }}
</div>
<div v-else-if="previewMediaType === 'image'" class="preview-image-content">
<img :src="previewMediaUrl" alt="预览图片" />
</div>
<div v-else-if="previewMediaType === 'video'" class="preview-video-content">
<video :src="previewMediaUrl" controls autoplay />
</div>
</a-modal>
</template>
<style scoped>
/* 图片上传区域高度控制 */
.send-upload-area :deep(.ant-upload-drag) {
height: calc(100% - 20px);
}
/* 统计内容样式 */
.stats-content {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.stats-numbers {
display: flex;
align-items: center;
gap: 20px;
}
.stat-item {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 12px;
background: #fafafa;
border-radius: 6px;
transition: all 0.2s;
}
.stat-item:hover {
background: #f0f0f0;
}
.stat-item.success {
color: #52c41a;
}
.stat-item.error {
color: #ff4d4f;
}
.stat-item.neutral {
color: #8c8c8c;
}
.stat-value {
font-size: 18px;
font-weight: 600;
font-family: 'SF Mono', 'Monaco', monospace;
}
.stat-label {
font-size: 12px;
color: #8c8c8c;
}
/* 工具栏样式 */
.toolbar {
margin-bottom: 16px;
}
.toolbar-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.toolbar-row:last-child {
margin-bottom: 0;
}
/* 工具栏 select 默认宽度 */
.toolbar-status-select {
width: 100px;
}
.toolbar-model-select {
width: 200px;
}
@media (min-width: 768px) {
.toolbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
}
.toolbar-row {
margin-bottom: 0;
}
.toolbar-row:last-child {
flex: 1;
max-width: 300px;
}
}
/* 表格内样式 */
.error-text {
color: #ff4d4f;
font-size: 12px;
}
.response-text {
font-size: 12px;
color: #595959;
}
/* 多行文本 */
.multiline-text {
font-size: 12px;
line-height: 1.5;
max-height: 54px; /* 约 3 行 */
overflow: hidden;
word-break: break-all;
}
.multiline-text.clickable {
cursor: pointer;
padding: 4px;
margin: -4px;
border-radius: 4px;
transition: background 0.2s;
}
.multiline-text.clickable:hover {
background: #f0f0f0;
}
.no-media {
color: #bfbfbf;
}
/* 表格行高度适配大缩略图 */
:deep(.ant-table-tbody > tr > td) {
vertical-align: middle;
}
/* 列表缩略图 - 160x160 */
.media-thumb-cell {
position: relative;
width: 160px;
height: 160px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
margin: 8px auto;
}
.thumb-img {
width: 160px;
height: 160px;
object-fit: cover;
border-radius: 4px;
border: 1px solid #f0f0f0;
}
.thumb-video {
width: 160px;
height: 160px;
background: #000;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 28px;
}
.thumb-placeholder {
width: 160px;
height: 160px;
background: #fafafa;
border: 1px dashed #d9d9d9;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
color: #bfbfbf;
font-size: 24px;
}
.media-count {
position: absolute;
bottom: 4px;
right: 4px;
background: rgba(0, 0, 0, 0.6);
color: #fff;
font-size: 11px;
padding: 2px 6px;
border-radius: 3px;
}
/* 内容框样式 */
.content-box {
background: #fafafa;
border: 1px solid #f0f0f0;
border-radius: 4px;
padding: 12px;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 13px;
white-space: pre-wrap;
word-break: break-all;
max-height: 600px;
overflow-y: auto;
}
.content-box.error-box {
color: #ff4d4f;
background: #fff2f0;
border-color: #ffccc7;
}
.content-box.reasoning-box {
background: #f6ffed;
border-color: #b7eb8f;
color: #389e0d;
}
/* 详情页媒体样式 - 更大尺寸 */
.media-gallery-large {
display: flex;
flex-direction: column;
gap: 16px;
}
.media-card-large {
border: 1px solid #f0f0f0;
border-radius: 8px;
overflow: hidden;
background: #fafafa;
}
.media-preview-large {
width: 100%;
min-height: 300px;
max-height: 500px;
display: flex;
align-items: center;
justify-content: center;
background: #f5f5f5;
}
.media-preview-large img {
max-width: 100%;
max-height: 500px;
object-fit: contain;
}
.media-preview-large video {
max-width: 100%;
max-height: 500px;
}
.media-placeholder-large {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #bfbfbf;
gap: 12px;
padding: 40px;
font-size: 48px;
}
.media-status {
font-size: 14px;
}
/* 媒体列表 */
.media-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
/* 预览弹窗内容 */
.preview-text-content {
background: #fafafa;
border: 1px solid #f0f0f0;
border-radius: 4px;
padding: 16px;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 14px;
white-space: pre-wrap;
word-break: break-all;
max-height: 60vh;
overflow-y: auto;
line-height: 1.6;
}
.preview-image-content {
display: flex;
justify-content: center;
align-items: center;
min-height: 200px;
}
.preview-image-content img {
max-width: 100%;
max-height: 70vh;
object-fit: contain;
border-radius: 4px;
}
.preview-video-content {
display: flex;
justify-content: center;
align-items: center;
}
.preview-video-content video {
max-width: 100%;
max-height: 70vh;
border-radius: 4px;
}
/* 图片上传区域尺寸 */
.send-upload-area {
flex: 0 0 280px;
min-width: 200px;
}
/* 日期选择器 */
.stats-date-picker {
width: 240px;
}
/* 响应式 - 平板及以下 */
@media (max-width: 768px) {
.send-upload-area {
flex: 1 1 100% !important;
min-width: 0 !important;
}
.stats-date-picker {
width: 100%;
}
.media-thumb-cell {
width: 80px;
height: 80px;
}
.thumb-img {
width: 80px;
height: 80px;
}
.thumb-video {
width: 80px;
height: 80px;
font-size: 20px;
}
.thumb-placeholder {
width: 80px;
height: 80px;
font-size: 18px;
}
.toolbar {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.toolbar-row {
flex-wrap: nowrap;
margin-bottom: 0;
gap: 4px;
}
.toolbar-row:last-child {
flex: 1;
min-width: 100px;
}
.toolbar-status-select {
width: 80px !important;
}
.toolbar-model-select {
width: 100px !important;
}
.stat-value {
font-size: 14px;
}
.stat-item {
padding: 2px 8px;
}
.content-box {
max-height: 400px;
font-size: 12px;
padding: 8px;
}
.media-preview-large {
min-height: 200px;
max-height: 350px;
}
}
/* 响应式 - 手机 */
@media (max-width: 576px) {
.stats-content {
flex-direction: column;
align-items: flex-start;
}
.stats-content .ant-divider {
display: none;
}
.stats-numbers {
margin-top: 8px;
flex-wrap: wrap;
gap: 8px;
}
.stat-item {
padding: 2px 6px;
gap: 4px;
}
.stat-value {
font-size: 13px;
}
.stat-label {
font-size: 11px;
}
}
</style>