feat: Add recent performance stability stats (matches/days) to player profile
This commit is contained in:
25
web/templates/tactics/analysis.html
Normal file
25
web/templates/tactics/analysis.html
Normal file
@@ -0,0 +1,25 @@
|
||||
{% extends "tactics/layout.html" %}
|
||||
|
||||
{% block title %}Deep Analysis - Tactics{% endblock %}
|
||||
|
||||
{% block tactics_content %}
|
||||
<div class="bg-white dark:bg-slate-800 shadow rounded-lg p-6">
|
||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-4">Deep Analysis: Chemistry & Depth</h2>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
<!-- Lineup Selector (Placeholder) -->
|
||||
<div class="border-2 border-dashed border-gray-300 dark:border-slate-600 rounded-lg p-8 flex flex-col items-center justify-center text-center">
|
||||
<svg class="w-12 h-12 text-gray-400 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"></path></svg>
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-white">Lineup Builder</h3>
|
||||
<p class="text-gray-500 dark:text-gray-400">Drag 5 players here to analyze chemistry.</p>
|
||||
</div>
|
||||
|
||||
<!-- Synergy Matrix (Placeholder) -->
|
||||
<div class="border-2 border-dashed border-gray-300 dark:border-slate-600 rounded-lg p-8 flex flex-col items-center justify-center text-center">
|
||||
<svg class="w-12 h-12 text-gray-400 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4"></path></svg>
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-white">Synergy Matrix</h3>
|
||||
<p class="text-gray-500 dark:text-gray-400">Select lineup to view pair-wise win rates.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
396
web/templates/tactics/board.html
Normal file
396
web/templates/tactics/board.html
Normal file
@@ -0,0 +1,396 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Strategy Board - Tactics{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
<!-- Leaflet CSS -->
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin=""/>
|
||||
<style>
|
||||
.player-token {
|
||||
cursor: grab;
|
||||
transition: transform 0.1s;
|
||||
}
|
||||
.player-token:active {
|
||||
cursor: grabbing;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
#map-container {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background-color: #1a1a1a;
|
||||
z-index: 1;
|
||||
}
|
||||
.leaflet-container {
|
||||
background: #1a1a1a;
|
||||
}
|
||||
.custom-scroll::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
.custom-scroll::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.custom-scroll::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(156, 163, 175, 0.5);
|
||||
border-radius: 20px;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="flex flex-col h-[calc(100vh-4rem)]">
|
||||
|
||||
<!-- Navigation (Compact) -->
|
||||
<div class="bg-white dark:bg-slate-800 border-b border-gray-200 dark:border-slate-700 px-4 py-2 flex items-center justify-between shrink-0 z-30 shadow-sm">
|
||||
<div class="flex space-x-6 text-sm font-medium">
|
||||
<a href="{{ url_for('tactics.index') }}" class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-white">← Dashboard</a>
|
||||
<a href="{{ url_for('tactics.analysis') }}" class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-white">Deep Analysis</a>
|
||||
<a href="{{ url_for('tactics.data') }}" class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-white">Data Center</a>
|
||||
<span class="text-yrtv-600 dark:text-yrtv-400 border-b-2 border-yrtv-500">Strategy Board</span>
|
||||
<a href="{{ url_for('tactics.economy') }}" class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-white">Economy</a>
|
||||
</div>
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">
|
||||
Real-time Sync: <span class="text-green-500">● Active</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Board Area -->
|
||||
<div class="flex flex-1 overflow-hidden" x-data="tacticsBoard()">
|
||||
|
||||
<!-- Left Sidebar: Controls & Roster -->
|
||||
<div class="w-72 flex flex-col bg-white dark:bg-slate-800 border-r border-gray-200 dark:border-slate-700 shadow-xl z-20">
|
||||
|
||||
<!-- Map Select -->
|
||||
<div class="p-4 border-b border-gray-200 dark:border-slate-700">
|
||||
<div class="flex space-x-2 mb-2">
|
||||
<select x-model="currentMap" @change="changeMap()" class="flex-1 rounded border-gray-300 dark:bg-slate-700 dark:border-slate-600 dark:text-white text-sm">
|
||||
<option value="de_mirage">Mirage</option>
|
||||
<option value="de_inferno">Inferno</option>
|
||||
<option value="de_dust2">Dust 2</option>
|
||||
<option value="de_nuke">Nuke</option>
|
||||
<option value="de_ancient">Ancient</option>
|
||||
<option value="de_anubis">Anubis</option>
|
||||
<option value="de_vertigo">Vertigo</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex space-x-2">
|
||||
<button @click="saveBoard()" class="flex-1 px-3 py-1.5 bg-yrtv-600 text-white rounded hover:bg-yrtv-700 text-xs font-medium">Save Snapshot</button>
|
||||
<button @click="clearBoard()" class="px-3 py-1.5 bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400 rounded hover:bg-red-200 dark:hover:bg-red-900/50 text-xs font-medium">Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scrollable Content -->
|
||||
<div class="flex-1 overflow-y-auto custom-scroll p-4 space-y-6">
|
||||
|
||||
<!-- Roster (Draggable) -->
|
||||
<div>
|
||||
<h3 class="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-3">Roster</h3>
|
||||
<div class="space-y-2">
|
||||
<template x-for="player in roster" :key="player.steam_id_64">
|
||||
<div class="player-token group flex items-center p-2 rounded-lg border border-transparent hover:bg-gray-50 dark:hover:bg-slate-700 hover:border-gray-200 dark:hover:border-slate-600 transition select-none cursor-grab active:cursor-grabbing"
|
||||
:data-id="player.steam_id_64"
|
||||
draggable="true"
|
||||
@dragstart="dragStart($event, player)">
|
||||
|
||||
<img :src="player.avatar_url || 'https://avatars.steamstatic.com/fef49e7fa7e1997310d705b2a6158ff8dc1cdfeb_full.jpg'"
|
||||
class="w-8 h-8 rounded-full border border-gray-200 dark:border-slate-600 object-cover pointer-events-none">
|
||||
|
||||
<div class="ml-3 flex-1 min-w-0 pointer-events-none">
|
||||
<div class="text-xs font-medium text-gray-900 dark:text-white truncate" x-text="player.username || player.name"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="roster.length === 0">
|
||||
<div class="text-xs text-gray-500 text-center py-4 border-2 border-dashed border-gray-200 dark:border-slate-700 rounded-lg">
|
||||
No players found.
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Active Players List -->
|
||||
<div x-show="activePlayers.length > 0">
|
||||
<h3 class="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-3 flex justify-between items-center">
|
||||
<span>On Board</span>
|
||||
<span class="text-xs bg-yrtv-100 text-yrtv-800 dark:bg-yrtv-900 dark:text-yrtv-300 px-2 py-0.5 rounded-full" x-text="activePlayers.length"></span>
|
||||
</h3>
|
||||
<ul class="space-y-1">
|
||||
<template x-for="p in activePlayers" :key="p.id">
|
||||
<li class="flex items-center justify-between p-2 rounded bg-gray-50 dark:bg-slate-700/50">
|
||||
<span class="text-xs text-gray-700 dark:text-gray-300 truncate" x-text="p.username || p.name"></span>
|
||||
<button @click="removeMarker(p.id)" class="text-gray-400 hover:text-red-500 transition">×</button>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Radar Chart -->
|
||||
<div class="pt-4 border-t border-gray-200 dark:border-slate-700">
|
||||
<h3 class="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-2">Synergy</h3>
|
||||
<div class="relative h-40 w-full">
|
||||
<canvas id="tacticRadar"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Map Area -->
|
||||
<div class="flex-1 relative bg-gray-900" id="map-dropzone" @dragover.prevent @drop="dropOnMap($event)">
|
||||
<div id="map-container"></div>
|
||||
|
||||
<div class="absolute bottom-4 right-4 z-[400] bg-black/50 backdrop-blur text-white text-[10px] px-2 py-1 rounded pointer-events-none">
|
||||
Drag players to map • Scroll to zoom
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scripts -->
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
|
||||
<script>
|
||||
function tacticsBoard() {
|
||||
return {
|
||||
roster: [],
|
||||
currentMap: 'de_mirage',
|
||||
map: null,
|
||||
markers: {}, // id -> marker
|
||||
activePlayers: [], // list of {id, name, stats}
|
||||
radarChart: null,
|
||||
|
||||
init() {
|
||||
this.fetchRoster();
|
||||
this.initMap();
|
||||
this.initRadar();
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
if (this.map) this.map.invalidateSize();
|
||||
});
|
||||
},
|
||||
|
||||
fetchRoster() {
|
||||
fetch('/teams/api/roster')
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
this.roster = data.roster || [];
|
||||
});
|
||||
},
|
||||
|
||||
initMap() {
|
||||
this.map = L.map('map-container', {
|
||||
crs: L.CRS.Simple,
|
||||
minZoom: -2,
|
||||
maxZoom: 2,
|
||||
zoomControl: true,
|
||||
attributionControl: false
|
||||
});
|
||||
|
||||
this.loadMapImage();
|
||||
},
|
||||
|
||||
loadMapImage() {
|
||||
const mapUrls = {
|
||||
'de_mirage': 'https://static.wikia.nocookie.net/cswikia/images/e/e3/Mirage_CS2_Radar.png',
|
||||
'de_inferno': 'https://static.wikia.nocookie.net/cswikia/images/7/77/Inferno_CS2_Radar.png',
|
||||
'de_dust2': 'https://static.wikia.nocookie.net/cswikia/images/0/03/Dust2_CS2_Radar.png',
|
||||
'de_nuke': 'https://static.wikia.nocookie.net/cswikia/images/1/14/Nuke_CS2_Radar.png',
|
||||
'de_ancient': 'https://static.wikia.nocookie.net/cswikia/images/1/16/Ancient_CS2_Radar.png',
|
||||
'de_anubis': 'https://static.wikia.nocookie.net/cswikia/images/2/22/Anubis_CS2_Radar.png',
|
||||
'de_vertigo': 'https://static.wikia.nocookie.net/cswikia/images/2/23/Vertigo_CS2_Radar.png'
|
||||
};
|
||||
|
||||
const url = mapUrls[this.currentMap] || mapUrls['de_mirage'];
|
||||
const bounds = [[0,0], [1024,1024]];
|
||||
|
||||
this.map.eachLayer((layer) => {
|
||||
this.map.removeLayer(layer);
|
||||
});
|
||||
|
||||
L.imageOverlay(url, bounds).addTo(this.map);
|
||||
this.map.fitBounds(bounds);
|
||||
},
|
||||
|
||||
changeMap() {
|
||||
this.loadMapImage();
|
||||
this.clearBoard();
|
||||
},
|
||||
|
||||
dragStart(event, player) {
|
||||
event.dataTransfer.setData('text/plain', JSON.stringify(player));
|
||||
event.dataTransfer.effectAllowed = 'copy';
|
||||
},
|
||||
|
||||
dropOnMap(event) {
|
||||
const data = event.dataTransfer.getData('text/plain');
|
||||
if (!data) return;
|
||||
|
||||
try {
|
||||
const player = JSON.parse(data);
|
||||
const container = document.getElementById('map-container');
|
||||
const rect = container.getBoundingClientRect();
|
||||
|
||||
const x = event.clientX - rect.left;
|
||||
const y = event.clientY - rect.top;
|
||||
|
||||
const point = this.map.containerPointToLatLng([x, y]);
|
||||
|
||||
this.addMarker(player, point);
|
||||
} catch (e) {
|
||||
console.error("Drop failed:", e);
|
||||
}
|
||||
},
|
||||
|
||||
addMarker(player, latlng) {
|
||||
if (this.markers[player.steam_id_64]) {
|
||||
this.markers[player.steam_id_64].setLatLng(latlng);
|
||||
} else {
|
||||
const displayName = player.username || player.name || player.steam_id_64;
|
||||
|
||||
const iconHtml = `
|
||||
<div class="flex flex-col items-center justify-center transform hover:scale-110 transition duration-200">
|
||||
<img src="${player.avatar_url || 'https://avatars.steamstatic.com/fef49e7fa7e1997310d705b2a6158ff8dc1cdfeb_full.jpg'}"
|
||||
class="w-10 h-10 rounded-full border-2 border-white shadow-lg box-content">
|
||||
<span class="mt-1 text-[10px] font-bold text-white bg-black/60 px-1.5 py-0.5 rounded backdrop-blur-sm whitespace-nowrap overflow-hidden max-w-[80px] text-ellipsis">
|
||||
${displayName}
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const icon = L.divIcon({
|
||||
className: 'bg-transparent',
|
||||
html: iconHtml,
|
||||
iconSize: [60, 60],
|
||||
iconAnchor: [30, 30]
|
||||
});
|
||||
|
||||
const marker = L.marker(latlng, { icon: icon, draggable: true }).addTo(this.map);
|
||||
this.markers[player.steam_id_64] = marker;
|
||||
|
||||
this.activePlayers.push({
|
||||
id: player.steam_id_64,
|
||||
username: player.username,
|
||||
name: player.name,
|
||||
stats: player.stats
|
||||
});
|
||||
|
||||
this.updateRadar();
|
||||
}
|
||||
},
|
||||
|
||||
removeMarker(id) {
|
||||
if (this.markers[id]) {
|
||||
this.map.removeLayer(this.markers[id]);
|
||||
delete this.markers[id];
|
||||
this.activePlayers = this.activePlayers.filter(p => p.id !== id);
|
||||
this.updateRadar();
|
||||
}
|
||||
},
|
||||
|
||||
clearBoard() {
|
||||
for (let id in this.markers) {
|
||||
this.map.removeLayer(this.markers[id]);
|
||||
}
|
||||
this.markers = {};
|
||||
this.activePlayers = [];
|
||||
this.updateRadar();
|
||||
},
|
||||
|
||||
saveBoard() {
|
||||
const title = prompt("Enter a title for this strategy:", "New Strat " + new Date().toLocaleTimeString());
|
||||
if (!title) return;
|
||||
|
||||
const markerData = [];
|
||||
for (let id in this.markers) {
|
||||
const m = this.markers[id];
|
||||
markerData.push({
|
||||
id: id,
|
||||
lat: m.getLatLng().lat,
|
||||
lng: m.getLatLng().lng
|
||||
});
|
||||
}
|
||||
|
||||
fetch("{{ url_for('tactics.save_board') }}", {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title: title,
|
||||
map_name: this.currentMap,
|
||||
markers: markerData
|
||||
})
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if(data.success) alert("Saved!");
|
||||
else alert("Error: " + data.message);
|
||||
});
|
||||
},
|
||||
|
||||
initRadar() {
|
||||
const ctx = document.getElementById('tacticRadar').getContext('2d');
|
||||
Chart.defaults.color = '#9ca3af';
|
||||
Chart.defaults.borderColor = '#374151';
|
||||
|
||||
this.radarChart = new Chart(ctx, {
|
||||
type: 'radar',
|
||||
data: {
|
||||
labels: ['RTG', 'K/D', 'KST', 'ADR', 'IMP', 'UTL'],
|
||||
datasets: [{
|
||||
label: 'Avg',
|
||||
data: [0, 0, 0, 0, 0, 0],
|
||||
backgroundColor: 'rgba(139, 92, 246, 0.2)',
|
||||
borderColor: 'rgba(139, 92, 246, 1)',
|
||||
pointBackgroundColor: 'rgba(139, 92, 246, 1)',
|
||||
borderWidth: 1,
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: {
|
||||
r: {
|
||||
beginAtZero: true,
|
||||
max: 1.5,
|
||||
grid: { color: 'rgba(156, 163, 175, 0.1)' },
|
||||
angleLines: { color: 'rgba(156, 163, 175, 0.1)' },
|
||||
pointLabels: { font: { size: 9 } },
|
||||
ticks: { display: false }
|
||||
}
|
||||
},
|
||||
plugins: { legend: { display: false } }
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
updateRadar() {
|
||||
if (this.activePlayers.length === 0) {
|
||||
this.radarChart.data.datasets[0].data = [0, 0, 0, 0, 0, 0];
|
||||
this.radarChart.update();
|
||||
return;
|
||||
}
|
||||
|
||||
let totals = [0, 0, 0, 0, 0, 0];
|
||||
this.activePlayers.forEach(p => {
|
||||
const s = p.stats || {};
|
||||
totals[0] += s.basic_avg_rating || 0;
|
||||
totals[1] += s.basic_avg_kd || 0;
|
||||
totals[2] += s.basic_avg_kast || 0;
|
||||
totals[3] += (s.basic_avg_adr || 0) / 100;
|
||||
totals[4] += s.bat_avg_impact || 1.0;
|
||||
totals[5] += s.util_usage_rate || 0.5;
|
||||
});
|
||||
|
||||
const count = this.activePlayers.length;
|
||||
const avgs = totals.map(t => t / count);
|
||||
|
||||
this.radarChart.data.datasets[0].data = avgs;
|
||||
this.radarChart.update();
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
161
web/templates/tactics/compare.html
Normal file
161
web/templates/tactics/compare.html
Normal file
@@ -0,0 +1,161 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
<div class="bg-white dark:bg-slate-800 shadow rounded-lg p-6">
|
||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-6">数据对比中心 (Data Center)</h2>
|
||||
|
||||
<!-- Search & Add -->
|
||||
<div class="mb-6 relative">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">添加对比玩家</label>
|
||||
<input type="text" id="playerSearch" placeholder="输入 ID 或昵称搜索..." class="w-full border border-gray-300 rounded-md py-2 px-4 dark:bg-slate-700 dark:text-white">
|
||||
<div id="searchResults" class="absolute z-10 w-full bg-white dark:bg-slate-700 shadow-lg rounded-b-md hidden"></div>
|
||||
</div>
|
||||
|
||||
<!-- Selected Players Tags -->
|
||||
<div id="selectedPlayers" class="flex flex-wrap gap-2 mb-6">
|
||||
<!-- Tags will be injected here -->
|
||||
</div>
|
||||
|
||||
<!-- Chart -->
|
||||
<div class="relative h-96">
|
||||
<canvas id="compareChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const searchInput = document.getElementById('playerSearch');
|
||||
const resultsDiv = document.getElementById('searchResults');
|
||||
const selectedDiv = document.getElementById('selectedPlayers');
|
||||
|
||||
let selectedIds = [];
|
||||
let chartInstance = null;
|
||||
|
||||
// Init Chart
|
||||
const ctx = document.getElementById('compareChart').getContext('2d');
|
||||
chartInstance = new Chart(ctx, {
|
||||
type: 'radar',
|
||||
data: {
|
||||
labels: ['STA', 'BAT', 'HPS', 'PTL', 'SIDE', 'UTIL'],
|
||||
datasets: []
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: {
|
||||
r: {
|
||||
beginAtZero: true,
|
||||
suggestedMax: 2.0
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Search
|
||||
let debounceTimer;
|
||||
searchInput.addEventListener('input', function() {
|
||||
clearTimeout(debounceTimer);
|
||||
const query = this.value;
|
||||
if (query.length < 2) {
|
||||
resultsDiv.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
debounceTimer = setTimeout(() => {
|
||||
fetch(`/players/api/search?q=${query}`)
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
resultsDiv.innerHTML = '';
|
||||
if (data.length > 0) {
|
||||
resultsDiv.classList.remove('hidden');
|
||||
data.forEach(p => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'p-2 hover:bg-gray-100 dark:hover:bg-slate-600 cursor-pointer text-gray-900 dark:text-white';
|
||||
div.innerText = `${p.username} (${p.steam_id})`;
|
||||
div.onclick = () => addPlayer(p);
|
||||
resultsDiv.appendChild(div);
|
||||
});
|
||||
} else {
|
||||
resultsDiv.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
}, 300);
|
||||
});
|
||||
|
||||
// Hide results on click outside
|
||||
document.addEventListener('click', function(e) {
|
||||
if (!searchInput.contains(e.target) && !resultsDiv.contains(e.target)) {
|
||||
resultsDiv.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
function addPlayer(player) {
|
||||
if (selectedIds.includes(player.steam_id)) return;
|
||||
selectedIds.push(player.steam_id);
|
||||
|
||||
// Add Tag
|
||||
const tag = document.createElement('span');
|
||||
tag.className = 'inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-yrtv-100 text-yrtv-800';
|
||||
tag.innerHTML = `
|
||||
${player.username}
|
||||
<button type="button" class="flex-shrink-0 ml-1.5 h-4 w-4 rounded-full inline-flex items-center justify-center text-yrtv-400 hover:bg-yrtv-200 hover:text-yrtv-500 focus:outline-none" onclick="removePlayer('${player.steam_id}', this)">
|
||||
<span class="sr-only">Remove</span>
|
||||
×
|
||||
</button>
|
||||
`;
|
||||
selectedDiv.appendChild(tag);
|
||||
|
||||
// Fetch Stats and Update Chart
|
||||
updateChart();
|
||||
|
||||
searchInput.value = '';
|
||||
resultsDiv.classList.add('hidden');
|
||||
}
|
||||
|
||||
window.removePlayer = function(id, btn) {
|
||||
selectedIds = selectedIds.filter(sid => sid !== id);
|
||||
btn.parentElement.remove();
|
||||
updateChart();
|
||||
}
|
||||
|
||||
function updateChart() {
|
||||
if (selectedIds.length === 0) {
|
||||
chartInstance.data.datasets = [];
|
||||
chartInstance.update();
|
||||
return;
|
||||
}
|
||||
|
||||
const ids = selectedIds.join(',');
|
||||
fetch(`/players/api/batch_stats?ids=${ids}`)
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
const datasets = data.map((p, index) => {
|
||||
const colors = [
|
||||
'rgba(124, 58, 237, 1)', 'rgba(16, 185, 129, 1)', 'rgba(239, 68, 68, 1)',
|
||||
'rgba(59, 130, 246, 1)', 'rgba(245, 158, 11, 1)'
|
||||
];
|
||||
const color = colors[index % colors.length];
|
||||
|
||||
return {
|
||||
label: p.username,
|
||||
data: [
|
||||
p.radar.STA, p.radar.BAT, p.radar.HPS,
|
||||
p.radar.PTL, p.radar.SIDE, p.radar.UTIL
|
||||
],
|
||||
backgroundColor: color.replace('1)', '0.2)'),
|
||||
borderColor: color,
|
||||
pointBackgroundColor: color
|
||||
};
|
||||
});
|
||||
|
||||
chartInstance.data.datasets = datasets;
|
||||
chartInstance.update();
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
355
web/templates/tactics/data.html
Normal file
355
web/templates/tactics/data.html
Normal file
@@ -0,0 +1,355 @@
|
||||
<!-- Data Center Tab Content -->
|
||||
<div x-show="activeTab === 'data'" class="space-y-6 h-full flex flex-col">
|
||||
<!-- Header / Controls -->
|
||||
<div class="flex justify-between items-center bg-white dark:bg-slate-800 p-4 rounded-xl shadow-sm border border-gray-200 dark:border-slate-700">
|
||||
<div>
|
||||
<h3 class="text-xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<span>📊</span> 数据对比中心 (Data Comparison)
|
||||
</h3>
|
||||
<p class="text-xs text-gray-500 mt-1">拖拽左侧队员至下方区域,或点击搜索添加</p>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<div class="relative">
|
||||
<input type="text" x-model="searchQuery" @keydown.enter="searchPlayer()" placeholder="Search Player..." class="pl-3 pr-8 py-2 border border-gray-300 dark:border-slate-600 rounded-lg text-sm bg-gray-50 dark:bg-slate-900 dark:text-white focus:ring-2 focus:ring-yrtv-500">
|
||||
<button @click="searchPlayer()" class="absolute right-2 top-2 text-gray-400 hover:text-yrtv-600">🔍</button>
|
||||
</div>
|
||||
<button @click="clearDataLineup()" class="px-4 py-2 bg-red-50 text-red-600 rounded-lg hover:bg-red-100 text-sm font-bold transition">清空</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content Grid -->
|
||||
<div class="flex-1 grid grid-cols-1 lg:grid-cols-4 gap-6 min-h-0">
|
||||
|
||||
<!-- Left: Selected Players (Drop Zone) -->
|
||||
<div class="lg:col-span-1 bg-white dark:bg-slate-800 rounded-xl shadow-lg border border-gray-100 dark:border-slate-700 flex flex-col overflow-hidden transition-colors duration-200"
|
||||
:class="{'border-yrtv-400 bg-yrtv-50 dark:bg-slate-700 ring-2 ring-yrtv-200': isDraggingOverData}"
|
||||
@dragover.prevent="isDraggingOverData = true"
|
||||
@dragleave="isDraggingOverData = false"
|
||||
@drop="dropData($event)">
|
||||
|
||||
<div class="p-4 border-b border-gray-100 dark:border-slate-700 bg-gray-50 dark:bg-slate-700/50">
|
||||
<h4 class="font-bold text-gray-700 dark:text-gray-200 flex justify-between">
|
||||
<span>对比列表</span>
|
||||
<span class="text-xs bg-yrtv-100 text-yrtv-700 px-2 py-0.5 rounded-full" x-text="dataLineup.length + '/5'">0/5</span>
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 p-4 space-y-3 overflow-y-auto custom-scroll min-h-[100px]">
|
||||
|
||||
<template x-for="(p, idx) in dataLineup" :key="p.steam_id_64">
|
||||
<div class="flex items-center p-3 bg-white dark:bg-slate-700 border border-gray-200 dark:border-slate-600 rounded-xl shadow-sm group hover:border-yrtv-300 transition relative">
|
||||
<!-- Color Indicator -->
|
||||
<div class="w-1.5 h-full absolute left-0 top-0 rounded-l-xl" :style="'background-color: ' + getPlayerColor(idx)"></div>
|
||||
|
||||
<div class="ml-3 flex-shrink-0">
|
||||
<template x-if="p.avatar_url">
|
||||
<img :src="p.avatar_url" class="w-10 h-10 rounded-full object-cover border border-gray-200 dark:border-slate-500">
|
||||
</template>
|
||||
<template x-if="!p.avatar_url">
|
||||
<div class="w-10 h-10 rounded-full bg-gray-100 flex items-center justify-center text-gray-500 font-bold text-xs">
|
||||
<span x-text="(p.username || p.name).substring(0,2).toUpperCase()"></span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="ml-3 flex-1 min-w-0">
|
||||
<div class="text-sm font-bold text-gray-900 dark:text-white truncate" x-text="p.username || p.name"></div>
|
||||
<div class="text-xs text-gray-500 font-mono truncate" x-text="p.steam_id_64"></div>
|
||||
</div>
|
||||
<button @click="removeFromDataLineup(idx)" class="text-gray-400 hover:text-red-500 p-1 opacity-0 group-hover:opacity-100 transition">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="dataLineup.length < 5">
|
||||
<div class="h-24 border-2 border-dashed border-gray-200 dark:border-slate-600 rounded-xl flex flex-col items-center justify-center text-gray-400 text-sm hover:bg-gray-50 dark:hover:bg-slate-800 transition cursor-default"
|
||||
:class="{'border-yrtv-400 text-yrtv-600 bg-white': isDraggingOverData}">
|
||||
<span>+ 拖拽或搜索添加</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: Visualization (Scrollable) -->
|
||||
<div class="lg:col-span-3 space-y-6 overflow-y-auto custom-scroll pr-2">
|
||||
|
||||
<!-- 1. Radar & Key Stats -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<!-- Radar Chart -->
|
||||
<div class="bg-white dark:bg-slate-800 p-6 rounded-xl shadow-lg border border-gray-100 dark:border-slate-700 min-h-[400px] flex flex-col">
|
||||
<h4 class="font-bold text-gray-800 dark:text-gray-200 mb-4">能力模型对比 (Capability Radar)</h4>
|
||||
<div class="flex-1 relative">
|
||||
<canvas id="dataRadarChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Basic Stats Table -->
|
||||
<div class="bg-white dark:bg-slate-800 p-6 rounded-xl shadow-lg border border-gray-100 dark:border-slate-700 flex flex-col">
|
||||
<h4 class="font-bold text-gray-800 dark:text-gray-200 mb-4">基础数据 (Basic Stats)</h4>
|
||||
<div class="flex-1 overflow-x-auto">
|
||||
<table class="min-w-full text-sm">
|
||||
<thead>
|
||||
<tr class="text-gray-500 border-b border-gray-100 dark:border-slate-700">
|
||||
<th class="py-2 text-left">Player</th>
|
||||
<th class="py-2 text-right">Rating</th>
|
||||
<th class="py-2 text-right">K/D</th>
|
||||
<th class="py-2 text-right">ADR</th>
|
||||
<th class="py-2 text-right">KAST</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100 dark:divide-slate-700">
|
||||
<template x-for="(stat, idx) in dataResult" :key="stat.steam_id">
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700/50">
|
||||
<td class="py-3 flex items-center gap-2">
|
||||
<div class="w-3 h-3 rounded-full" :style="'background-color: ' + getPlayerColor(idx)"></div>
|
||||
<span class="font-bold dark:text-white truncate max-w-[100px]" x-text="stat.username"></span>
|
||||
</td>
|
||||
<td class="py-3 text-right font-mono font-bold" :class="getRatingColor(stat.basic.rating)" x-text="stat.basic.rating.toFixed(2)"></td>
|
||||
<td class="py-3 text-right font-mono" x-text="stat.basic.kd.toFixed(2)"></td>
|
||||
<td class="py-3 text-right font-mono" x-text="stat.basic.adr.toFixed(1)"></td>
|
||||
<td class="py-3 text-right font-mono" x-text="(stat.basic.kast * 100).toFixed(1) + '%'"></td>
|
||||
</tr>
|
||||
</template>
|
||||
<template x-if="!dataResult || dataResult.length === 0">
|
||||
<tr><td colspan="5" class="py-8 text-center text-gray-400">请选择选手进行对比</td></tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 2. Detailed Breakdown (New) -->
|
||||
<div class="bg-white dark:bg-slate-800 p-6 rounded-xl shadow-lg border border-gray-100 dark:border-slate-700">
|
||||
<h4 class="font-bold text-gray-800 dark:text-gray-200 mb-6">详细数据对比 (Detailed Stats)</h4>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full text-sm">
|
||||
<thead>
|
||||
<tr class="bg-gray-50 dark:bg-slate-700/50 text-gray-500">
|
||||
<th class="px-4 py-3 text-left rounded-l-lg">Metric</th>
|
||||
<template x-for="(stat, idx) in dataResult" :key="'dh-'+stat.steam_id">
|
||||
<th class="px-4 py-3 text-center" :class="{'rounded-r-lg': idx === dataResult.length-1}">
|
||||
<span class="border-b-2 px-1 font-bold dark:text-gray-300" :style="'border-color: ' + getPlayerColor(idx)" x-text="stat.username"></span>
|
||||
</th>
|
||||
</template>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100 dark:divide-slate-700">
|
||||
<!-- Row 1 -->
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700/30">
|
||||
<td class="px-4 py-2 font-medium text-gray-600 dark:text-gray-400">Rating (Rating/KD)</td>
|
||||
<template x-for="stat in dataResult">
|
||||
<td class="px-4 py-2 text-center font-mono text-xs">
|
||||
<div class="flex flex-col">
|
||||
<div class="flex justify-between w-full max-w-[120px] mx-auto">
|
||||
<span class="text-amber-600 dark:text-amber-400 font-bold" x-text="stat.detailed.rating_t.toFixed(2)"></span>
|
||||
<span class="text-blue-600 dark:text-blue-400 font-bold" x-text="stat.detailed.rating_ct.toFixed(2)"></span>
|
||||
</div>
|
||||
<div class="flex justify-between w-full max-w-[120px] mx-auto text-[10px] text-gray-400">
|
||||
<span>T-Side</span><span>CT-Side</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</template>
|
||||
</tr>
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700/30">
|
||||
<td class="px-4 py-2 font-medium text-gray-600 dark:text-gray-400">KD Ratio</td>
|
||||
<template x-for="stat in dataResult">
|
||||
<td class="px-4 py-2 text-center font-mono text-xs">
|
||||
<div class="flex flex-col">
|
||||
<div class="flex justify-between w-full max-w-[120px] mx-auto">
|
||||
<span class="text-amber-600 dark:text-amber-400" x-text="stat.detailed.kd_t.toFixed(2)"></span>
|
||||
<span class="text-blue-600 dark:text-blue-400" x-text="stat.detailed.kd_ct.toFixed(2)"></span>
|
||||
</div>
|
||||
<div class="flex justify-between w-full max-w-[120px] mx-auto text-[10px] text-gray-400">
|
||||
<span>T-Side</span><span>CT-Side</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</template>
|
||||
</tr>
|
||||
|
||||
<!-- Row 2 -->
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700/30">
|
||||
<td class="px-4 py-2 font-medium text-gray-600 dark:text-gray-400">Win Rate (胜率)</td>
|
||||
<template x-for="stat in dataResult">
|
||||
<td class="px-4 py-2 text-center font-mono text-xs">
|
||||
<div class="flex flex-col">
|
||||
<div class="flex justify-between w-full max-w-[120px] mx-auto">
|
||||
<span class="text-amber-600 dark:text-amber-400" x-text="(stat.detailed.win_rate_t * 100).toFixed(1) + '%'"></span>
|
||||
<span class="text-blue-600 dark:text-blue-400" x-text="(stat.detailed.win_rate_ct * 100).toFixed(1) + '%'"></span>
|
||||
</div>
|
||||
<div class="flex justify-between w-full max-w-[120px] mx-auto text-[10px] text-gray-400">
|
||||
<span>T-Side</span><span>CT-Side</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</template>
|
||||
</tr>
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700/30">
|
||||
<td class="px-4 py-2 font-medium text-gray-600 dark:text-gray-400">First Kill Rate (首杀率)</td>
|
||||
<template x-for="stat in dataResult">
|
||||
<td class="px-4 py-2 text-center font-mono text-xs">
|
||||
<div class="flex flex-col">
|
||||
<div class="flex justify-between w-full max-w-[120px] mx-auto">
|
||||
<span class="text-amber-600 dark:text-amber-400" x-text="(stat.detailed.first_kill_t * 100).toFixed(1) + '%'"></span>
|
||||
<span class="text-blue-600 dark:text-blue-400" x-text="(stat.detailed.first_kill_ct * 100).toFixed(1) + '%'"></span>
|
||||
</div>
|
||||
<div class="flex justify-between w-full max-w-[120px] mx-auto text-[10px] text-gray-400">
|
||||
<span>T-Side</span><span>CT-Side</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</template>
|
||||
</tr>
|
||||
|
||||
<!-- Row 3 -->
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700/30">
|
||||
<td class="px-4 py-2 font-medium text-gray-600 dark:text-gray-400">First Death Rate (首死率)</td>
|
||||
<template x-for="stat in dataResult">
|
||||
<td class="px-4 py-2 text-center font-mono text-xs">
|
||||
<div class="flex flex-col">
|
||||
<div class="flex justify-between w-full max-w-[120px] mx-auto">
|
||||
<span class="text-amber-600 dark:text-amber-400" x-text="(stat.detailed.first_death_t * 100).toFixed(1) + '%'"></span>
|
||||
<span class="text-blue-600 dark:text-blue-400" x-text="(stat.detailed.first_death_ct * 100).toFixed(1) + '%'"></span>
|
||||
</div>
|
||||
<div class="flex justify-between w-full max-w-[120px] mx-auto text-[10px] text-gray-400">
|
||||
<span>T-Side</span><span>CT-Side</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</template>
|
||||
</tr>
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700/30">
|
||||
<td class="px-4 py-2 font-medium text-gray-600 dark:text-gray-400">KAST (贡献率)</td>
|
||||
<template x-for="stat in dataResult">
|
||||
<td class="px-4 py-2 text-center font-mono text-xs">
|
||||
<div class="flex flex-col">
|
||||
<div class="flex justify-between w-full max-w-[120px] mx-auto">
|
||||
<span class="text-amber-600 dark:text-amber-400" x-text="(stat.detailed.kast_t * 100).toFixed(1) + '%'"></span>
|
||||
<span class="text-blue-600 dark:text-blue-400" x-text="(stat.detailed.kast_ct * 100).toFixed(1) + '%'"></span>
|
||||
</div>
|
||||
<div class="flex justify-between w-full max-w-[120px] mx-auto text-[10px] text-gray-400">
|
||||
<span>T-Side</span><span>CT-Side</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</template>
|
||||
</tr>
|
||||
|
||||
<!-- Row 4 -->
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700/30">
|
||||
<td class="px-4 py-2 font-medium text-gray-600 dark:text-gray-400">RWS (Round Win Share)</td>
|
||||
<template x-for="stat in dataResult">
|
||||
<td class="px-4 py-2 text-center font-mono text-xs">
|
||||
<div class="flex flex-col">
|
||||
<div class="flex justify-between w-full max-w-[120px] mx-auto">
|
||||
<span class="text-amber-600 dark:text-amber-400" x-text="stat.detailed.rws_t.toFixed(2)"></span>
|
||||
<span class="text-blue-600 dark:text-blue-400" x-text="stat.detailed.rws_ct.toFixed(2)"></span>
|
||||
</div>
|
||||
<div class="flex justify-between w-full max-w-[120px] mx-auto text-[10px] text-gray-400">
|
||||
<span>T-Side</span><span>CT-Side</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</template>
|
||||
</tr>
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700/30">
|
||||
<td class="px-4 py-2 font-medium text-gray-600 dark:text-gray-400">Multi-Kill Rate (多杀率)</td>
|
||||
<template x-for="stat in dataResult">
|
||||
<td class="px-4 py-2 text-center font-mono text-xs">
|
||||
<div class="flex flex-col">
|
||||
<div class="flex justify-between w-full max-w-[120px] mx-auto">
|
||||
<span class="text-amber-600 dark:text-amber-400" x-text="(stat.detailed.multikill_t * 100).toFixed(1) + '%'"></span>
|
||||
<span class="text-blue-600 dark:text-blue-400" x-text="(stat.detailed.multikill_ct * 100).toFixed(1) + '%'"></span>
|
||||
</div>
|
||||
<div class="flex justify-between w-full max-w-[120px] mx-auto text-[10px] text-gray-400">
|
||||
<span>T-Side</span><span>CT-Side</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</template>
|
||||
</tr>
|
||||
|
||||
<!-- Row 5 -->
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700/30">
|
||||
<td class="px-4 py-2 font-medium text-gray-600 dark:text-gray-400">Headshot Rate (爆头率)</td>
|
||||
<template x-for="stat in dataResult">
|
||||
<td class="px-4 py-2 text-center font-mono text-xs">
|
||||
<div class="flex flex-col">
|
||||
<div class="flex justify-between w-full max-w-[120px] mx-auto">
|
||||
<span class="text-amber-600 dark:text-amber-400" x-text="(stat.detailed.hs_t * 100).toFixed(1) + '%'"></span>
|
||||
<span class="text-blue-600 dark:text-blue-400" x-text="(stat.detailed.hs_ct * 100).toFixed(1) + '%'"></span>
|
||||
</div>
|
||||
<div class="flex justify-between w-full max-w-[120px] mx-auto text-[10px] text-gray-400">
|
||||
<span>T-Side</span><span>CT-Side</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</template>
|
||||
</tr>
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700/30">
|
||||
<td class="px-4 py-2 font-medium text-gray-600 dark:text-gray-400">Obj (下包 vs 拆包)</td>
|
||||
<template x-for="stat in dataResult">
|
||||
<td class="px-4 py-2 text-center font-mono text-xs">
|
||||
<div class="flex flex-col">
|
||||
<div class="flex justify-between w-full max-w-[120px] mx-auto">
|
||||
<span class="text-amber-600 dark:text-amber-400" x-text="stat.detailed.obj_t.toFixed(2)"></span>
|
||||
<span class="text-blue-600 dark:text-blue-400" x-text="stat.detailed.obj_ct.toFixed(2)"></span>
|
||||
</div>
|
||||
<div class="flex justify-between w-full max-w-[120px] mx-auto text-[10px] text-gray-400">
|
||||
<span>T-Side</span><span>CT-Side</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</template>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 3. Map Performance -->
|
||||
<div class="bg-white dark:bg-slate-800 p-6 rounded-xl shadow-lg border border-gray-100 dark:border-slate-700">
|
||||
<h4 class="font-bold text-gray-800 dark:text-gray-200 mb-6">地图表现 (Map Performance)</h4>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full text-sm">
|
||||
<thead class="bg-gray-50 dark:bg-slate-700/50">
|
||||
<tr>
|
||||
<th class="px-4 py-2 text-left rounded-l-lg">Map</th>
|
||||
<template x-for="(stat, idx) in dataResult" :key="'h-'+stat.steam_id">
|
||||
<th class="px-4 py-2 text-center" :class="{'rounded-r-lg': idx === dataResult.length-1}">
|
||||
<span class="border-b-2 px-1" :style="'border-color: ' + getPlayerColor(idx)" x-text="stat.username"></span>
|
||||
</th>
|
||||
</template>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100 dark:divide-slate-700">
|
||||
<!-- We need to iterate maps. Assuming mapMap is computed in JS -->
|
||||
<template x-for="mapName in allMaps" :key="mapName">
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700/30">
|
||||
<td class="px-4 py-3 font-bold text-gray-600 dark:text-gray-300" x-text="mapName"></td>
|
||||
<template x-for="stat in dataResult" :key="'d-'+stat.steam_id+mapName">
|
||||
<td class="px-4 py-3 text-center">
|
||||
<template x-if="getMapStat(stat.steam_id, mapName)">
|
||||
<div>
|
||||
<div class="font-bold font-mono" :class="getRatingColor(getMapStat(stat.steam_id, mapName).rating)" x-text="getMapStat(stat.steam_id, mapName).rating.toFixed(2)"></div>
|
||||
<div class="text-[10px] text-gray-400" x-text="(getMapStat(stat.steam_id, mapName).win_rate * 100).toFixed(0) + '% (' + getMapStat(stat.steam_id, mapName).matches + ')'"></div>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="!getMapStat(stat.steam_id, mapName)">
|
||||
<span class="text-gray-300">-</span>
|
||||
</template>
|
||||
</td>
|
||||
</template>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
65
web/templates/tactics/economy.html
Normal file
65
web/templates/tactics/economy.html
Normal file
@@ -0,0 +1,65 @@
|
||||
{% extends "tactics/layout.html" %}
|
||||
|
||||
{% block title %}Economy Calculator - Tactics{% endblock %}
|
||||
|
||||
{% block tactics_content %}
|
||||
<div class="bg-white dark:bg-slate-800 shadow rounded-lg p-6">
|
||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-4">Economy Calculator</h2>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
<!-- Input Form -->
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-white">Current Round State</h3>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Round Result</label>
|
||||
<select class="mt-1 block w-full rounded-md border-gray-300 dark:bg-slate-700 dark:text-white">
|
||||
<option>Won (Elimination/Time)</option>
|
||||
<option>Won (Bomb Defused)</option>
|
||||
<option>Lost (Elimination)</option>
|
||||
<option>Lost (Bomb Planted)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Surviving Players</label>
|
||||
<input type="number" min="0" max="5" value="0" class="mt-1 block w-full rounded-md border-gray-300 dark:bg-slate-700 dark:text-white">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Current Loss Bonus</label>
|
||||
<select class="mt-1 block w-full rounded-md border-gray-300 dark:bg-slate-700 dark:text-white">
|
||||
<option>$1400 (0)</option>
|
||||
<option>$1900 (1)</option>
|
||||
<option>$2400 (2)</option>
|
||||
<option>$2900 (3)</option>
|
||||
<option>$3400 (4+)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button class="w-full px-4 py-2 bg-yrtv-600 text-white rounded-md">Calculate Next Round</button>
|
||||
</div>
|
||||
|
||||
<!-- Output -->
|
||||
<div class="bg-gray-50 dark:bg-slate-700 p-6 rounded-lg">
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-4">Prediction</h3>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-600 dark:text-gray-300">Team Money (Min)</span>
|
||||
<span class="font-bold text-gray-900 dark:text-white">$12,400</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-600 dark:text-gray-300">Team Money (Max)</span>
|
||||
<span class="font-bold text-gray-900 dark:text-white">$18,500</span>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-gray-200 dark:border-slate-600 pt-4">
|
||||
<span class="block text-sm text-gray-500 dark:text-gray-400">Recommendation</span>
|
||||
<span class="block text-xl font-bold text-green-600 dark:text-green-400">Full Buy</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
780
web/templates/tactics/index.html
Normal file
780
web/templates/tactics/index.html
Normal file
@@ -0,0 +1,780 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Tactics Center{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
<!-- Leaflet CSS -->
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin=""/>
|
||||
<style>
|
||||
.player-token { cursor: grab; transition: transform 0.1s; }
|
||||
.player-token:active { cursor: grabbing; transform: scale(1.05); }
|
||||
#map-container { background-color: #1a1a1a; z-index: 1; }
|
||||
.leaflet-container { background: #1a1a1a; }
|
||||
.custom-scroll::-webkit-scrollbar { width: 6px; }
|
||||
.custom-scroll::-webkit-scrollbar-track { background: transparent; }
|
||||
.custom-scroll::-webkit-scrollbar-thumb { background-color: rgba(156, 163, 175, 0.5); border-radius: 20px; }
|
||||
[x-cloak] { display: none !important; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="flex h-[calc(100vh-4rem)] overflow-hidden" x-data="tacticsApp()" x-cloak>
|
||||
|
||||
<!-- Left Sidebar: Roster (Permanent) -->
|
||||
<div class="w-72 flex flex-col bg-white dark:bg-slate-800 border-r border-gray-200 dark:border-slate-700 shadow-xl z-20 shrink-0">
|
||||
<div class="p-4 border-b border-gray-200 dark:border-slate-700">
|
||||
<h2 class="text-lg font-bold text-gray-900 dark:text-white">队员列表 (Roster)</h2>
|
||||
<p class="text-xs text-gray-500">拖拽队员至右侧功能区</p>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto custom-scroll p-4 space-y-2">
|
||||
<template x-for="player in roster" :key="player.steam_id_64">
|
||||
<div class="player-token group flex items-center p-2 rounded-lg border border-transparent hover:bg-gray-50 dark:hover:bg-slate-700 hover:border-gray-200 dark:hover:border-slate-600 transition select-none cursor-grab active:cursor-grabbing"
|
||||
:data-id="player.steam_id_64"
|
||||
draggable="true"
|
||||
@dragstart="dragStart($event, player)">
|
||||
|
||||
<template x-if="player.avatar_url">
|
||||
<img :src="player.avatar_url" class="w-10 h-10 rounded-full border border-gray-200 dark:border-slate-600 object-cover pointer-events-none">
|
||||
</template>
|
||||
<template x-if="!player.avatar_url">
|
||||
<div class="w-10 h-10 rounded-full bg-yrtv-100 flex items-center justify-center border border-gray-200 dark:border-slate-600 text-yrtv-600 font-bold text-xs pointer-events-none">
|
||||
<span x-text="(player.username || player.name || player.steam_id_64).substring(0, 2).toUpperCase()"></span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="ml-3 flex-1 min-w-0 pointer-events-none">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-white truncate" x-text="player.username || player.name || player.steam_id_64"></div>
|
||||
<!-- Tag Display -->
|
||||
<div class="flex flex-wrap gap-1 mt-0.5">
|
||||
<template x-for="tag in player.tags">
|
||||
<span class="text-[10px] bg-gray-100 dark:bg-slate-600 text-gray-600 dark:text-gray-300 px-1 rounded" x-text="tag"></span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="roster.length === 0">
|
||||
<div class="text-sm text-gray-500 text-center py-8">
|
||||
暂无队员,请去 <a href="/teams" class="text-yrtv-600 hover:underline">Team</a> 页面添加。
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Content Area -->
|
||||
<div class="flex-1 flex flex-col min-w-0 bg-gray-50 dark:bg-gray-900">
|
||||
|
||||
<!-- Top Navigation Tabs -->
|
||||
<div class="bg-white dark:bg-slate-800 border-b border-gray-200 dark:border-slate-700 px-4">
|
||||
<nav class="-mb-px flex space-x-8">
|
||||
<button @click="switchTab('analysis')" :class="{'border-yrtv-500 text-yrtv-600 dark:text-yrtv-400': activeTab === 'analysis', 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400': activeTab !== 'analysis'}" class="whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm transition">
|
||||
深度分析 (Deep Analysis)
|
||||
</button>
|
||||
<button @click="switchTab('data')" :class="{'border-yrtv-500 text-yrtv-600 dark:text-yrtv-400': activeTab === 'data', 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400': activeTab !== 'data'}" class="whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm transition">
|
||||
数据中心 (Data Center)
|
||||
</button>
|
||||
<button @click="switchTab('board')" :class="{'border-yrtv-500 text-yrtv-600 dark:text-yrtv-400': activeTab === 'board', 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400': activeTab !== 'board'}" class="whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm transition">
|
||||
战术白板 (Strategy Board)
|
||||
</button>
|
||||
<button @click="switchTab('economy')" :class="{'border-yrtv-500 text-yrtv-600 dark:text-yrtv-400': activeTab === 'economy', 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400': activeTab !== 'economy'}" class="whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm transition">
|
||||
经济计算 (Economy)
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- Tab Contents -->
|
||||
<div class="flex-1 overflow-y-auto p-6 relative">
|
||||
|
||||
<!-- 1. Deep Analysis -->
|
||||
<div x-show="activeTab === 'analysis'" class="space-y-6">
|
||||
<h3 class="text-xl font-bold text-gray-900 dark:text-white">阵容化学反应分析</h3>
|
||||
|
||||
<div class="flex flex-col space-y-8">
|
||||
<!-- Drop Zone -->
|
||||
<div class="bg-white dark:bg-slate-800 p-8 rounded-xl shadow-lg min-h-[320px] border border-gray-100 dark:border-slate-700"
|
||||
@dragover.prevent @drop="dropAnalysis($event)">
|
||||
<h4 class="text-lg font-bold text-gray-800 dark:text-gray-200 mb-6 flex justify-between items-center">
|
||||
<span class="flex items-center gap-2">
|
||||
<span class="bg-yrtv-100 text-yrtv-700 p-1 rounded">🏗️</span>
|
||||
<span x-text="'阵容构建 (' + analysisLineup.length + '/5)'">阵容构建 (0/5)</span>
|
||||
</span>
|
||||
<button @click="clearAnalysis()" class="px-3 py-1.5 bg-red-50 text-red-600 rounded-md hover:bg-red-100 text-sm font-medium transition">清空全部</button>
|
||||
</h4>
|
||||
|
||||
<div class="grid grid-cols-5 gap-6">
|
||||
<template x-for="(p, idx) in analysisLineup" :key="p.steam_id_64">
|
||||
<div class="relative group bg-gradient-to-b from-gray-50 to-gray-100 dark:from-slate-700 dark:to-slate-800 p-4 rounded-xl border-2 border-yrtv-200 dark:border-slate-600 flex flex-col items-center justify-center h-48 shadow-sm transition-all duration-200 hover:-translate-y-1 hover:shadow-md">
|
||||
<button @click="removeFromAnalysis(idx)" class="absolute -top-2 -right-2 bg-red-500 text-white rounded-full w-6 h-6 flex items-center justify-center opacity-0 group-hover:opacity-100 transition shadow-sm">×</button>
|
||||
|
||||
<!-- Avatar -->
|
||||
<template x-if="p.avatar_url">
|
||||
<img :src="p.avatar_url" class="w-20 h-20 rounded-full mb-3 object-cover border-4 border-white dark:border-slate-600 shadow-md">
|
||||
</template>
|
||||
<template x-if="!p.avatar_url">
|
||||
<div class="w-20 h-20 rounded-full mb-3 bg-white flex items-center justify-center text-yrtv-600 font-bold text-2xl border-4 border-gray-100 dark:border-slate-600 shadow-md">
|
||||
<span x-text="(p.username || p.name || p.steam_id_64).substring(0, 2).toUpperCase()"></span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<span class="text-sm font-bold truncate w-full text-center dark:text-white mb-1" x-text="p.username || p.name"></span>
|
||||
<div class="px-2.5 py-1 bg-white dark:bg-slate-900 rounded-full text-xs text-gray-500 dark:text-gray-400 shadow-inner border border-gray-100 dark:border-slate-700">
|
||||
Rating: <span class="font-bold text-yrtv-600" x-text="(p.stats?.basic_avg_rating || 0).toFixed(2)"></span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Empty Slots -->
|
||||
<template x-for="i in (5 - analysisLineup.length)">
|
||||
<div class="border-2 border-dashed border-gray-300 dark:border-slate-600 rounded-xl flex flex-col items-center justify-center h-48 text-gray-400 text-sm bg-gray-50/30 dark:bg-slate-800/30 hover:bg-gray-50 dark:hover:bg-slate-800 transition cursor-default">
|
||||
<div class="text-4xl mb-2 opacity-30 text-gray-300">+</div>
|
||||
<span class="opacity-70">拖拽队员</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Results Area -->
|
||||
<div class="bg-white dark:bg-slate-800 p-8 rounded-xl shadow-lg min-h-[240px] border border-gray-100 dark:border-slate-700">
|
||||
<template x-if="!analysisResult">
|
||||
<div class="h-48 flex flex-col items-center justify-center text-gray-400">
|
||||
<div class="text-5xl mb-4 opacity-20 grayscale">📊</div>
|
||||
<div class="text-lg font-medium text-gray-500">请先构建阵容,系统将自动分析</div>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="analysisResult">
|
||||
<div class="space-y-6">
|
||||
<div class="flex justify-between items-end border-b border-gray-100 dark:border-slate-700 pb-4">
|
||||
<h4 class="font-bold text-xl text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<span>📈</span> 综合评分
|
||||
</h4>
|
||||
<div class="flex items-baseline gap-2">
|
||||
<span class="text-sm text-gray-500">Team Rating</span>
|
||||
<span class="text-4xl font-black text-yrtv-600 tracking-tight" x-text="analysisResult.avg_stats.rating.toFixed(2)"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-3 gap-6 text-center">
|
||||
<div class="bg-gray-50 dark:bg-slate-700 p-4 rounded-xl border border-gray-100 dark:border-slate-600">
|
||||
<div class="text-gray-500 text-xs uppercase tracking-wider mb-1">Avg K/D</div>
|
||||
<div class="text-2xl font-bold dark:text-white" x-text="analysisResult.avg_stats.kd.toFixed(2)"></div>
|
||||
</div>
|
||||
<div class="bg-gray-50 dark:bg-slate-700 p-4 rounded-xl border border-gray-100 dark:border-slate-600">
|
||||
<div class="text-gray-500 text-xs uppercase tracking-wider mb-1">Avg ADR</div>
|
||||
<div class="text-2xl font-bold dark:text-white" x-text="analysisResult.avg_stats.adr.toFixed(1)"></div>
|
||||
</div>
|
||||
<div class="bg-gray-50 dark:bg-slate-700 p-4 rounded-xl border border-gray-100 dark:border-slate-600">
|
||||
<div class="text-gray-500 text-xs uppercase tracking-wider mb-1">Shared Matches</div>
|
||||
<div class="text-2xl font-bold dark:text-white" x-text="analysisResult.total_shared_matches"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h5 class="text-sm font-bold text-gray-700 dark:text-gray-300 mb-3 flex items-center gap-2">
|
||||
<span>🗓️</span> 共同比赛记录 (Shared Matches History)
|
||||
</h5>
|
||||
<div class="max-h-60 overflow-y-auto custom-scroll border border-gray-200 dark:border-slate-700 rounded-lg mb-6">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-slate-700">
|
||||
<thead class="bg-gray-50 dark:bg-slate-800 sticky top-0">
|
||||
<tr>
|
||||
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Map</th>
|
||||
<th class="px-4 py-2 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Score</th>
|
||||
<th class="px-4 py-2 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Result</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white dark:bg-slate-800 divide-y divide-gray-200 dark:divide-slate-700">
|
||||
<template x-for="m in analysisResult.shared_matches" :key="m.match_id">
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors">
|
||||
<td class="px-4 py-3 text-sm font-medium dark:text-gray-300" x-text="m.map_name"></td>
|
||||
<td class="px-4 py-3 text-sm text-right dark:text-gray-400 font-mono" x-text="m.score_team1 + ':' + m.score_team2"></td>
|
||||
<td class="px-4 py-3 text-sm text-right font-bold">
|
||||
<span :class="m.is_win ? 'bg-green-100 text-green-800 px-2 py-0.5 rounded dark:bg-green-900 dark:text-green-200' : 'bg-red-100 text-red-800 px-2 py-0.5 rounded dark:bg-red-900 dark:text-red-200'"
|
||||
x-text="m.result_str"></span>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
<template x-if="analysisResult.shared_matches.length === 0">
|
||||
<div class="p-8 text-center text-gray-400 bg-gray-50 dark:bg-slate-800">
|
||||
无共同比赛记录
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Map Stats -->
|
||||
<h5 class="text-sm font-bold text-gray-700 dark:text-gray-300 mb-3 flex items-center gap-2">
|
||||
<span>🗺️</span> 地图表现统计 (Map Performance)
|
||||
</h5>
|
||||
<div class="border border-gray-200 dark:border-slate-700 rounded-lg overflow-hidden">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-slate-700">
|
||||
<thead class="bg-gray-50 dark:bg-slate-800">
|
||||
<tr>
|
||||
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Map</th>
|
||||
<th class="px-4 py-2 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Matches</th>
|
||||
<th class="px-4 py-2 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Wins</th>
|
||||
<th class="px-4 py-2 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Win Rate</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white dark:bg-slate-800 divide-y divide-gray-200 dark:divide-slate-700">
|
||||
<template x-for="stat in analysisResult.map_stats" :key="stat.map_name">
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors">
|
||||
<td class="px-4 py-2 text-sm font-medium dark:text-gray-300" x-text="stat.map_name"></td>
|
||||
<td class="px-4 py-2 text-sm text-right dark:text-gray-400" x-text="stat.count"></td>
|
||||
<td class="px-4 py-2 text-sm text-right text-green-600 font-bold" x-text="stat.wins"></td>
|
||||
<td class="px-4 py-2 text-sm text-right font-bold dark:text-white">
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<span x-text="stat.win_rate.toFixed(1) + '%'"></span>
|
||||
<div class="w-16 h-1.5 bg-gray-200 dark:bg-slate-600 rounded-full overflow-hidden">
|
||||
<div class="h-full bg-yrtv-500 rounded-full" :style="'width: ' + stat.win_rate + '%'"></div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
<template x-if="!analysisResult.map_stats || analysisResult.map_stats.length === 0">
|
||||
<div class="p-4 text-center text-gray-400 bg-gray-50 dark:bg-slate-800 text-sm">
|
||||
暂无地图数据
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 2. Data Center -->
|
||||
{% include 'tactics/data.html' %}
|
||||
|
||||
<!-- 3. Strategy Board -->
|
||||
<div x-show="activeTab === 'board'" class="h-full flex flex-col">
|
||||
<!-- Map Controls -->
|
||||
<div class="mb-4 flex justify-between items-center bg-white dark:bg-slate-800 p-3 rounded shadow">
|
||||
<div class="flex space-x-2">
|
||||
<select x-model="currentMap" @change="changeMap()" class="rounded border-gray-300 dark:bg-slate-700 dark:border-slate-600 dark:text-white text-sm">
|
||||
<option value="de_mirage">Mirage</option>
|
||||
<option value="de_inferno">Inferno</option>
|
||||
<option value="de_dust2">Dust 2</option>
|
||||
<option value="de_nuke">Nuke</option>
|
||||
<option value="de_ancient">Ancient</option>
|
||||
<option value="de_anubis">Anubis</option>
|
||||
<option value="de_vertigo">Vertigo</option>
|
||||
</select>
|
||||
<button @click="clearBoard()" class="px-3 py-1 bg-red-100 text-red-700 rounded hover:bg-red-200 text-sm">清空 (Clear)</button>
|
||||
<button @click="saveBoard()" class="px-3 py-1 bg-green-100 text-green-700 rounded hover:bg-green-200 text-sm">保存快照 (Save)</button>
|
||||
</div>
|
||||
<div class="text-sm text-gray-500">
|
||||
在场人数: <span x-text="boardPlayers.length" class="font-bold text-yrtv-600"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Map Area -->
|
||||
<div class="flex-1 relative bg-gray-900 rounded-lg overflow-hidden border border-gray-700"
|
||||
id="board-dropzone"
|
||||
@dragover.prevent
|
||||
@drop="dropBoard($event)">
|
||||
<div id="map-container" class="w-full h-full"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 4. Economy -->
|
||||
<div x-show="activeTab === 'economy'" class="bg-white dark:bg-slate-800 shadow rounded-lg p-6">
|
||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-6">经济计算器 (Economy Calculator)</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">本回合结果</label>
|
||||
<select x-model="econ.result" class="mt-1 block w-full rounded-md border-gray-300 dark:bg-slate-700 dark:text-white">
|
||||
<option value="win">胜利 (Won)</option>
|
||||
<option value="loss">失败 (Lost)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">连败加成等级 (Loss Bonus)</label>
|
||||
<select x-model="econ.lossBonus" class="mt-1 block w-full rounded-md border-gray-300 dark:bg-slate-700 dark:text-white">
|
||||
<option value="0">$1400 (0)</option>
|
||||
<option value="1">$1900 (1)</option>
|
||||
<option value="2">$2400 (2)</option>
|
||||
<option value="3">$2900 (3)</option>
|
||||
<option value="4">$3400 (4+)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">存活人数</label>
|
||||
<input type="number" x-model="econ.surviving" min="0" max="5" class="mt-1 block w-full rounded-md border-gray-300 dark:bg-slate-700 dark:text-white">
|
||||
</div>
|
||||
|
||||
<div class="pt-4">
|
||||
<div class="p-4 bg-gray-100 dark:bg-slate-700 rounded-lg">
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">下回合收入预测</div>
|
||||
<div class="text-3xl font-bold text-green-600 dark:text-green-400" x-text="'$' + calculateIncome()"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- External Libs -->
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
|
||||
<script>
|
||||
function tacticsApp() {
|
||||
return {
|
||||
activeTab: 'analysis',
|
||||
roster: [],
|
||||
|
||||
// Analysis State
|
||||
analysisLineup: [],
|
||||
analysisResult: null,
|
||||
debounceTimer: null,
|
||||
|
||||
// Data Center State
|
||||
dataLineup: [],
|
||||
dataResult: [],
|
||||
searchQuery: '',
|
||||
radarChart: null,
|
||||
allMaps: ['de_mirage', 'de_inferno', 'de_dust2', 'de_nuke', 'de_ancient', 'de_anubis', 'de_vertigo'],
|
||||
mapStatsCache: {},
|
||||
isDraggingOverData: false,
|
||||
|
||||
// Board State
|
||||
currentMap: 'de_mirage',
|
||||
map: null,
|
||||
markers: {},
|
||||
boardPlayers: [],
|
||||
|
||||
// Economy State
|
||||
econ: {
|
||||
result: 'loss',
|
||||
lossBonus: '0',
|
||||
surviving: 0
|
||||
},
|
||||
|
||||
init() {
|
||||
this.fetchRoster();
|
||||
|
||||
// Auto-analyze when lineup changes
|
||||
this.$watch('analysisLineup', () => {
|
||||
if (this.debounceTimer) clearTimeout(this.debounceTimer);
|
||||
this.debounceTimer = setTimeout(() => {
|
||||
if (this.analysisLineup.length > 0) {
|
||||
this.analyzeLineup();
|
||||
} else {
|
||||
this.analysisResult = null;
|
||||
}
|
||||
}, 300);
|
||||
});
|
||||
|
||||
// Watch Data Lineup
|
||||
this.$watch('dataLineup', () => {
|
||||
this.comparePlayers();
|
||||
});
|
||||
|
||||
// Init map on first board view, or delay
|
||||
this.$watch('activeTab', value => {
|
||||
if (value === 'board') {
|
||||
this.$nextTick(() => {
|
||||
if (!this.map) this.initMap();
|
||||
else this.map.invalidateSize();
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
fetchRoster() {
|
||||
fetch('/teams/api/roster')
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
this.roster = data.roster || [];
|
||||
});
|
||||
},
|
||||
|
||||
switchTab(tab) {
|
||||
this.activeTab = tab;
|
||||
},
|
||||
|
||||
// --- Drag & Drop Generic ---
|
||||
dragStart(event, player) {
|
||||
// Only send essential data to avoid circular references with Alpine proxies
|
||||
const payload = {
|
||||
steam_id_64: player.steam_id_64,
|
||||
username: player.username || player.name,
|
||||
name: player.name || player.username,
|
||||
avatar_url: player.avatar_url
|
||||
};
|
||||
event.dataTransfer.setData('text/plain', JSON.stringify(payload));
|
||||
event.dataTransfer.effectAllowed = 'copy';
|
||||
},
|
||||
|
||||
// --- Data Center Logic ---
|
||||
searchPlayer() {
|
||||
if (!this.searchQuery) return;
|
||||
const q = this.searchQuery.toLowerCase();
|
||||
const found = this.roster.find(p =>
|
||||
(p.username && p.username.toLowerCase().includes(q)) ||
|
||||
(p.steam_id_64 && p.steam_id_64.includes(q))
|
||||
);
|
||||
if (found) {
|
||||
this.addToDataLineup(found);
|
||||
this.searchQuery = '';
|
||||
} else {
|
||||
alert('未找到玩家 (Locally)');
|
||||
}
|
||||
},
|
||||
|
||||
addToDataLineup(player) {
|
||||
if (this.dataLineup.some(p => p.steam_id_64 === player.steam_id_64)) {
|
||||
alert('该选手已在对比列表中');
|
||||
return;
|
||||
}
|
||||
if (this.dataLineup.length >= 5) {
|
||||
alert('对比列表已满 (最多5人)');
|
||||
return;
|
||||
}
|
||||
this.dataLineup.push(player);
|
||||
},
|
||||
|
||||
removeFromDataLineup(index) {
|
||||
this.dataLineup.splice(index, 1);
|
||||
},
|
||||
|
||||
clearDataLineup() {
|
||||
this.dataLineup = [];
|
||||
},
|
||||
|
||||
dropData(event) {
|
||||
this.isDraggingOverData = false;
|
||||
const data = event.dataTransfer.getData('text/plain');
|
||||
if (!data) return;
|
||||
try {
|
||||
const player = JSON.parse(data);
|
||||
this.addToDataLineup(player);
|
||||
} catch (e) {
|
||||
console.error("Drop Error:", e);
|
||||
alert("无法解析拖拽数据");
|
||||
}
|
||||
},
|
||||
|
||||
comparePlayers() {
|
||||
if (this.dataLineup.length === 0) {
|
||||
this.dataResult = [];
|
||||
if (this.radarChart) {
|
||||
this.radarChart.data.datasets = [];
|
||||
this.radarChart.update();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const ids = this.dataLineup.map(p => p.steam_id_64).join(',');
|
||||
|
||||
// 1. Fetch Basic & Radar Stats
|
||||
fetch('/players/api/batch_stats?ids=' + ids)
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
this.dataResult = data;
|
||||
// Use $nextTick to ensure DOM update if needed, but for Chart.js usually direct call is fine.
|
||||
// However, dataResult is reactive. Let's call update explicitly.
|
||||
this.$nextTick(() => {
|
||||
this.updateRadarChart();
|
||||
});
|
||||
});
|
||||
|
||||
// 2. Fetch Map Stats
|
||||
fetch('/players/api/batch_map_stats?ids=' + ids)
|
||||
.then(res => res.json())
|
||||
.then(mapData => {
|
||||
this.mapStatsCache = mapData;
|
||||
});
|
||||
},
|
||||
|
||||
getMapStat(sid, mapName) {
|
||||
if (!this.mapStatsCache[sid]) return null;
|
||||
return this.mapStatsCache[sid].find(m => m.map_name === mapName);
|
||||
},
|
||||
|
||||
getPlayerColor(idx) {
|
||||
const colors = ['#3b82f6', '#ef4444', '#10b981', '#f59e0b', '#8b5cf6'];
|
||||
return colors[idx % colors.length];
|
||||
},
|
||||
|
||||
getRatingColor(rating) {
|
||||
if (rating >= 1.2) return 'text-red-500';
|
||||
if (rating >= 1.05) return 'text-green-600';
|
||||
return 'text-gray-500';
|
||||
},
|
||||
|
||||
updateRadarChart() {
|
||||
// Force destroy to avoid state issues (fullSize error)
|
||||
if (this.radarChart) {
|
||||
this.radarChart.destroy();
|
||||
this.radarChart = null;
|
||||
}
|
||||
|
||||
const canvas = document.getElementById('dataRadarChart');
|
||||
if (!canvas) return; // Tab might not be visible yet
|
||||
|
||||
// Unwrap proxy if needed
|
||||
const rawData = JSON.parse(JSON.stringify(this.dataResult));
|
||||
|
||||
const datasets = rawData.map((p, idx) => {
|
||||
const color = this.getPlayerColor(idx);
|
||||
const d = [
|
||||
p.radar.BAT || 0, p.radar.PTL || 0, p.radar.HPS || 0,
|
||||
p.radar.SIDE || 0, p.radar.UTIL || 0, p.radar.STA || 0
|
||||
];
|
||||
|
||||
return {
|
||||
label: p.username,
|
||||
data: d,
|
||||
borderColor: color,
|
||||
backgroundColor: color + '20',
|
||||
borderWidth: 2,
|
||||
pointRadius: 3
|
||||
};
|
||||
});
|
||||
|
||||
// Recreate Chart with Profile-aligned config
|
||||
const ctx = canvas.getContext('2d');
|
||||
this.radarChart = new Chart(ctx, {
|
||||
type: 'radar',
|
||||
data: {
|
||||
labels: ['BAT (火力)', 'PTL (手枪)', 'HPS (抗压)', 'SIDE (阵营)', 'UTIL (道具)', 'STA (稳定)'],
|
||||
datasets: datasets
|
||||
},
|
||||
options: {
|
||||
maintainAspectRatio: false,
|
||||
scales: {
|
||||
r: {
|
||||
min: 0,
|
||||
max: 100,
|
||||
ticks: {
|
||||
display: false, // Cleaner look like profile
|
||||
stepSize: 20
|
||||
},
|
||||
pointLabels: {
|
||||
font: { size: 12, weight: 'bold' },
|
||||
color: (ctx) => document.documentElement.classList.contains('dark') ? '#cbd5e1' : '#374151'
|
||||
},
|
||||
grid: {
|
||||
color: (ctx) => document.documentElement.classList.contains('dark') ? 'rgba(51, 65, 85, 0.5)' : 'rgba(229, 231, 235, 0.8)'
|
||||
},
|
||||
angleLines: {
|
||||
color: (ctx) => document.documentElement.classList.contains('dark') ? 'rgba(51, 65, 85, 0.5)' : 'rgba(229, 231, 235, 0.8)'
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom',
|
||||
labels: {
|
||||
color: (ctx) => document.documentElement.classList.contains('dark') ? '#fff' : '#000',
|
||||
usePointStyle: true,
|
||||
padding: 20
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
initRadarChart() {
|
||||
const canvas = document.getElementById('dataRadarChart');
|
||||
if (!canvas) return; // Tab might not be visible yet
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
this.radarChart = new Chart(ctx, {
|
||||
type: 'radar',
|
||||
data: {
|
||||
labels: ['BAT (火力)', 'PTL (手枪)', 'HPS (抗压)', 'SIDE (阵营)', 'UTIL (道具)', 'STA (稳定)'],
|
||||
datasets: []
|
||||
},
|
||||
options: {
|
||||
scales: {
|
||||
r: {
|
||||
min: 0,
|
||||
max: 100,
|
||||
ticks: { display: false, stepSize: 20 },
|
||||
pointLabels: {
|
||||
font: { size: 12, weight: 'bold' },
|
||||
color: (ctx) => document.documentElement.classList.contains('dark') ? '#cbd5e1' : '#374151'
|
||||
},
|
||||
grid: {
|
||||
color: (ctx) => document.documentElement.classList.contains('dark') ? '#334155' : '#e5e7eb'
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
labels: {
|
||||
color: (ctx) => document.documentElement.classList.contains('dark') ? '#fff' : '#000'
|
||||
}
|
||||
}
|
||||
},
|
||||
maintainAspectRatio: false
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
// --- Analysis Logic ---
|
||||
dropAnalysis(event) {
|
||||
const data = event.dataTransfer.getData('text/plain');
|
||||
if (!data) return;
|
||||
const player = JSON.parse(data);
|
||||
|
||||
// Check duplicates
|
||||
if (this.analysisLineup.some(p => p.steam_id_64 === player.steam_id_64)) return;
|
||||
|
||||
// Limit 5
|
||||
if (this.analysisLineup.length >= 5) return;
|
||||
|
||||
this.analysisLineup.push(player);
|
||||
},
|
||||
|
||||
removeFromAnalysis(index) {
|
||||
this.analysisLineup.splice(index, 1);
|
||||
},
|
||||
|
||||
clearAnalysis() {
|
||||
this.analysisLineup = [];
|
||||
this.analysisResult = null;
|
||||
},
|
||||
|
||||
analyzeLineup() {
|
||||
const ids = this.analysisLineup.map(p => p.steam_id_64);
|
||||
fetch('/tactics/api/analyze', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({steam_ids: ids})
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
this.analysisResult = data;
|
||||
});
|
||||
},
|
||||
|
||||
// --- Board Logic ---
|
||||
initMap() {
|
||||
this.map = L.map('map-container', {
|
||||
crs: L.CRS.Simple,
|
||||
minZoom: -2,
|
||||
maxZoom: 2,
|
||||
zoomControl: true,
|
||||
attributionControl: false
|
||||
});
|
||||
this.loadMapImage();
|
||||
},
|
||||
|
||||
loadMapImage() {
|
||||
const mapUrls = {
|
||||
'de_mirage': 'https://static.wikia.nocookie.net/cswikia/images/e/e3/Mirage_CS2_Radar.png',
|
||||
'de_inferno': 'https://static.wikia.nocookie.net/cswikia/images/7/77/Inferno_CS2_Radar.png',
|
||||
'de_dust2': 'https://static.wikia.nocookie.net/cswikia/images/0/03/Dust2_CS2_Radar.png',
|
||||
'de_nuke': 'https://static.wikia.nocookie.net/cswikia/images/1/14/Nuke_CS2_Radar.png',
|
||||
'de_ancient': 'https://static.wikia.nocookie.net/cswikia/images/1/16/Ancient_CS2_Radar.png',
|
||||
'de_anubis': 'https://static.wikia.nocookie.net/cswikia/images/2/22/Anubis_CS2_Radar.png',
|
||||
'de_vertigo': 'https://static.wikia.nocookie.net/cswikia/images/2/23/Vertigo_CS2_Radar.png'
|
||||
};
|
||||
const url = mapUrls[this.currentMap] || mapUrls['de_mirage'];
|
||||
const bounds = [[0,0], [1024,1024]];
|
||||
|
||||
this.map.eachLayer((layer) => { this.map.removeLayer(layer); });
|
||||
L.imageOverlay(url, bounds).addTo(this.map);
|
||||
this.map.fitBounds(bounds);
|
||||
},
|
||||
|
||||
changeMap() {
|
||||
this.loadMapImage();
|
||||
this.clearBoard();
|
||||
},
|
||||
|
||||
dropBoard(event) {
|
||||
const data = event.dataTransfer.getData('text/plain');
|
||||
if (!data) return;
|
||||
const player = JSON.parse(data);
|
||||
|
||||
const container = document.getElementById('map-container');
|
||||
const rect = container.getBoundingClientRect();
|
||||
const x = event.clientX - rect.left;
|
||||
const y = event.clientY - rect.top;
|
||||
const point = this.map.containerPointToLatLng([x, y]);
|
||||
|
||||
this.addMarker(player, point);
|
||||
},
|
||||
|
||||
addMarker(player, latlng) {
|
||||
if (this.markers[player.steam_id_64]) {
|
||||
this.markers[player.steam_id_64].setLatLng(latlng);
|
||||
} else {
|
||||
const displayName = player.username || player.name || player.steam_id_64;
|
||||
const iconHtml = `
|
||||
<div class="flex flex-col items-center justify-center transform hover:scale-110 transition duration-200">
|
||||
${player.avatar_url ?
|
||||
`<img src="${player.avatar_url}" class="w-8 h-8 rounded-full border-2 border-white shadow-lg box-content object-cover">` :
|
||||
`<div class="w-8 h-8 rounded-full bg-yrtv-100 border-2 border-white shadow-lg box-content flex items-center justify-center text-yrtv-600 font-bold text-[10px]">${(player.username || player.name).substring(0, 2).toUpperCase()}</div>`
|
||||
}
|
||||
<span class="mt-1 text-[10px] font-bold text-white bg-black/60 px-1.5 py-0.5 rounded backdrop-blur-sm whitespace-nowrap overflow-hidden max-w-[80px] text-ellipsis">
|
||||
${displayName}
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
const icon = L.divIcon({ className: 'bg-transparent', html: iconHtml, iconSize: [60, 60], iconAnchor: [30, 30] });
|
||||
|
||||
const marker = L.marker(latlng, { icon: icon, draggable: true }).addTo(this.map);
|
||||
this.markers[player.steam_id_64] = marker;
|
||||
this.boardPlayers.push(player);
|
||||
}
|
||||
},
|
||||
|
||||
clearBoard() {
|
||||
for (let id in this.markers) { this.map.removeLayer(this.markers[id]); }
|
||||
this.markers = {};
|
||||
this.boardPlayers = [];
|
||||
},
|
||||
|
||||
saveBoard() {
|
||||
const title = prompt("请输入战术标题:", "New Strat " + new Date().toLocaleTimeString());
|
||||
if (!title) return;
|
||||
|
||||
const markerData = [];
|
||||
for (let id in this.markers) {
|
||||
const m = this.markers[id];
|
||||
markerData.push({ id: id, lat: m.getLatLng().lat, lng: m.getLatLng().lng });
|
||||
}
|
||||
|
||||
fetch('/tactics/save_board', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({ title: title, map_name: this.currentMap, markers: markerData })
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => alert(data.success ? "保存成功" : "保存失败"));
|
||||
},
|
||||
|
||||
// --- Economy Logic ---
|
||||
calculateIncome() {
|
||||
let base = 0;
|
||||
const lbLevel = parseInt(this.econ.lossBonus);
|
||||
|
||||
if (this.econ.result === 'win') {
|
||||
base = 3250 + (300 * this.econ.surviving); // Simplified estimate
|
||||
} else {
|
||||
// Loss base
|
||||
const lossAmounts = [1400, 1900, 2400, 2900, 3400];
|
||||
base = lossAmounts[Math.min(lbLevel, 4)];
|
||||
}
|
||||
return base;
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
28
web/templates/tactics/layout.html
Normal file
28
web/templates/tactics/layout.html
Normal file
@@ -0,0 +1,28 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<!-- Navigation Tabs -->
|
||||
<div class="border-b border-gray-200 dark:border-slate-700 mb-6">
|
||||
<nav class="-mb-px flex space-x-8">
|
||||
<a href="{{ url_for('tactics.index') }}" class="border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm">
|
||||
← Dashboard
|
||||
</a>
|
||||
<a href="{{ url_for('tactics.analysis') }}" class="{{ 'border-yrtv-500 text-yrtv-600 dark:text-yrtv-400' if request.endpoint == 'tactics.analysis' else 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-200' }} whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm">
|
||||
Deep Analysis
|
||||
</a>
|
||||
<a href="{{ url_for('tactics.data') }}" class="{{ 'border-yrtv-500 text-yrtv-600 dark:text-yrtv-400' if request.endpoint == 'tactics.data' else 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-200' }} whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm">
|
||||
Data Center
|
||||
</a>
|
||||
<a href="{{ url_for('tactics.board') }}" class="{{ 'border-yrtv-500 text-yrtv-600 dark:text-yrtv-400' if request.endpoint == 'tactics.board' else 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-200' }} whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm">
|
||||
Strategy Board
|
||||
</a>
|
||||
<a href="{{ url_for('tactics.economy') }}" class="{{ 'border-yrtv-500 text-yrtv-600 dark:text-yrtv-400' if request.endpoint == 'tactics.economy' else 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-200' }} whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm">
|
||||
Economy
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{% block tactics_content %}{% endblock %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
27
web/templates/tactics/maps.html
Normal file
27
web/templates/tactics/maps.html
Normal file
@@ -0,0 +1,27 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="bg-white dark:bg-slate-800 shadow rounded-lg p-6">
|
||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-6">地图情报</h2>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{% for map in maps %}
|
||||
<div class="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden hover:shadow-lg transition cursor-pointer">
|
||||
<div class="h-40 bg-gray-300 flex items-center justify-center overflow-hidden">
|
||||
<!-- Use actual map images or fallback -->
|
||||
<img src="{{ url_for('static', filename='images/maps/' + map.name + '.jpg') }}"
|
||||
onerror="this.src='https://developer.valvesoftware.com/w/images/thumb/3/3d/De_mirage_radar_spectator.png/800px-De_mirage_radar_spectator.png'; this.style.objectFit='cover'; this.style.height='100%'; this.style.width='100%';"
|
||||
alt="{{ map.title }}" class="w-full h-full object-cover">
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<h3 class="text-lg font-bold text-gray-900 dark:text-white">{{ map.title }}</h3>
|
||||
<div class="mt-4 flex space-x-2">
|
||||
<button class="px-3 py-1 bg-yrtv-100 text-yrtv-700 rounded text-sm hover:bg-yrtv-200">道具点位</button>
|
||||
<button class="px-3 py-1 bg-gray-100 text-gray-700 rounded text-sm hover:bg-gray-200">战术板</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user