|
|
|
@{
|
|
|
|
ViewData["Title"] = "客户端消息队列";
|
|
|
|
var address = ViewBag.Address as string ?? "TestClient";
|
|
|
|
}
|
|
|
|
|
|
|
|
<style>
|
|
|
|
/* 页面容器 */
|
|
|
|
.card-body {
|
|
|
|
height: calc(100vh - 280px);
|
|
|
|
display: flex;
|
|
|
|
flex-direction: column;
|
|
|
|
overflow: hidden;
|
|
|
|
}
|
|
|
|
|
|
|
|
.message-scroll-area {
|
|
|
|
flex: 1;
|
|
|
|
min-height: 0;
|
|
|
|
overflow-y: auto;
|
|
|
|
overflow-x: hidden;
|
|
|
|
border: 1px solid #dee2e6;
|
|
|
|
border-radius: 0.25rem;
|
|
|
|
background-color: #f8f9fa;
|
|
|
|
position: relative;
|
|
|
|
}
|
|
|
|
|
|
|
|
.log-files-panel {
|
|
|
|
background-color: #f8f9fa;
|
|
|
|
border: 1px solid #dee2e6;
|
|
|
|
border-radius: 0.25rem;
|
|
|
|
padding: 1rem;
|
|
|
|
margin-bottom: 1rem;
|
|
|
|
max-height: 180px;
|
|
|
|
overflow-y: auto;
|
|
|
|
flex-shrink: 0;
|
|
|
|
}
|
|
|
|
|
|
|
|
.log-file-item {
|
|
|
|
background: white;
|
|
|
|
border: 1px solid #dee2e6;
|
|
|
|
border-radius: 0.25rem;
|
|
|
|
padding: 8px;
|
|
|
|
margin-bottom: 8px;
|
|
|
|
transition: all 0.2s;
|
|
|
|
}
|
|
|
|
|
|
|
|
.log-file-item:hover {
|
|
|
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
|
|
|
}
|
|
|
|
|
|
|
|
.log-file-actions {
|
|
|
|
display: flex;
|
|
|
|
gap: 5px;
|
|
|
|
margin-top: 8px;
|
|
|
|
}
|
|
|
|
|
|
|
|
.log-file-actions .btn {
|
|
|
|
font-size: 0.8rem;
|
|
|
|
padding: 2px 8px;
|
|
|
|
}
|
|
|
|
|
|
|
|
/* 按钮组间距 */
|
|
|
|
.card-tools {
|
|
|
|
display: flex;
|
|
|
|
gap: 0.5rem;
|
|
|
|
align-items: center;
|
|
|
|
}
|
|
|
|
|
|
|
|
.card-tools .btn {
|
|
|
|
margin-left: 0;
|
|
|
|
}
|
|
|
|
|
|
|
|
/* 确保内容区域不会超出容器 */
|
|
|
|
.clusterize-content {
|
|
|
|
padding: 10px;
|
|
|
|
}
|
|
|
|
|
|
|
|
/* 消息卡片样式 */
|
|
|
|
.message-card {
|
|
|
|
margin-bottom: 10px;
|
|
|
|
border-radius: 0.25rem;
|
|
|
|
}
|
|
|
|
|
|
|
|
/* 消息区域行容器 */
|
|
|
|
.row.p-4 {
|
|
|
|
flex: 1;
|
|
|
|
display: flex;
|
|
|
|
min-height: 0;
|
|
|
|
margin: 0;
|
|
|
|
padding: 1rem !important;
|
|
|
|
}
|
|
|
|
|
|
|
|
.row.p-4 .col-md-6 {
|
|
|
|
display: flex;
|
|
|
|
flex-direction: column;
|
|
|
|
}
|
|
|
|
|
|
|
|
.row.p-4 .col-md-6 .card {
|
|
|
|
flex: 1;
|
|
|
|
display: flex;
|
|
|
|
flex-direction: column;
|
|
|
|
margin-bottom: 0;
|
|
|
|
}
|
|
|
|
|
|
|
|
.row.p-4 .col-md-6 .card .card-body {
|
|
|
|
flex: 1;
|
|
|
|
padding: 0;
|
|
|
|
display: flex;
|
|
|
|
flex-direction: column;
|
|
|
|
}
|
|
|
|
|
|
|
|
/* 自定义滚动条 */
|
|
|
|
.message-scroll-area::-webkit-scrollbar,
|
|
|
|
.log-files-panel::-webkit-scrollbar {
|
|
|
|
width: 6px;
|
|
|
|
}
|
|
|
|
|
|
|
|
.message-scroll-area::-webkit-scrollbar-track,
|
|
|
|
.log-files-panel::-webkit-scrollbar-track {
|
|
|
|
background: #f1f1f1;
|
|
|
|
border-radius: 3px;
|
|
|
|
}
|
|
|
|
|
|
|
|
.message-scroll-area::-webkit-scrollbar-thumb,
|
|
|
|
.log-files-panel::-webkit-scrollbar-thumb {
|
|
|
|
background: #c1c1c1;
|
|
|
|
border-radius: 3px;
|
|
|
|
}
|
|
|
|
|
|
|
|
.message-scroll-area::-webkit-scrollbar-thumb:hover,
|
|
|
|
.log-files-panel::-webkit-scrollbar-thumb:hover {
|
|
|
|
background: #a8a8a8;
|
|
|
|
}
|
|
|
|
|
|
|
|
/* 响应式高度调整 */
|
|
|
|
@@media (max-width: 768px) {
|
|
|
|
.card-body {
|
|
|
|
height: calc(100vh - 240px);
|
|
|
|
}
|
|
|
|
|
|
|
|
.log-files-panel {
|
|
|
|
max-height: 150px;
|
|
|
|
}
|
|
|
|
|
|
|
|
.row.p-4 {
|
|
|
|
padding: 0.75rem !important;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
@@media (max-width: 576px) {
|
|
|
|
.card-body {
|
|
|
|
height: calc(100vh - 220px);
|
|
|
|
}
|
|
|
|
|
|
|
|
.log-files-panel {
|
|
|
|
max-height: 120px;
|
|
|
|
}
|
|
|
|
|
|
|
|
.row.p-4 {
|
|
|
|
padding: 0.5rem !important;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
</style>
|
|
|
|
|
|
|
|
<div class="container">
|
|
|
|
<div class="row">
|
|
|
|
<div class="col-12">
|
|
|
|
<div class="card">
|
|
|
|
<div class="card-header">
|
|
|
|
<h3 class="card-title">客户端消息队列 - @address</h3>
|
|
|
|
<div class="card-tools">
|
|
|
|
<span id="connection-status" class="badge badge-secondary">正在连接...</span>
|
|
|
|
<span id="message-status" class="badge badge-info ml-2" style="display: none;">等待消息...</span>
|
|
|
|
<button id="refreshLogFiles" class="btn btn-outline-primary btn-sm">
|
|
|
|
<i class="fas fa-sync-alt"></i> 刷新日志文件
|
|
|
|
</button>
|
|
|
|
<a href="@Url.Action("TestClientConfig", "Home")" class="btn btn-info btn-sm">
|
|
|
|
<i class="fas fa-cog"></i> 配置
|
|
|
|
</a>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<div class="card-body p-0">
|
|
|
|
<!-- 日志文件管理面板 -->
|
|
|
|
<div class="log-files-panel">
|
|
|
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
|
|
|
<h5><i class="fas fa-file-alt"></i> 消息日志文件管理</h5>
|
|
|
|
<div id="logStats" class="text-muted small">
|
|
|
|
<span id="totalFiles">0</span> 个文件 |
|
|
|
|
<span id="totalSize">0</span> KB |
|
|
|
|
<span id="lastUpdate">-</span>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<div id="logFilesContainer">
|
|
|
|
<div class="text-muted">正在加载日志文件列表...</div>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
|
|
|
|
<div class="row p-4">
|
|
|
|
<div class="col-md-6">
|
|
|
|
<div class="card">
|
|
|
|
<div class="card-header">
|
|
|
|
<h5 class="card-title">
|
|
|
|
<i class="fas fa-paper-plane text-success"></i> 发送消息
|
|
|
|
<span class="badge badge-primary" id="sentCount">0</span>
|
|
|
|
</h5>
|
|
|
|
</div>
|
|
|
|
<div id="sentScrollArea" class="clusterize-scroll message-scroll-area">
|
|
|
|
<div id="sentContentArea" class="clusterize-content">
|
|
|
|
<div class="clusterize-no-data">正在连接...</div>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<div class="col-md-6">
|
|
|
|
<div class="card">
|
|
|
|
<div class="card-header">
|
|
|
|
<h5 class="card-title">
|
|
|
|
<i class="fas fa-download text-info"></i> 接收消息
|
|
|
|
<span class="badge badge-info" id="receivedCount">0</span>
|
|
|
|
</h5>
|
|
|
|
</div>
|
|
|
|
<div id="receivedScrollArea" class="clusterize-scroll message-scroll-area">
|
|
|
|
<div id="receivedContentArea" class="clusterize-content">
|
|
|
|
<div class="clusterize-no-data">正在连接...</div>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
|
|
|
|
<!-- 日志文件内容模态框 -->
|
|
|
|
<div class="modal fade" id="logContentModal" tabindex="-1" role="dialog">
|
|
|
|
<div class="modal-dialog modal-xl" role="document">
|
|
|
|
<div class="modal-content">
|
|
|
|
<div class="modal-header">
|
|
|
|
<h5 class="modal-title" id="logContentModalTitle">日志文件内容</h5>
|
|
|
|
<button type="button" class="close" data-dismiss="modal">
|
|
|
|
<span>×</span>
|
|
|
|
</button>
|
|
|
|
</div>
|
|
|
|
<div class="modal-body">
|
|
|
|
<div class="form-group">
|
|
|
|
<label for="logContentLines">显示行数:</label>
|
|
|
|
<select id="logContentLines" class="form-control" style="width: 150px;">
|
|
|
|
<option value="50">最近50行</option>
|
|
|
|
<option value="100" selected>最近100行</option>
|
|
|
|
<option value="200">最近200行</option>
|
|
|
|
<option value="500">最近500行</option>
|
|
|
|
<option value="1000">最近1000行</option>
|
|
|
|
</select>
|
|
|
|
</div>
|
|
|
|
<div id="logContentContainer">
|
|
|
|
<div class="text-muted">正在加载日志内容...</div>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<div class="modal-footer">
|
|
|
|
<button type="button" class="btn btn-secondary" data-dismiss="modal">关闭</button>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
|
|
|
|
@section Scripts {
|
|
|
|
<!-- Clusterize.js for virtual scrolling -->
|
|
|
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/clusterize.js/0.19.0/clusterize.min.css">
|
|
|
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/clusterize.js/0.19.0/clusterize.min.js"></script>
|
|
|
|
|
|
|
|
<!-- highlight.js for syntax highlighting -->
|
|
|
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.7.0/styles/github.min.css">
|
|
|
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.7.0/highlight.min.js"></script>
|
|
|
|
|
|
|
|
<script>
|
|
|
|
const address = '@address';
|
|
|
|
const MAX_MESSAGES_IN_MEMORY = 10000; // JS内存中最多保留1万条数据
|
|
|
|
|
|
|
|
let sentMessagesData = [];
|
|
|
|
let receivedMessagesData = [];
|
|
|
|
let sentClusterize, receivedClusterize;
|
|
|
|
let currentLogFileName = '';
|
|
|
|
|
|
|
|
$(document).ready(function() {
|
|
|
|
// 初始化虚拟滚动列表
|
|
|
|
sentClusterize = new Clusterize({
|
|
|
|
rows: [],
|
|
|
|
scrollId: 'sentScrollArea',
|
|
|
|
contentId: 'sentContentArea',
|
|
|
|
tag: 'div',
|
|
|
|
rows_in_block: 20,
|
|
|
|
blocks_in_cluster: 4,
|
|
|
|
no_data_text: '暂无发送消息'
|
|
|
|
});
|
|
|
|
|
|
|
|
receivedClusterize = new Clusterize({
|
|
|
|
rows: [],
|
|
|
|
scrollId: 'receivedScrollArea',
|
|
|
|
contentId: 'receivedContentArea',
|
|
|
|
tag: 'div',
|
|
|
|
rows_in_block: 20,
|
|
|
|
blocks_in_cluster: 4,
|
|
|
|
no_data_text: '暂无接收消息'
|
|
|
|
});
|
|
|
|
|
|
|
|
// 只有 address 有效时才启动SSE连接
|
|
|
|
if (address && address !== "null" && address.trim() !== "") {
|
|
|
|
initializeEventSource();
|
|
|
|
} else {
|
|
|
|
$('#connection-status').removeClass('badge-secondary').addClass('badge-danger').text('未指定客户端地址,无法连接消息流');
|
|
|
|
}
|
|
|
|
// 加载日志文件列表
|
|
|
|
loadLogFiles();
|
|
|
|
// 绑定事件
|
|
|
|
$('#refreshLogFiles').click(loadLogFiles);
|
|
|
|
$('#logContentLines').change(function() {
|
|
|
|
if (currentLogFileName) {
|
|
|
|
loadLogContent(currentLogFileName, $(this).val());
|
|
|
|
}
|
|
|
|
});
|
|
|
|
});
|
|
|
|
|
|
|
|
// 加载日志文件列表
|
|
|
|
function loadLogFiles() {
|
|
|
|
$('#logFilesContainer').html('<div class="text-muted">正在加载日志文件列表...</div>');
|
|
|
|
|
|
|
|
$.get('/api/message/logs')
|
|
|
|
.done(function(response) {
|
|
|
|
if (response.files && response.files.length > 0) {
|
|
|
|
const filesHtml = response.files.map(file => createLogFileItemHtml(file)).join('');
|
|
|
|
$('#logFilesContainer').html(filesHtml);
|
|
|
|
|
|
|
|
// 更新统计信息
|
|
|
|
const totalSize = response.files.reduce((sum, file) => sum + file.size, 0);
|
|
|
|
const totalSizeKB = Math.round(totalSize / 1024 * 100) / 100;
|
|
|
|
const nonEmptyFiles = response.files.filter(file => file.size > 0).length;
|
|
|
|
|
|
|
|
$('#totalFiles').text(`${nonEmptyFiles}/${response.files.length}`);
|
|
|
|
$('#totalSize').text(totalSizeKB);
|
|
|
|
$('#lastUpdate').text(new Date().toLocaleTimeString());
|
|
|
|
} else {
|
|
|
|
$('#logFilesContainer').html('<div class="text-muted">暂无日志文件</div>');
|
|
|
|
$('#totalFiles').text('0/0');
|
|
|
|
$('#totalSize').text('0');
|
|
|
|
$('#lastUpdate').text(new Date().toLocaleTimeString());
|
|
|
|
}
|
|
|
|
})
|
|
|
|
.fail(function(xhr) {
|
|
|
|
$('#logFilesContainer').html('<div class="text-danger">加载日志文件列表失败: ' + (xhr.responseJSON?.message || xhr.statusText) + '</div>');
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
// 创建日志文件项HTML
|
|
|
|
function createLogFileItemHtml(file) {
|
|
|
|
const sizeKB = Math.round(file.size / 1024 * 100) / 100;
|
|
|
|
const lastModified = new Date(file.lastModified).toLocaleString();
|
|
|
|
const isEmpty = file.size === 0;
|
|
|
|
const statusClass = isEmpty ? 'text-muted' : 'text-success';
|
|
|
|
const statusText = isEmpty ? '空文件' : '有数据';
|
|
|
|
|
|
|
|
return `
|
|
|
|
<div class="log-file-item">
|
|
|
|
<div class="d-flex justify-content-between align-items-start">
|
|
|
|
<div>
|
|
|
|
<strong>${file.fileName}</strong>
|
|
|
|
<span class="badge badge-${isEmpty ? 'secondary' : 'success'} ml-2">${statusText}</span>
|
|
|
|
<br>
|
|
|
|
<small class="text-muted">
|
|
|
|
类型: ${file.type} | 大小: ${sizeKB} KB | 修改时间: ${lastModified}
|
|
|
|
</small>
|
|
|
|
</div>
|
|
|
|
<div class="log-file-actions">
|
|
|
|
<button class="btn btn-outline-primary btn-sm" onclick="viewLogContent('${file.fileName}')" ${isEmpty ? 'disabled' : ''}>
|
|
|
|
<i class="fas fa-eye"></i> 查看
|
|
|
|
</button>
|
|
|
|
<button class="btn btn-outline-warning btn-sm" onclick="clearLogFile('${file.fileName}')">
|
|
|
|
<i class="fas fa-eraser"></i> 清空
|
|
|
|
</button>
|
|
|
|
<button class="btn btn-outline-danger btn-sm" onclick="deleteLogFile('${file.fileName}')">
|
|
|
|
<i class="fas fa-trash"></i> 删除
|
|
|
|
</button>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
`;
|
|
|
|
}
|
|
|
|
|
|
|
|
// 查看日志文件内容
|
|
|
|
function viewLogContent(fileName) {
|
|
|
|
currentLogFileName = fileName;
|
|
|
|
$('#logContentModalTitle').text('日志文件内容: ' + fileName);
|
|
|
|
$('#logContentModal').modal('show');
|
|
|
|
loadLogContent(fileName, $('#logContentLines').val());
|
|
|
|
}
|
|
|
|
|
|
|
|
// 加载日志文件内容
|
|
|
|
function loadLogContent(fileName, lines) {
|
|
|
|
$('#logContentContainer').html('<div class="text-muted">正在加载日志内容...</div>');
|
|
|
|
|
|
|
|
$.get(`/api/message/logs/${encodeURIComponent(fileName)}?lines=${lines}`)
|
|
|
|
.done(function(response) {
|
|
|
|
if (response.content && response.content.length > 0) {
|
|
|
|
const contentHtml = response.content.map(line =>
|
|
|
|
`<div class="log-line">${escapeHtml(line)}</div>`
|
|
|
|
).join('');
|
|
|
|
$('#logContentContainer').html(`
|
|
|
|
<div class="alert alert-info">
|
|
|
|
文件: ${response.fileName} | 总行数: ${response.totalLines} | 显示行数: ${response.returnedLines} | 文件大小: ${Math.round(response.fileSize / 1024 * 100) / 100} KB
|
|
|
|
</div>
|
|
|
|
<div class="border rounded p-3" style="max-height: 500px; overflow-y: auto; background-color: #f8f9fa; font-family: monospace; font-size: 12px;">
|
|
|
|
${contentHtml}
|
|
|
|
</div>
|
|
|
|
`);
|
|
|
|
} else {
|
|
|
|
$('#logContentContainer').html('<div class="text-muted">日志文件为空</div>');
|
|
|
|
}
|
|
|
|
})
|
|
|
|
.fail(function(xhr) {
|
|
|
|
$('#logContentContainer').html('<div class="text-danger">加载日志内容失败: ' + (xhr.responseJSON?.message || xhr.statusText) + '</div>');
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
// 清空日志文件
|
|
|
|
function clearLogFile(fileName) {
|
|
|
|
if (!confirm(`确定要清空日志文件 "${fileName}" 吗?`)) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
$.ajax({
|
|
|
|
url: '/api/message/logs',
|
|
|
|
method: 'DELETE',
|
|
|
|
data: { fileName: fileName }
|
|
|
|
})
|
|
|
|
.done(function(response) {
|
|
|
|
alert('日志文件已清空');
|
|
|
|
loadLogFiles();
|
|
|
|
})
|
|
|
|
.fail(function(xhr) {
|
|
|
|
alert('清空日志文件失败: ' + (xhr.responseJSON?.message || xhr.statusText));
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
// 删除日志文件
|
|
|
|
function deleteLogFile(fileName) {
|
|
|
|
if (!confirm(`确定要删除日志文件 "${fileName}" 吗?此操作不可恢复!`)) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
$.ajax({
|
|
|
|
url: '/api/message/logs/delete',
|
|
|
|
method: 'DELETE',
|
|
|
|
data: { fileName: fileName }
|
|
|
|
})
|
|
|
|
.done(function(response) {
|
|
|
|
alert('日志文件已删除');
|
|
|
|
loadLogFiles();
|
|
|
|
})
|
|
|
|
.fail(function(xhr) {
|
|
|
|
alert('删除日志文件失败: ' + (xhr.responseJSON?.message || xhr.statusText));
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
function initializeEventSource() {
|
|
|
|
const source = new EventSource(`/api/message/${encodeURIComponent(address)}/stream`);
|
|
|
|
const statusBadge = $('#connection-status');
|
|
|
|
const messageStatusBadge = $('#message-status');
|
|
|
|
|
|
|
|
source.addEventListener('open', function(e) {
|
|
|
|
console.log("SSE connection opened.");
|
|
|
|
statusBadge.removeClass('badge-secondary badge-danger').addClass('badge-success').text('已连接');
|
|
|
|
messageStatusBadge.show().removeClass('badge-success badge-warning').addClass('badge-info').text('等待消息...');
|
|
|
|
|
|
|
|
// 重置数据和视图
|
|
|
|
sentMessagesData = [];
|
|
|
|
receivedMessagesData = [];
|
|
|
|
sentClusterize.update([]);
|
|
|
|
receivedClusterize.update([]);
|
|
|
|
});
|
|
|
|
|
|
|
|
source.addEventListener('update', function(e) {
|
|
|
|
const data = JSON.parse(e.data);
|
|
|
|
const isSent = data.type === 'sent';
|
|
|
|
|
|
|
|
// 更新消息状态
|
|
|
|
if (data.newCount > 0) {
|
|
|
|
messageStatusBadge.removeClass('badge-info badge-warning').addClass('badge-success')
|
|
|
|
.text(`收到 ${data.newCount} 条新${isSent ? '发送' : '接收'}消息`);
|
|
|
|
|
|
|
|
// 3秒后恢复等待状态
|
|
|
|
setTimeout(() => {
|
|
|
|
if (sentMessagesData.length === 0 && receivedMessagesData.length === 0) {
|
|
|
|
messageStatusBadge.removeClass('badge-success').addClass('badge-info').text('等待消息...');
|
|
|
|
} else {
|
|
|
|
messageStatusBadge.removeClass('badge-success').addClass('badge-warning')
|
|
|
|
.text(`总计: 发送${sentMessagesData.length}条, 接收${receivedMessagesData.length}条`);
|
|
|
|
}
|
|
|
|
}, 3000);
|
|
|
|
}
|
|
|
|
|
|
|
|
const clusterize = isSent ? sentClusterize : receivedClusterize;
|
|
|
|
const dataArray = isSent ? sentMessagesData : receivedMessagesData;
|
|
|
|
const scrollArea = isSent ? $('#sentScrollArea') : $('#receivedScrollArea');
|
|
|
|
|
|
|
|
// 检查用户是否已滚动到底部
|
|
|
|
const isScrolledToBottom = scrollArea[0].scrollHeight - scrollArea.scrollTop() < scrollArea.outerHeight() + 50;
|
|
|
|
|
|
|
|
const newRows = data.messages.map(function(msg, i) {
|
|
|
|
let totalIndex = data.totalCount - data.messages.length + i;
|
|
|
|
return createMessageCardHtml(msg, totalIndex, data.type);
|
|
|
|
});
|
|
|
|
|
|
|
|
// 使用 append 方法,性能更高
|
|
|
|
clusterize.append(newRows);
|
|
|
|
dataArray.push(...newRows); // 同步数据到内存数组
|
|
|
|
|
|
|
|
// 限制内存中的数据量
|
|
|
|
if (dataArray.length > MAX_MESSAGES_IN_MEMORY) {
|
|
|
|
const toRemoveCount = dataArray.length - MAX_MESSAGES_IN_MEMORY;
|
|
|
|
dataArray.splice(0, toRemoveCount);
|
|
|
|
// Clusterize.js 内部会自动处理DOM,我们只需更新数据数组即可
|
|
|
|
clusterize.update(dataArray);
|
|
|
|
}
|
|
|
|
|
|
|
|
// 如果用户之前在底部,则自动滚动到底部
|
|
|
|
if (isScrolledToBottom) {
|
|
|
|
scrollArea.scrollTop(scrollArea[0].scrollHeight);
|
|
|
|
}
|
|
|
|
|
|
|
|
$(`#${data.type}Count`).text(data.totalCount);
|
|
|
|
});
|
|
|
|
|
|
|
|
source.addEventListener('error', function(e) {
|
|
|
|
statusBadge.removeClass('badge-success').addClass('badge-danger').text('连接断开');
|
|
|
|
messageStatusBadge.hide();
|
|
|
|
console.error("SSE connection error/closed.", e);
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
function createMessageCardHtml(message, index, type) {
|
|
|
|
const timestamp = new Date().toLocaleTimeString();
|
|
|
|
const messageType = type === 'sent' ? '发送' : '接收';
|
|
|
|
const bgClass = type === 'sent' ? 'border-success' : 'border-info';
|
|
|
|
const iconClass = type === 'sent' ? 'fas fa-paper-plane text-success' : 'fas fa-download text-info';
|
|
|
|
|
|
|
|
return `
|
|
|
|
<div class="card mb-2 ${bgClass}">
|
|
|
|
<div class="card-header py-2">
|
|
|
|
<small class="text-muted">
|
|
|
|
<i class="${iconClass}"></i> ${messageType} #${index + 1} - ${timestamp}
|
|
|
|
</small>
|
|
|
|
<button class="btn btn-sm btn-outline-secondary float-right py-0" onclick="toggleMessage(this)">
|
|
|
|
<i class="fas fa-chevron-down"></i>
|
|
|
|
</button>
|
|
|
|
</div>
|
|
|
|
<div class="card-body py-2" style="display: none;">
|
|
|
|
<pre><code class="json">${escapeHtml(formatJson(message))}</code></pre>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
`;
|
|
|
|
}
|
|
|
|
|
|
|
|
function toggleMessage(button) {
|
|
|
|
const cardBody = $(button).closest('.card').find('.card-body');
|
|
|
|
cardBody.slideToggle('fast', function() {
|
|
|
|
// 当内容可见时,才进行语法高亮
|
|
|
|
if (cardBody.is(':visible')) {
|
|
|
|
const codeBlock = cardBody.find('pre code');
|
|
|
|
if (codeBlock.length && typeof hljs !== 'undefined' && !codeBlock.hasClass('hljs')) {
|
|
|
|
hljs.highlightElement(codeBlock[0]);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
});
|
|
|
|
$(button).find('i').toggleClass('fa-chevron-down fa-chevron-up');
|
|
|
|
}
|
|
|
|
|
|
|
|
function formatJson(jsonString) {
|
|
|
|
try {
|
|
|
|
const obj = JSON.parse(jsonString);
|
|
|
|
return JSON.stringify(obj, null, 2);
|
|
|
|
} catch (e) {
|
|
|
|
return jsonString;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
function escapeHtml(text) {
|
|
|
|
const map = {
|
|
|
|
'&': '&',
|
|
|
|
'<': '<',
|
|
|
|
'>': '>',
|
|
|
|
'"': '"',
|
|
|
|
"'": '''
|
|
|
|
};
|
|
|
|
return text.replace(/[&<>"']/g, function(m) { return map[m]; });
|
|
|
|
}
|
|
|
|
</script>
|
|
|
|
}
|