Files
squad-rain-ops-mini/wwwroot/logs.html

708 lines
32 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Squad 游戏日志查询</title>
<link href="/lib/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="/lib/css/font-awesome.min.css">
<script src="/lib/js/vue.global.prod.js"></script>
<script src="/lib/js/chart.js"></script>
<style>
:root {
--primary-color: #0056b3;
--header-bg: #f8f9fa;
--border-color: #dee2e6;
}
html, body {
height: 100%;
overflow: hidden;
}
body {
background-color: #f0f2f5;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
}
#app {
height: 100%;
display: flex;
flex-direction: column;
}
.main-card {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
border: none;
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
border-radius: 0.5rem;
/* 移除之前的 mb-4因为 flex 布局不需要 margin-bottom 来撑开 */
margin-bottom: 0 !important;
}
/* 统计卡片容器需要固定高度或者自适应,这里让它保持原样,不参与 flex:1 */
.stats-container {
flex-shrink: 0;
}
/* 筛选区域容器 */
.filter-card {
flex-shrink: 0;
border: none;
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
border-radius: 0.5rem;
margin-bottom: 1rem;
}
.card-body {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.table-container {
flex: 1;
overflow-y: auto;
border: 1px solid var(--border-color);
border-radius: 0.5rem;
background-color: white;
}
.table thead th {
position: sticky;
top: 0;
background-color: var(--header-bg);
border-bottom: 2px solid var(--border-color);
color: #495057;
font-weight: 600;
z-index: 1;
padding-top: 1rem;
padding-bottom: 1rem;
box-shadow: 0 1px 0 var(--border-color);
/* 修复边框在滚动时的问题 */
}
.table tbody td {
vertical-align: middle;
padding: 0.75rem 0.75rem;
border-bottom-color: #f0f0f0;
}
.table-hover tbody tr:hover {
background-color: #f1f4f9;
}
/* 强制表格列不换行,除非显式指定 */
.text-nowrap-custom {
white-space: nowrap;
}
.raw-log-cell {
font-family: "Consolas", "Monaco", monospace;
font-size: 0.85rem;
color: #6c757d;
max-width: 0;
/* 配合 text-truncate 使用 */
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pagination {
margin-bottom: 0;
}
/* 自定义滚动条样式 */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #f1f1f1;
}
::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}
.badge-soft {
background-color: rgba(13, 110, 253, 0.1);
color: var(--primary-color);
}
</style>
</head>
<body>
<div id="app" class="container-fluid px-4 py-4">
<div class="d-flex justify-content-between align-items-center mb-4">
<h4 class="mb-0 fw-bold text-secondary"><i class="fas fa-file-alt"></i> Squad 游戏日志查询</h4>
<div>
<span class="badge bg-white text-secondary border me-2 py-2 px-3">总记录: {{ totalCount }}</span>
<span class="badge bg-white text-secondary border py-2 px-3">页码: {{ currentPage }} / {{ totalPages
}}</span>
</div>
</div>
<div class="card filter-card">
<div class="card-body py-3" style="flex: 0 0 auto;">
<div class="row g-3 align-items-center">
<!-- 类型筛选 -->
<div class="col-auto">
<label class="col-form-label fw-bold text-secondary"><i class="fas fa-filter"></i> 类型</label>
</div>
<div class="col-auto">
<select class="form-select border-secondary-subtle" v-model="selectedType"
@change="handleTypeChange" style="min-width: 180px;">
<option value="">全部类型</option>
<option v-for="type in logTypes" :key="type" :value="type">{{ logTypeMapping[type] || type
}}</option>
</select>
</div>
<!-- 搜索框 -->
<div class="col-auto ms-3">
<label class="col-form-label fw-bold text-secondary">搜索</label>
</div>
<div class="col-auto flex-grow-1" style="min-width: 200px; max-width: 400px;">
<input type="text" class="form-control border-secondary-subtle" v-model="searchQuery"
@keyup.enter="searchLogs" placeholder="搜索玩家名、SteamID等...">
</div>
<!-- 每页显示 -->
<div class="col-auto ms-3">
<select class="form-select border-secondary-subtle" v-model="pageSize" @change="searchLogs">
<option :value="20">20条/页</option>
<option :value="50">50条/页</option>
<option :value="100">100条/页</option>
</select>
</div>
<!-- 按钮 -->
<div class="col-auto ms-auto">
<button class="btn btn-primary px-4 me-2" @click="searchLogs" :disabled="loading">
<span v-if="loading" class="spinner-border spinner-border-sm me-1" role="status"
aria-hidden="true"></span>
查询
</button>
<button class="btn btn-light border px-3" @click="resetFilters">重置</button>
</div>
</div>
</div>
</div>
<!-- 统计卡片 -->
<div class="row g-3 mb-4 stats-container" v-show="!selectedType && Object.keys(logStats).length > 0">
<!-- 图表区域 -->
<div class="col-md-3">
<div class="card h-100 border-0 shadow-sm">
<div class="card-body p-2 d-flex justify-content-center align-items-center" style="height: 200px; position: relative;">
<canvas id="logStatsChart"></canvas>
</div>
</div>
</div>
<!-- 数据卡片区域 -->
<div class="col-md-9">
<div class="row g-3 h-100 align-content-start">
<div class="col-md-3 col-sm-4 col-6" v-for="(count, type) in logStats" :key="type">
<div class="card h-100 border-0 shadow-sm" @click="selectedType = type; searchLogs()"
style="cursor: pointer;">
<div class="card-body p-3" style="flex: 0 0 auto;">
<h6 class="card-subtitle mb-2 text-muted small text-truncate"
:title="logTypeMapping[type] || type">
{{ logTypeMapping[type] || type }}
</h6>
<h4 class="card-title mb-0 fw-bold text-primary">{{ count }}</h4>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 数据表格 -->
<div class="card main-card">
<div class="card-body p-0">
<div class="table-container">
<table class="table table-hover align-middle mb-0 w-100">
<thead>
<tr>
<th v-for="col in currentColumns" :key="col.key" :style="{ width: col.width }">
{{ col.label }}
</th>
<!-- 只有当显示通用列表时才显示详情按钮列 -->
<th v-if="!selectedType" style="width: 100px; text-align: center;">操作</th>
</tr>
</thead>
<tbody>
<tr v-if="loading">
<td :colspan="currentColumns.length + (selectedType ? 0 : 1)" class="text-center py-5">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</td>
</tr>
<tr v-else-if="logs.length === 0">
<td :colspan="currentColumns.length + (selectedType ? 0 : 1)"
class="text-center text-muted py-5">
暂无数据
</td>
</tr>
<tr v-for="(log, index) in logs" :key="index">
<td v-for="col in currentColumns" :key="col.key" :class="col.class">
<!-- 特殊处理 rawContent -->
<div v-if="col.key === 'rawContent'" class="raw-log-cell"
:title="log.rawContent || log.RawContent">
{{ log.rawContent || log.RawContent }}
</div>
<!-- 普通字段 -->
<span v-else>{{ getColumnValue(log, col) }}</span>
</td>
<td v-if="!selectedType" class="text-center">
<button class="btn btn-sm btn-outline-info" @click="showDetail(log)">
详情
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- 分页条 -->
<div class="card-footer bg-white py-2">
<nav aria-label="Page navigation">
<ul class="pagination justify-content-center mb-0">
<li class="page-item" :class="{ disabled: currentPage === 1 }">
<button class="page-link" @click="changePage(1)">&laquo;</button>
</li>
<li class="page-item" :class="{ disabled: currentPage === 1 }">
<button class="page-link" @click="changePage(currentPage - 1)">&lt;</button>
</li>
<li class="page-item disabled"><span class="page-link">第 {{ currentPage }} 页 / 共 {{ totalPages
}} 页</span></li>
<li class="page-item" :class="{ disabled: currentPage === totalPages || totalPages === 0 }">
<button class="page-link" @click="changePage(currentPage + 1)">&gt;</button>
</li>
<li class="page-item" :class="{ disabled: currentPage === totalPages || totalPages === 0 }">
<button class="page-link" @click="changePage(totalPages)">&raquo;</button>
</li>
</ul>
</nav>
</div>
</div>
</div>
<!-- 详情模态框 -->
<div class="modal fade" id="detailModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">日志详情</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div v-if="currentLog">
<h6>基础信息</h6>
<table class="table table-sm table-bordered">
<tr>
<th style="width: 120px;">时间</th>
<td>{{ formatDate(currentLog.timestamp || currentLog.Timestamp) }}</td>
</tr>
<tr>
<th>类型</th>
<td>{{ currentLog.logType || currentLog.LogType }}</td>
</tr>
</table>
<h6 class="mt-3">解析数据</h6>
<div v-if="currentLogData && typeof currentLogData === 'object'">
<table class="table table-sm table-striped table-bordered">
<tbody>
<tr v-for="(value, key) in currentLogData" :key="key">
<th style="width: 30%;">{{ translateField(key) }} ({{key}})</th>
<td class="text-break">{{ value }}</td>
</tr>
</tbody>
</table>
</div>
<div v-else class="p-2 bg-light border rounded">
{{ currentLogData }}
</div>
<h6 class="mt-3">原始日志</h6>
<pre class="bg-light p-2 border rounded"
style="white-space: pre-wrap; font-size: 0.85rem;">{{ currentLog.rawContent || currentLog.RawContent }}</pre>
</div>
</div>
</div>
</div>
</div>
<script src="/lib/js/bootstrap.bundle.min.js"></script>
<script>
const { createApp, ref, computed, onMounted } = Vue;
createApp({
setup() {
const logs = ref([]);
const loading = ref(false);
const selectedType = ref('');
const searchQuery = ref('');
const currentPage = ref(1);
const pageSize = ref(20);
const totalCount = ref(0);
const currentLog = ref(null);
const logStats = ref({});
let detailModal = null;
let chartInstance = null;
// 常见类型
const logTypes = [
'CreateSquad', 'Wound', 'Die', 'Revived', 'Tick', 'BattleEnd', 'NewPlayerConnect', 'Disconnect'
];
// 中文映射
const logTypeMapping = {
'NewPlayerConnect': '进服',
'Wound': '击倒',
'Die': '死亡',
'Revived': '复活',
'Tick': 'TPS刷新率',
'BattleEnd': '战斗结束' ,
'Disconnect': '退服',
'CreateSquad': '建队'
};
// 字段映射
const fieldMapping = {
'LogTime': '日志时间', 'ThreadID': '线程ID', 'SEQUENCE': '序列号',
'PlayerName': '玩家名称', 'SteamName': 'Steam名称', 'UserName': '用户名',
'SteamID': 'Steam ID', 'EOSID': 'EOS ID', 'ControllerID': '控制器ID',
'RemoteAddrIP': 'IP地址', 'RemoteAddrPort': '端口', 'OutTime': '退出时间',
'KillingDamage': '致死伤害', 'DamageValue': '伤害数值', 'ActualDamage': '实际伤害',
'CauseObjectID': '伤害来源/武器', 'Weapon': '武器', 'WeaponType': '武器类型',
'AttackerName': '攻击者', 'VictimName': '受害者',
'TeamName': '队伍名称', 'SquadName': '小队名称', 'SquadNum': '小队号',
'Tickets': '剩余票数', 'MapLayer': '地图层', 'MapLevel': '地图等级',
'TickRate': 'Tick率', 'ReviverName': '救治者', 'RevivedName': '被救者'
};
const translateField = (key) => fieldMapping[key] || key;
// 列定义
const defaultColumns = [
{ key: 'timestamp', label: '时间', width: '180px', formatter: 'time' },
{ key: 'logType', label: '类型', width: '150px' },
{ key: 'rawContent', label: '原始内容 (Raw)', class: 'raw-log-cell' }
];
const typeColumns = {
'Disconnect': [
{ key: 'timestamp', label: '退出时间', width: '170px', formatter: 'time' },
{ key: 'SteamName', label: 'Steam名称' },
{ key: 'SteamID', label: 'Steam ID' },
{ key: 'EOSID', label: 'EOS ID' }
],
'NewPlayerConnect': [
{ key: 'timestamp', label: '加入时间', width: '170px', formatter: 'time' },
{ key: 'IPAddress', label: 'IP地址' },
{ key: 'SteamID', label: 'Steam ID' },
{ key: 'EOSID', label: 'EOS ID' }
],
'CreateSquad': [
{ key: 'timestamp', label: '创建时间', width: '170px', formatter: 'time' },
{ key: 'SquadNum', label: '小队号', width: '80px' },
{ key: 'UserName', label: '队长' },
{ key: 'SquadName', label: '小队名' },
{ key: 'TeamName', label: '阵营' }
],
'Wound': [
{ key: 'timestamp', label: '时间', width: '200px', formatter: 'time' },
{ key: 'attackerName', label: '攻击者', width: '300px' },
{ key: 'attackerSteamID', label: '攻击者SteamID', width: '200px' },
{ key: 'VictimName', label: '受害者', width: '300px' },
{ key: 'VictimSteamID', label: '受害者SteamID', width: '200px' },
{ key: 'CauseObjectID', label: '武器' },
{ key: 'KillingDamage', label: '伤害' },
{ key: 'mapName', label: '当前地图' },
{ key: 'matchTime', label: '对局时间' },
{ key: 'IsTK', label: '是否TK', formatter: 'bool' }
],
'Die': [
{ key: 'timestamp', label: '时间', width: '200px', formatter: 'time' },
{ key: 'attackerName', label: '攻击者', width: '300px' },
{ key: 'attackerSteamID', label: '攻击者SteamID', width: '200px' },
{ key: 'VictimName', label: '受害者', width: '300px' },
{ key: 'VictimSteamID', label: '受害者SteamID', width: '200px' },
{ key: 'WeaponType', label: '武器' },
{ key: 'DamageValue', label: '伤害' },
{ key: 'mapName', label: '当前地图' },
{ key: 'matchTime', label: '对局时间' },
{ key: 'IsTK', label: '是否TK', formatter: 'bool' }
],
'Attack': [
{ key: 'timestamp', label: '时间', width: '170px', formatter: 'time' },
{ key: 'AttackerName', label: '攻击者' },
{ key: 'PlayerName', label: '受害者' },
{ key: 'ActualDamage', label: '伤害量' },
{ key: 'Weapon', label: '武器' }
],
'Revived': [
{ key: 'timestamp', label: '时间', width: '200px', formatter: 'time' },
{ key: 'ReviverName', label: '救治者', width: '300px' },
{ key: 'ReviverSteamID', label: '救治者SteamID', width: '200px' },
{ key: 'RevivedName', label: '被救者', width: '300px' },
{ key: 'RevivedSteamID', label: '被救者SteamID', width: '200px' },
{ key: 'MapName', label: '当前地图' },
{ key: 'MatchTime', label: '对局时间' },
{ key: 'IsTK', label: '是否TK', formatter: 'bool' }
],
'Tick': [
{ key: 'timestamp', label: '时间', width: '170px', formatter: 'time' },
{ key: 'TickRate', label: 'Tick率' },
],
'BattleEnd': [
{ key: 'timestamp', label: '时间', width: '170px', formatter: 'time' },
{ key: 'TeamInfo', label: '获胜队伍' },
{ key: 'Tickets', label: '剩余票数' },
{ key: 'MapLayer', label: '地图' },
{ key: 'Type', label: '胜利类型' }
]
};
const currentColumns = computed(() => {
if (selectedType.value && typeColumns[selectedType.value]) {
return typeColumns[selectedType.value];
}
return defaultColumns;
});
const totalPages = computed(() => {
return Math.ceil(totalCount.value / pageSize.value);
});
const currentLogData = computed(() => {
if (!currentLog.value) return null;
return currentLog.value.data || currentLog.value.Data;
});
// 获取嵌套属性
const getColumnValue = (log, col) => {
let val = '';
const data = log.data || log.Data || {};
if (col.key === 'timestamp') {
val = log.timestamp || log.Timestamp;
} else if (col.key === 'logType') {
val = log.logType || log.LogType;
} else if (col.key === 'rawContent') {
val = log.rawContent || log.RawContent;
} else {
// 尝试从 data 中获取,忽略大小写
val = data[col.key];
if (val === undefined || val === null) {
// 如果直接获取失败,尝试查找忽略大小写的 key
const lowerKey = col.key.toLowerCase();
const foundKey = Object.keys(data).find(k => k.toLowerCase() === lowerKey);
if (foundKey) {
val = data[foundKey];
}
}
}
if (col.formatter === 'time') {
return formatDate(val);
} else if (col.formatter === 'bool') {
if (val === true || val === 'true' || val === 1) return '是';
if (val === false || val === 'false' || val === 0) return '否';
return '';
}
// Fallbacks for compatibility with old logs
if (!val && col.key === 'VictimName') return getColumnValue(log, { key: 'PlayerName' });
if (!val && col.key === 'AttackerName') return getColumnValue(log, { key: 'CausePlayerControllerID' });
if (!val && col.key === 'AttackerSteamID') return getColumnValue(log, { key: 'SteamID' });
return val;
};
const formatDate = (dateStr) => {
if (!dateStr) return '';
const date = new Date(dateStr);
return date.toLocaleString('zh-CN', { hour12: false });
};
const initChart = () => {
const ctx = document.getElementById('logStatsChart');
if (!ctx) return;
if (chartInstance) {
chartInstance.destroy();
}
// 准备数据
const labels = Object.keys(logStats.value).map(k => logTypeMapping[k] || k);
const data = Object.values(logStats.value);
const colors = [
'#0d6efd', '#6610f2', '#6f42c1', '#d63384', '#dc3545',
'#fd7e14', '#ffc107', '#198754', '#20c997', '#0dcaf0',
'#adb5bd', '#6c757d'
];
chartInstance = new Chart(ctx, {
type: 'doughnut',
data: {
labels: labels,
datasets: [{
data: data,
backgroundColor: colors.slice(0, data.length),
borderWidth: 1
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: true,
position: 'right',
labels: {
boxWidth: 12,
font: {
size: 11
}
}
},
tooltip: {
callbacks: {
label: function(context) {
const label = context.label || '';
const value = context.raw || 0;
const total = context.dataset.data.reduce((a, b) => a + b, 0);
const percentage = Math.round((value / total) * 100) + '%';
return `${label}: ${value} (${percentage})`;
}
}
}
},
onClick: (evt, elements) => {
if (elements.length > 0) {
const index = elements[0].index;
const typeKey = Object.keys(logStats.value)[index];
selectedType.value = typeKey;
searchLogs();
}
}
}
});
};
const fetchLogStats = async () => {
try {
const response = await fetch('/api/logs/stats');
if (response.ok) {
const stats = await response.json();
// 过滤掉不需要显示的类型
const filteredStats = {};
const ignoreTypes = ['SetNextLayer', 'ChangeLayer'];
for (const key in stats) {
if (!ignoreTypes.includes(key)) {
filteredStats[key] = stats[key];
}
}
logStats.value = filteredStats;
setTimeout(initChart, 100);
}
} catch (error) {
console.error('Failed to fetch stats:', error);
}
};
const fetchLogs = async () => {
loading.value = true;
// 同时更新统计信息(如果是全部类型)
if (!selectedType.value) {
fetchLogStats();
}
try {
let url = `/api/logs/game-match?page=${currentPage.value}&pageSize=${pageSize.value}`;
if (selectedType.value) {
url += `&type=${encodeURIComponent(selectedType.value)}`;
}
if (searchQuery.value) {
url += `&search=${encodeURIComponent(searchQuery.value)}`;
}
const response = await fetch(url);
if (!response.ok) throw new Error('API Error');
const result = await response.json();
logs.value = result.items || [];
totalCount.value = result.total || 0;
} catch (error) {
console.error('Error:', error);
logs.value = [];
totalCount.value = 0;
} finally {
loading.value = false;
}
};
const searchLogs = () => {
currentPage.value = 1;
fetchLogs();
};
const handleTypeChange = () => {
searchLogs();
};
const resetFilters = () => {
selectedType.value = '';
searchQuery.value = '';
pageSize.value = 20;
searchLogs();
};
const changePage = (page) => {
if (page < 1 || page > totalPages.value) return;
currentPage.value = page;
fetchLogs();
};
const showDetail = (log) => {
currentLog.value = log;
if (detailModal) detailModal.show();
};
onMounted(() => {
detailModal = new bootstrap.Modal(document.getElementById('detailModal'));
fetchLogs();
});
return {
logs, loading, selectedType, searchQuery, currentPage, pageSize, totalCount,
logTypes, currentColumns, totalPages, currentLog, currentLogData,
searchLogs, handleTypeChange, resetFilters, changePage, showDetail,
getColumnValue, formatDate, translateField,
typeColumns, logTypeMapping, logStats
};
}
}).mount('#app');
</script>
</body>
</html>