You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

597 lines
24 KiB

@{
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>&times;</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 = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;'
};
return text.replace(/[&<>"']/g, function(m) { return map[m]; });
}
</script>
}