mirror of
https://gitee.com/xiarenalofs/squad-rain-ops-mini.git
synced 2026-08-06 21:36:25 +08:00
708 lines
32 KiB
HTML
708 lines
32 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>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)">«</button>
|
||
</li>
|
||
<li class="page-item" :class="{ disabled: currentPage === 1 }">
|
||
<button class="page-link" @click="changePage(currentPage - 1)"><</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)">></button>
|
||
</li>
|
||
<li class="page-item" :class="{ disabled: currentPage === totalPages || totalPages === 0 }">
|
||
<button class="page-link" @click="changePage(totalPages)">»</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> |