569 lines
26 KiB
HTML
569 lines
26 KiB
HTML
|
|
<!DOCTYPE html>
|
|||
|
|
<html lang="zh-CN">
|
|||
|
|
<head>
|
|||
|
|
<meta charset="UTF-8">
|
|||
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|||
|
|
<title>Seqlog Dashboard</title>
|
|||
|
|
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
|
|||
|
|
<script src="https://cdn.tailwindcss.com"></script>
|
|||
|
|
<style>
|
|||
|
|
[v-cloak] { display: none; }
|
|||
|
|
.log-entry {
|
|||
|
|
font-family: 'Monaco', 'Menlo', 'Consolas', monospace;
|
|||
|
|
font-size: 13px;
|
|||
|
|
}
|
|||
|
|
.log-data {
|
|||
|
|
display: -webkit-box;
|
|||
|
|
-webkit-line-clamp: 3;
|
|||
|
|
-webkit-box-orient: vertical;
|
|||
|
|
overflow: hidden;
|
|||
|
|
cursor: pointer;
|
|||
|
|
}
|
|||
|
|
.log-data:hover {
|
|||
|
|
background-color: rgba(0, 0, 0, 0.02);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* Dialog 样式 */
|
|||
|
|
dialog {
|
|||
|
|
border: none;
|
|||
|
|
border-radius: 0.5rem;
|
|||
|
|
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
|||
|
|
padding: 0;
|
|||
|
|
max-width: 56rem;
|
|||
|
|
width: 90vw;
|
|||
|
|
max-height: 80vh;
|
|||
|
|
overflow: hidden;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
dialog .dialog-container {
|
|||
|
|
max-height: 80vh;
|
|||
|
|
display: flex;
|
|||
|
|
flex-direction: column;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
dialog .dialog-content {
|
|||
|
|
overflow-y: auto;
|
|||
|
|
flex: 1;
|
|||
|
|
min-height: 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
dialog:not([open]) {
|
|||
|
|
display: none !important;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
dialog[open] {
|
|||
|
|
display: block !important;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
dialog::backdrop {
|
|||
|
|
background-color: rgba(0, 0, 0, 0.5);
|
|||
|
|
backdrop-filter: blur(2px);
|
|||
|
|
}
|
|||
|
|
</style>
|
|||
|
|
</head>
|
|||
|
|
<body class="bg-gray-50">
|
|||
|
|
<div id="app" v-cloak>
|
|||
|
|
<!-- 头部 -->
|
|||
|
|
<header class="bg-white shadow-sm border-b">
|
|||
|
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
|||
|
|
<div class="flex items-center justify-between">
|
|||
|
|
<div class="flex items-center space-x-3">
|
|||
|
|
<svg class="w-8 h-8 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|||
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
|
|||
|
|
</svg>
|
|||
|
|
<h1 class="text-2xl font-bold text-gray-900">Seqlog Dashboard</h1>
|
|||
|
|
</div>
|
|||
|
|
<div class="flex items-center space-x-4">
|
|||
|
|
<span class="text-sm text-gray-500">实时日志查询系统</span>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</header>
|
|||
|
|
|
|||
|
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
|||
|
|
<div class="grid grid-cols-12 gap-6">
|
|||
|
|
<!-- 侧边栏 -->
|
|||
|
|
<div class="col-span-12 lg:col-span-3">
|
|||
|
|
<div class="bg-white rounded-lg shadow p-4">
|
|||
|
|
<h2 class="text-lg font-semibold text-gray-900 mb-4">Topics</h2>
|
|||
|
|
|
|||
|
|
<div v-if="topics.length === 0" class="text-gray-500 text-sm text-center py-4">
|
|||
|
|
暂无 Topic
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="space-y-2">
|
|||
|
|
<button
|
|||
|
|
v-for="topicInfo in topics"
|
|||
|
|
:key="topicInfo.topic"
|
|||
|
|
@click="selectTopic(topicInfo.topic)"
|
|||
|
|
:class="[
|
|||
|
|
'w-full text-left px-4 py-2 rounded-md transition-colors',
|
|||
|
|
selectedTopic === topicInfo.topic
|
|||
|
|
? 'bg-blue-600 text-white'
|
|||
|
|
: 'bg-gray-50 text-gray-700 hover:bg-gray-100'
|
|||
|
|
]">
|
|||
|
|
{{ topicInfo.title }}
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- 统计信息 -->
|
|||
|
|
<div v-if="stats" class="bg-white rounded-lg shadow p-4 mt-4">
|
|||
|
|
<h3 class="text-sm font-semibold text-gray-900 mb-3">统计信息</h3>
|
|||
|
|
<div class="space-y-2 text-sm">
|
|||
|
|
<div class="flex justify-between">
|
|||
|
|
<span class="text-gray-600">写入次数:</span>
|
|||
|
|
<span class="font-medium">{{ stats.write_count }}</span>
|
|||
|
|
</div>
|
|||
|
|
<div class="flex justify-between">
|
|||
|
|
<span class="text-gray-600">写入大小:</span>
|
|||
|
|
<span class="font-medium">{{ formatBytes(stats.write_bytes) }}</span>
|
|||
|
|
</div>
|
|||
|
|
<div class="flex justify-between">
|
|||
|
|
<span class="text-gray-600">已处理:</span>
|
|||
|
|
<span class="font-medium">{{ stats.processed_count }}</span>
|
|||
|
|
</div>
|
|||
|
|
<div class="flex justify-between">
|
|||
|
|
<span class="text-gray-600">处理大小:</span>
|
|||
|
|
<span class="font-medium">{{ formatBytes(stats.processed_bytes) }}</span>
|
|||
|
|
</div>
|
|||
|
|
<div class="flex justify-between">
|
|||
|
|
<span class="text-gray-600">错误次数:</span>
|
|||
|
|
<span class="font-medium text-red-600">{{ stats.error_count }}</span>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- 主内容区 -->
|
|||
|
|
<div class="col-span-12 lg:col-span-9">
|
|||
|
|
<div class="bg-white rounded-lg shadow">
|
|||
|
|
<!-- 控制栏 -->
|
|||
|
|
<div class="border-b px-4 py-3 flex items-center justify-between">
|
|||
|
|
<div class="flex items-center space-x-3">
|
|||
|
|
<button
|
|||
|
|
@click="loadLogs"
|
|||
|
|
class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors text-sm font-medium">
|
|||
|
|
刷新
|
|||
|
|
</button>
|
|||
|
|
<button
|
|||
|
|
@click="queryFirst"
|
|||
|
|
class="px-4 py-2 bg-gray-100 text-gray-700 rounded-md hover:bg-gray-200 transition-colors text-sm">
|
|||
|
|
最早
|
|||
|
|
</button>
|
|||
|
|
<button
|
|||
|
|
@click="queryLast"
|
|||
|
|
class="px-4 py-2 bg-gray-100 text-gray-700 rounded-md hover:bg-gray-200 transition-colors text-sm">
|
|||
|
|
最新
|
|||
|
|
</button>
|
|||
|
|
|
|||
|
|
<div class="flex items-center space-x-2">
|
|||
|
|
<label class="text-sm text-gray-600">显示:</label>
|
|||
|
|
<input
|
|||
|
|
v-model.number="queryCount"
|
|||
|
|
type="number"
|
|||
|
|
min="1"
|
|||
|
|
max="100"
|
|||
|
|
class="w-20 px-2 py-1 border rounded text-sm">
|
|||
|
|
<span class="text-sm text-gray-600">条</span>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div v-if="selectedTopic" class="text-sm text-gray-500">
|
|||
|
|
Topic: <span class="font-medium text-gray-900">{{ selectedTopic }}</span>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- 日志列表 -->
|
|||
|
|
<div class="p-4">
|
|||
|
|
<div v-if="!selectedTopic" class="text-center py-12 text-gray-500">
|
|||
|
|
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|||
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
|
|||
|
|
</svg>
|
|||
|
|
<p class="mt-2">请选择一个 Topic 开始查看日志</p>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div v-else-if="loading" class="text-center py-12">
|
|||
|
|
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
|||
|
|
<p class="mt-2 text-gray-500">加载中...</p>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div v-else-if="logs.length === 0" class="text-center py-12 text-gray-500">
|
|||
|
|
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|||
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"></path>
|
|||
|
|
</svg>
|
|||
|
|
<p class="mt-2">暂无日志记录</p>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div v-else class="space-y-2 max-h-[600px] overflow-y-auto" ref="logContainer" @scroll="handleScroll">
|
|||
|
|
<div
|
|||
|
|
v-for="log in logs"
|
|||
|
|
:key="log.index"
|
|||
|
|
:class="[
|
|||
|
|
'log-entry p-3 rounded-md border-l-4 transition-colors',
|
|||
|
|
getLogClass(log.status)
|
|||
|
|
]">
|
|||
|
|
<div class="flex items-start justify-between mb-1">
|
|||
|
|
<div class="flex items-center space-x-2">
|
|||
|
|
<span class="text-xs font-mono text-gray-500">#{{ log.index }}</span>
|
|||
|
|
<span :class="['px-2 py-0.5 rounded-full text-xs font-medium', getStatusClass(log.status)]">
|
|||
|
|
{{ getStatusText(log.status) }}
|
|||
|
|
</span>
|
|||
|
|
<span class="text-xs text-gray-500">{{ formatBytes(log.dataSize || (log.data ? log.data.length : 0)) }}</span>
|
|||
|
|
</div>
|
|||
|
|
<span class="text-xs text-gray-400 font-mono">{{ formatUUID(log.uuid) }}</span>
|
|||
|
|
</div>
|
|||
|
|
<div class="log-data text-sm text-gray-800 break-all whitespace-pre-wrap" @click="showLogDetail(log)">
|
|||
|
|
{{ log.dataPreview || log.data }}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- 日志详情对话框 -->
|
|||
|
|
<dialog ref="logDialog">
|
|||
|
|
<div class="dialog-container">
|
|||
|
|
<template v-if="selectedLog">
|
|||
|
|
<!-- 对话框头部 -->
|
|||
|
|
<div class="flex items-center justify-between p-4 border-b flex-shrink-0">
|
|||
|
|
<div class="flex items-center space-x-3">
|
|||
|
|
<h3 class="text-lg font-semibold text-gray-900">日志详情</h3>
|
|||
|
|
<span class="text-sm text-gray-500">#{{ selectedLog.index }}</span>
|
|||
|
|
</div>
|
|||
|
|
<button @click="closeModal" class="text-gray-400 hover:text-gray-600">
|
|||
|
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|||
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
|||
|
|
</svg>
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- 对话框内容 -->
|
|||
|
|
<div class="dialog-content p-4">
|
|||
|
|
<div class="space-y-3">
|
|||
|
|
<div class="flex items-center space-x-4 text-sm">
|
|||
|
|
<div class="flex items-center space-x-2">
|
|||
|
|
<span class="text-gray-600">UUID:</span>
|
|||
|
|
<span class="font-mono text-gray-900">{{ selectedLog.uuid }}</span>
|
|||
|
|
</div>
|
|||
|
|
<div class="flex items-center space-x-2">
|
|||
|
|
<span class="text-gray-600">状态:</span>
|
|||
|
|
<span :class="['px-2 py-0.5 rounded-full text-xs font-medium', getStatusClass(selectedLog.status)]">
|
|||
|
|
{{ getStatusText(selectedLog.status) }}
|
|||
|
|
</span>
|
|||
|
|
</div>
|
|||
|
|
<div class="flex items-center space-x-2">
|
|||
|
|
<span class="text-gray-600">大小:</span>
|
|||
|
|
<span class="font-medium text-gray-900">{{ formatBytes(selectedLog.dataSize || (selectedLog.data ? selectedLog.data.length : 0)) }}</span>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div>
|
|||
|
|
<label class="block text-sm font-medium text-gray-700 mb-2">日志内容</label>
|
|||
|
|
<pre class="bg-gray-50 p-4 rounded-md text-sm font-mono text-gray-800 whitespace-pre-wrap break-all border border-gray-200">{{ selectedLog.data }}</pre>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- 对话框底部 -->
|
|||
|
|
<div class="p-4 border-t flex justify-end flex-shrink-0">
|
|||
|
|
<button @click="closeModal" class="px-4 py-2 bg-gray-100 text-gray-700 rounded-md hover:bg-gray-200 transition-colors">
|
|||
|
|
关闭
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</template>
|
|||
|
|
</div>
|
|||
|
|
</dialog>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<script>
|
|||
|
|
const { createApp } = Vue;
|
|||
|
|
|
|||
|
|
createApp({
|
|||
|
|
data() {
|
|||
|
|
return {
|
|||
|
|
topics: [],
|
|||
|
|
selectedTopic: null,
|
|||
|
|
logs: [],
|
|||
|
|
stats: null,
|
|||
|
|
loading: false,
|
|||
|
|
queryCount: 20,
|
|||
|
|
selectedLog: null,
|
|||
|
|
loadingOlder: false, // 正在加载更早的记录
|
|||
|
|
loadingNewer: false, // 正在加载更新的记录
|
|||
|
|
scrollThreshold: 50, // 触发加载的阈值(像素)
|
|||
|
|
loadingDetail: false // 正在加载详情
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
methods: {
|
|||
|
|
async loadTopics() {
|
|||
|
|
try {
|
|||
|
|
const response = await fetch('/api/topics');
|
|||
|
|
this.topics = await response.json();
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('加载 topics 失败:', error);
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
async selectTopic(topic) {
|
|||
|
|
this.selectedTopic = topic;
|
|||
|
|
await this.loadStats();
|
|||
|
|
await this.loadLogs();
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
async loadStats() {
|
|||
|
|
if (!this.selectedTopic) return;
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const response = await fetch(`/api/stats?topic=${this.selectedTopic}`);
|
|||
|
|
this.stats = await response.json();
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('加载统计信息失败:', error);
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
async loadLogs() {
|
|||
|
|
if (!this.selectedTopic) return;
|
|||
|
|
|
|||
|
|
this.loading = true;
|
|||
|
|
try {
|
|||
|
|
const response = await fetch(
|
|||
|
|
`/api/logs?topic=${this.selectedTopic}&count=${this.queryCount}`
|
|||
|
|
);
|
|||
|
|
const data = await response.json();
|
|||
|
|
this.logs = data.records || [];
|
|||
|
|
if (data.stats) {
|
|||
|
|
this.stats = data.stats;
|
|||
|
|
}
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('加载日志失败:', error);
|
|||
|
|
} finally {
|
|||
|
|
this.loading = false;
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
async queryFirst() {
|
|||
|
|
if (!this.selectedTopic) return;
|
|||
|
|
|
|||
|
|
this.loading = true;
|
|||
|
|
try {
|
|||
|
|
const response = await fetch(
|
|||
|
|
`/api/logs/first?topic=${this.selectedTopic}&count=${this.queryCount}`
|
|||
|
|
);
|
|||
|
|
const data = await response.json();
|
|||
|
|
this.logs = data.records || [];
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('查询失败:', error);
|
|||
|
|
} finally {
|
|||
|
|
this.loading = false;
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
async queryLast() {
|
|||
|
|
if (!this.selectedTopic) return;
|
|||
|
|
|
|||
|
|
this.loading = true;
|
|||
|
|
try {
|
|||
|
|
const response = await fetch(
|
|||
|
|
`/api/logs/last?topic=${this.selectedTopic}&count=${this.queryCount}`
|
|||
|
|
);
|
|||
|
|
const data = await response.json();
|
|||
|
|
this.logs = data.records || [];
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('查询失败:', error);
|
|||
|
|
} finally {
|
|||
|
|
this.loading = false;
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
getLogClass(status) {
|
|||
|
|
const classes = {
|
|||
|
|
'bg-gray-50 border-gray-300': status === 'pending',
|
|||
|
|
'bg-yellow-50 border-yellow-400': status === 'processing',
|
|||
|
|
'bg-green-50 border-green-400': status === 'processed'
|
|||
|
|
};
|
|||
|
|
return classes[status] || 'bg-gray-50 border-gray-300';
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
getStatusClass(status) {
|
|||
|
|
const classes = {
|
|||
|
|
'bg-gray-200 text-gray-700': status === 'pending',
|
|||
|
|
'bg-yellow-200 text-yellow-800': status === 'processing',
|
|||
|
|
'bg-green-200 text-green-800': status === 'processed'
|
|||
|
|
};
|
|||
|
|
return classes[status] || 'bg-gray-200 text-gray-700';
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
getStatusText(status) {
|
|||
|
|
const texts = {
|
|||
|
|
'pending': '待处理',
|
|||
|
|
'processing': '处理中',
|
|||
|
|
'processed': '已处理'
|
|||
|
|
};
|
|||
|
|
return texts[status] || '未知';
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
formatUUID(uuid) {
|
|||
|
|
// 只显示前 8 位
|
|||
|
|
return uuid ? uuid.substring(0, 8) : '';
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
formatBytes(bytes) {
|
|||
|
|
if (bytes === 0) return '0 B';
|
|||
|
|
const k = 1024;
|
|||
|
|
const sizes = ['B', 'KB', 'MB', 'GB'];
|
|||
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|||
|
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
async showLogDetail(log) {
|
|||
|
|
// 如果已经有完整数据,直接显示
|
|||
|
|
if (log.data && !log.dataPreview) {
|
|||
|
|
this.selectedLog = log;
|
|||
|
|
this.$refs.logDialog.showModal();
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 否则先显示弹窗,然后加载完整数据
|
|||
|
|
this.selectedLog = { ...log, data: '加载中...' };
|
|||
|
|
this.$refs.logDialog.showModal();
|
|||
|
|
this.loadingDetail = true;
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const response = await fetch(
|
|||
|
|
`/api/logs/record?topic=${this.selectedTopic}&index=${log.index}`
|
|||
|
|
);
|
|||
|
|
const fullRecord = await response.json();
|
|||
|
|
// 更新 selectedLog 为完整数据
|
|||
|
|
this.selectedLog = fullRecord;
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('加载日志详情失败:', error);
|
|||
|
|
this.selectedLog.data = '加载失败';
|
|||
|
|
} finally {
|
|||
|
|
this.loadingDetail = false;
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
closeModal() {
|
|||
|
|
this.$refs.logDialog.close();
|
|||
|
|
this.selectedLog = null;
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
// 处理滚动事件
|
|||
|
|
handleScroll(event) {
|
|||
|
|
const container = event.target;
|
|||
|
|
const scrollTop = container.scrollTop;
|
|||
|
|
const scrollHeight = container.scrollHeight;
|
|||
|
|
const clientHeight = container.clientHeight;
|
|||
|
|
|
|||
|
|
// 触顶:加载更早的记录
|
|||
|
|
if (scrollTop < this.scrollThreshold && !this.loadingOlder && this.logs.length > 0) {
|
|||
|
|
this.loadOlder();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 触底:加载更新的记录
|
|||
|
|
if (scrollHeight - scrollTop - clientHeight < this.scrollThreshold && !this.loadingNewer && this.logs.length > 0) {
|
|||
|
|
this.loadNewer();
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
// 加载更早的记录(向前)
|
|||
|
|
async loadOlder() {
|
|||
|
|
if (!this.selectedTopic || this.logs.length === 0 || this.loadingOlder) return;
|
|||
|
|
|
|||
|
|
// 获取当前最小索引
|
|||
|
|
const minIndex = Math.min(...this.logs.map(log => log.index));
|
|||
|
|
if (minIndex === 0) return; // 已经到达最早
|
|||
|
|
|
|||
|
|
this.loadingOlder = true;
|
|||
|
|
const container = this.$refs.logContainer;
|
|||
|
|
const oldScrollHeight = container.scrollHeight;
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const response = await fetch(
|
|||
|
|
`/api/logs/older?topic=${this.selectedTopic}&refIndex=${minIndex}&count=${this.queryCount}`
|
|||
|
|
);
|
|||
|
|
const data = await response.json();
|
|||
|
|
const newRecords = data.records || [];
|
|||
|
|
|
|||
|
|
if (newRecords.length > 0) {
|
|||
|
|
// 将新记录添加到数组开头
|
|||
|
|
this.logs = [...newRecords, ...this.logs];
|
|||
|
|
|
|||
|
|
// 保持滚动位置
|
|||
|
|
this.$nextTick(() => {
|
|||
|
|
const newScrollHeight = container.scrollHeight;
|
|||
|
|
container.scrollTop = newScrollHeight - oldScrollHeight;
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('加载更早记录失败:', error);
|
|||
|
|
} finally {
|
|||
|
|
this.loadingOlder = false;
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
// 加载更新的记录(向后)
|
|||
|
|
async loadNewer() {
|
|||
|
|
if (!this.selectedTopic || this.logs.length === 0 || this.loadingNewer) return;
|
|||
|
|
|
|||
|
|
// 获取当前最大索引
|
|||
|
|
const maxIndex = Math.max(...this.logs.map(log => log.index));
|
|||
|
|
|
|||
|
|
this.loadingNewer = true;
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const response = await fetch(
|
|||
|
|
`/api/logs/newer?topic=${this.selectedTopic}&refIndex=${maxIndex}&count=${this.queryCount}`
|
|||
|
|
);
|
|||
|
|
const data = await response.json();
|
|||
|
|
const newRecords = data.records || [];
|
|||
|
|
|
|||
|
|
if (newRecords.length > 0) {
|
|||
|
|
// 将新记录添加到数组末尾
|
|||
|
|
this.logs = [...this.logs, ...newRecords];
|
|||
|
|
}
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('加载更新记录失败:', error);
|
|||
|
|
} finally {
|
|||
|
|
this.loadingNewer = false;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
mounted() {
|
|||
|
|
this.loadTopics();
|
|||
|
|
|
|||
|
|
// 每 3 秒自动刷新统计信息
|
|||
|
|
setInterval(() => {
|
|||
|
|
if (this.selectedTopic) {
|
|||
|
|
this.loadStats();
|
|||
|
|
}
|
|||
|
|
}, 3000);
|
|||
|
|
|
|||
|
|
// 监听 dialog 关闭事件(ESC 键)
|
|||
|
|
this.$refs.logDialog.addEventListener('close', () => {
|
|||
|
|
this.selectedLog = null;
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// 点击弹窗外部关闭弹窗
|
|||
|
|
this.$refs.logDialog.addEventListener('click', (event) => {
|
|||
|
|
const rect = this.$refs.logDialog.getBoundingClientRect();
|
|||
|
|
const isInDialog = (
|
|||
|
|
rect.top <= event.clientY &&
|
|||
|
|
event.clientY <= rect.top + rect.height &&
|
|||
|
|
rect.left <= event.clientX &&
|
|||
|
|
event.clientX <= rect.left + rect.width
|
|||
|
|
);
|
|||
|
|
if (!isInDialog) {
|
|||
|
|
this.closeModal();
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
}).mount('#app');
|
|||
|
|
</script>
|
|||
|
|
</body>
|
|||
|
|
</html>
|