mirror of
https://github.com/foxhui/WebAI2API.git
synced 2026-06-16 21:03:59 +08:00
a5dd734c79
- 修复媒体文件服务接口未拼接 mediaDir 导致图片无法显示的问题 - 历史模块路径改为绝对路径,与 config 模块保持一致 - Prompt 列支持点击弹窗预览完整内容 - 预览弹窗新增「复制全文」按钮 - 重发失败记录时自动删除旧的失败条目
1576 lines
47 KiB
Vue
1576 lines
47 KiB
Vue
<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>
|