feat: Add recent performance stability stats (matches/days) to player profile
36
web/app.py
Normal file
@@ -0,0 +1,36 @@
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Add the project root directory to sys.path
|
||||
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
|
||||
|
||||
from flask import Flask, render_template
|
||||
from web.config import Config
|
||||
from web.database import close_dbs
|
||||
|
||||
def create_app():
|
||||
app = Flask(__name__)
|
||||
app.config.from_object(Config)
|
||||
|
||||
app.teardown_appcontext(close_dbs)
|
||||
|
||||
# Register Blueprints
|
||||
from web.routes import main, matches, players, teams, tactics, admin, wiki, opponents
|
||||
app.register_blueprint(main.bp)
|
||||
app.register_blueprint(matches.bp)
|
||||
app.register_blueprint(players.bp)
|
||||
app.register_blueprint(teams.bp)
|
||||
app.register_blueprint(tactics.bp)
|
||||
app.register_blueprint(admin.bp)
|
||||
app.register_blueprint(wiki.bp)
|
||||
app.register_blueprint(opponents.bp)
|
||||
|
||||
@app.route('/')
|
||||
def index():
|
||||
return render_template('home/index.html')
|
||||
|
||||
return app
|
||||
|
||||
if __name__ == '__main__':
|
||||
app = create_app()
|
||||
app.run(debug=True, port=5000)
|
||||
11
web/auth.py
Normal file
@@ -0,0 +1,11 @@
|
||||
from functools import wraps
|
||||
from flask import session, redirect, url_for, flash
|
||||
|
||||
def admin_required(f):
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
if session.get('is_admin'):
|
||||
return f(*args, **kwargs)
|
||||
flash('Admin access required', 'warning')
|
||||
return redirect(url_for('admin.login'))
|
||||
return decorated_function
|
||||
14
web/config.py
Normal file
@@ -0,0 +1,14 @@
|
||||
import os
|
||||
|
||||
class Config:
|
||||
SECRET_KEY = os.environ.get('SECRET_KEY') or 'yrtv-secret-key-dev'
|
||||
BASE_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
|
||||
|
||||
DB_L2_PATH = os.path.join(BASE_DIR, 'database', 'L2', 'L2_Main.sqlite')
|
||||
DB_L3_PATH = os.path.join(BASE_DIR, 'database', 'L3', 'L3_Features.sqlite')
|
||||
DB_WEB_PATH = os.path.join(BASE_DIR, 'database', 'Web', 'Web_App.sqlite')
|
||||
|
||||
ADMIN_TOKEN = 'jackyyang0929'
|
||||
|
||||
# Pagination
|
||||
ITEMS_PER_PAGE = 20
|
||||
47
web/database.py
Normal file
@@ -0,0 +1,47 @@
|
||||
import sqlite3
|
||||
from flask import g
|
||||
from web.config import Config
|
||||
|
||||
def get_db(db_name):
|
||||
"""
|
||||
db_name: 'l2', 'l3', or 'web'
|
||||
"""
|
||||
db_attr = f'db_{db_name}'
|
||||
db = getattr(g, db_attr, None)
|
||||
|
||||
if db is None:
|
||||
if db_name == 'l2':
|
||||
path = Config.DB_L2_PATH
|
||||
elif db_name == 'l3':
|
||||
path = Config.DB_L3_PATH
|
||||
elif db_name == 'web':
|
||||
path = Config.DB_WEB_PATH
|
||||
else:
|
||||
raise ValueError(f"Unknown database: {db_name}")
|
||||
|
||||
# Connect with check_same_thread=False if needed for dev, but default is safer per thread
|
||||
db = sqlite3.connect(path)
|
||||
db.row_factory = sqlite3.Row
|
||||
setattr(g, db_attr, db)
|
||||
|
||||
return db
|
||||
|
||||
def close_dbs(e=None):
|
||||
for db_name in ['l2', 'l3', 'web']:
|
||||
db_attr = f'db_{db_name}'
|
||||
db = getattr(g, db_attr, None)
|
||||
if db is not None:
|
||||
db.close()
|
||||
|
||||
def query_db(db_name, query, args=(), one=False):
|
||||
cur = get_db(db_name).execute(query, args)
|
||||
rv = cur.fetchall()
|
||||
cur.close()
|
||||
return (rv[0] if rv else None) if one else rv
|
||||
|
||||
def execute_db(db_name, query, args=()):
|
||||
db = get_db(db_name)
|
||||
cur = db.execute(query, args)
|
||||
db.commit()
|
||||
cur.close()
|
||||
return cur.lastrowid
|
||||
38
web/debug_roster.py
Normal file
@@ -0,0 +1,38 @@
|
||||
from web.services.web_service import WebService
|
||||
from web.services.stats_service import StatsService
|
||||
import json
|
||||
|
||||
def debug_roster():
|
||||
print("--- Debugging Roster Stats ---")
|
||||
lineups = WebService.get_lineups()
|
||||
if not lineups:
|
||||
print("No lineups found via WebService.")
|
||||
return
|
||||
|
||||
raw_json = lineups[0]['player_ids_json']
|
||||
print(f"Raw JSON: {raw_json}")
|
||||
|
||||
try:
|
||||
roster_ids = json.loads(raw_json)
|
||||
print(f"Parsed IDs (List): {roster_ids}")
|
||||
print(f"Type of first ID: {type(roster_ids[0])}")
|
||||
except Exception as e:
|
||||
print(f"JSON Parse Error: {e}")
|
||||
return
|
||||
|
||||
target_id = roster_ids[0] # Pick first one
|
||||
print(f"\nTesting for Target ID: {target_id} (Type: {type(target_id)})")
|
||||
|
||||
# Test StatsService
|
||||
dist = StatsService.get_roster_stats_distribution(target_id)
|
||||
print(f"\nDistribution Result: {dist}")
|
||||
|
||||
# Test Basic Stats
|
||||
basic = StatsService.get_player_basic_stats(str(target_id))
|
||||
print(f"\nBasic Stats for {target_id}: {basic}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
from web.app import create_app
|
||||
app = create_app()
|
||||
with app.app_context():
|
||||
debug_roster()
|
||||
75
web/routes/admin.py
Normal file
@@ -0,0 +1,75 @@
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, session, flash
|
||||
from web.config import Config
|
||||
from web.auth import admin_required
|
||||
from web.database import query_db
|
||||
import os
|
||||
|
||||
bp = Blueprint('admin', __name__, url_prefix='/admin')
|
||||
|
||||
@bp.route('/login', methods=['GET', 'POST'])
|
||||
def login():
|
||||
if request.method == 'POST':
|
||||
token = request.form.get('token')
|
||||
if token == Config.ADMIN_TOKEN:
|
||||
session['is_admin'] = True
|
||||
return redirect(url_for('admin.dashboard'))
|
||||
else:
|
||||
flash('Invalid Token', 'error')
|
||||
return render_template('admin/login.html')
|
||||
|
||||
@bp.route('/logout')
|
||||
def logout():
|
||||
session.pop('is_admin', None)
|
||||
return redirect(url_for('main.index'))
|
||||
|
||||
@bp.route('/')
|
||||
@admin_required
|
||||
def dashboard():
|
||||
return render_template('admin/dashboard.html')
|
||||
|
||||
from web.services.etl_service import EtlService
|
||||
|
||||
@bp.route('/trigger_etl', methods=['POST'])
|
||||
@admin_required
|
||||
def trigger_etl():
|
||||
script_name = request.form.get('script')
|
||||
allowed = ['L1A.py', 'L2_Builder.py', 'L3_Builder.py']
|
||||
if script_name not in allowed:
|
||||
return "Invalid script", 400
|
||||
|
||||
success, message = EtlService.run_script(script_name)
|
||||
status_code = 200 if success else 500
|
||||
return message, status_code
|
||||
|
||||
@bp.route('/sql', methods=['GET', 'POST'])
|
||||
@admin_required
|
||||
def sql_runner():
|
||||
result = None
|
||||
error = None
|
||||
query = ""
|
||||
db_name = "l2"
|
||||
|
||||
if request.method == 'POST':
|
||||
query = request.form.get('query')
|
||||
db_name = request.form.get('db_name', 'l2')
|
||||
|
||||
# Safety check
|
||||
forbidden = ['DELETE', 'DROP', 'UPDATE', 'INSERT', 'ALTER', 'GRANT', 'REVOKE']
|
||||
if any(x in query.upper() for x in forbidden):
|
||||
error = "Only SELECT queries allowed in Web Runner."
|
||||
else:
|
||||
try:
|
||||
# Enforce limit if not present
|
||||
if 'LIMIT' not in query.upper():
|
||||
query += " LIMIT 50"
|
||||
|
||||
rows = query_db(db_name, query)
|
||||
if rows:
|
||||
columns = rows[0].keys()
|
||||
result = {'columns': columns, 'rows': rows}
|
||||
else:
|
||||
result = {'columns': [], 'rows': []}
|
||||
except Exception as e:
|
||||
error = str(e)
|
||||
|
||||
return render_template('admin/sql.html', result=result, error=error, query=query, db_name=db_name)
|
||||
35
web/routes/main.py
Normal file
@@ -0,0 +1,35 @@
|
||||
from flask import Blueprint, render_template, request, jsonify
|
||||
from web.services.stats_service import StatsService
|
||||
import time
|
||||
|
||||
bp = Blueprint('main', __name__)
|
||||
|
||||
@bp.route('/')
|
||||
def index():
|
||||
recent_matches = StatsService.get_recent_matches(limit=5)
|
||||
daily_counts = StatsService.get_daily_match_counts()
|
||||
live_matches = StatsService.get_live_matches()
|
||||
|
||||
# Convert rows to dict for easier JS usage
|
||||
heatmap_data = {}
|
||||
if daily_counts:
|
||||
for row in daily_counts:
|
||||
heatmap_data[row['day']] = row['count']
|
||||
|
||||
return render_template('home/index.html', recent_matches=recent_matches, heatmap_data=heatmap_data, live_matches=live_matches)
|
||||
|
||||
from web.services.etl_service import EtlService
|
||||
|
||||
@bp.route('/parse_match', methods=['POST'])
|
||||
def parse_match():
|
||||
url = request.form.get('url')
|
||||
if not url or '5eplay.com' not in url:
|
||||
return jsonify({'success': False, 'message': 'Invalid 5EPlay URL'})
|
||||
|
||||
# Trigger L1A.py with URL argument
|
||||
success, msg = EtlService.run_script('L1A.py', args=[url])
|
||||
|
||||
if success:
|
||||
return jsonify({'success': True, 'message': 'Match parsing completed successfully!'})
|
||||
else:
|
||||
return jsonify({'success': False, 'message': f'Error: {msg}'})
|
||||
135
web/routes/matches.py
Normal file
@@ -0,0 +1,135 @@
|
||||
from flask import Blueprint, render_template, request, Response
|
||||
from web.services.stats_service import StatsService
|
||||
from web.config import Config
|
||||
import json
|
||||
|
||||
bp = Blueprint('matches', __name__, url_prefix='/matches')
|
||||
|
||||
@bp.route('/')
|
||||
def index():
|
||||
page = request.args.get('page', 1, type=int)
|
||||
map_name = request.args.get('map')
|
||||
date_from = request.args.get('date_from')
|
||||
|
||||
# Fetch summary stats (for the dashboard)
|
||||
summary_stats = StatsService.get_team_stats_summary()
|
||||
|
||||
matches, total = StatsService.get_matches(page, Config.ITEMS_PER_PAGE, map_name, date_from)
|
||||
total_pages = (total + Config.ITEMS_PER_PAGE - 1) // Config.ITEMS_PER_PAGE
|
||||
|
||||
return render_template('matches/list.html',
|
||||
matches=matches, total=total, page=page, total_pages=total_pages,
|
||||
summary_stats=summary_stats)
|
||||
|
||||
@bp.route('/<match_id>')
|
||||
def detail(match_id):
|
||||
match = StatsService.get_match_detail(match_id)
|
||||
if not match:
|
||||
return "Match not found", 404
|
||||
|
||||
players = StatsService.get_match_players(match_id)
|
||||
# Convert sqlite3.Row objects to dicts to allow modification
|
||||
players = [dict(p) for p in players]
|
||||
|
||||
rounds = StatsService.get_match_rounds(match_id)
|
||||
|
||||
# --- Roster Identification ---
|
||||
# Fetch active roster to identify "Our Team" players
|
||||
from web.services.web_service import WebService
|
||||
lineups = WebService.get_lineups()
|
||||
# Assume we use the first/active lineup
|
||||
active_roster_ids = []
|
||||
if lineups:
|
||||
try:
|
||||
active_roster_ids = json.loads(lineups[0]['player_ids_json'])
|
||||
except:
|
||||
pass
|
||||
|
||||
# Mark roster players (Ensure strict string comparison)
|
||||
roster_set = set(str(uid) for uid in active_roster_ids)
|
||||
for p in players:
|
||||
p['is_in_roster'] = str(p['steam_id_64']) in roster_set
|
||||
|
||||
# --- Party Size Calculation ---
|
||||
# Only calculate party size for OUR ROSTER members.
|
||||
# Group roster members by match_team_id
|
||||
roster_parties = {} # match_team_id -> count of roster members
|
||||
|
||||
for p in players:
|
||||
if p['is_in_roster']:
|
||||
mtid = p.get('match_team_id')
|
||||
if mtid and mtid > 0:
|
||||
key = f"tid_{mtid}"
|
||||
roster_parties[key] = roster_parties.get(key, 0) + 1
|
||||
|
||||
# Assign party size ONLY to roster members
|
||||
for p in players:
|
||||
if p['is_in_roster']:
|
||||
mtid = p.get('match_team_id')
|
||||
if mtid and mtid > 0:
|
||||
p['party_size'] = roster_parties.get(f"tid_{mtid}", 1)
|
||||
else:
|
||||
p['party_size'] = 1 # Solo roster player
|
||||
else:
|
||||
p['party_size'] = 0 # Hide party info for non-roster players
|
||||
|
||||
# Organize players by Side (team_id)
|
||||
# team_id 1 = Team 1, team_id 2 = Team 2
|
||||
# Note: group_id 1/2 usually corresponds to Team 1/2.
|
||||
# Fallback to team_id if group_id is missing or 0 (legacy data compatibility)
|
||||
team1_players = [p for p in players if p.get('group_id') == 1]
|
||||
team2_players = [p for p in players if p.get('group_id') == 2]
|
||||
|
||||
# If group_id didn't work (empty lists), try team_id grouping (if team_id is 1/2 only)
|
||||
if not team1_players and not team2_players:
|
||||
team1_players = [p for p in players if p['team_id'] == 1]
|
||||
team2_players = [p for p in players if p['team_id'] == 2]
|
||||
|
||||
# Explicitly sort by Rating DESC
|
||||
team1_players.sort(key=lambda x: x.get('rating', 0) or 0, reverse=True)
|
||||
team2_players.sort(key=lambda x: x.get('rating', 0) or 0, reverse=True)
|
||||
|
||||
# New Data for Enhanced Detail View
|
||||
h2h_stats = StatsService.get_head_to_head_stats(match_id)
|
||||
round_details = StatsService.get_match_round_details(match_id)
|
||||
|
||||
# Convert H2H stats to a more usable format (nested dict)
|
||||
# h2h_matrix[attacker_id][victim_id] = kills
|
||||
h2h_matrix = {}
|
||||
if h2h_stats:
|
||||
for row in h2h_stats:
|
||||
a_id = row['attacker_steam_id']
|
||||
v_id = row['victim_steam_id']
|
||||
kills = row['kills']
|
||||
if a_id not in h2h_matrix: h2h_matrix[a_id] = {}
|
||||
h2h_matrix[a_id][v_id] = kills
|
||||
|
||||
# Create a mapping of SteamID -> Username for the template
|
||||
# We can use the players list we already have
|
||||
player_name_map = {}
|
||||
for p in players:
|
||||
sid = p.get('steam_id_64')
|
||||
name = p.get('username')
|
||||
if sid and name:
|
||||
player_name_map[str(sid)] = name
|
||||
|
||||
return render_template('matches/detail.html', match=match,
|
||||
team1_players=team1_players, team2_players=team2_players,
|
||||
rounds=rounds,
|
||||
h2h_matrix=h2h_matrix,
|
||||
round_details=round_details,
|
||||
player_name_map=player_name_map)
|
||||
|
||||
@bp.route('/<match_id>/raw')
|
||||
def raw_json(match_id):
|
||||
match = StatsService.get_match_detail(match_id)
|
||||
if not match:
|
||||
return "Match not found", 404
|
||||
|
||||
# Construct a raw object from available raw fields
|
||||
data = {
|
||||
'round_list': json.loads(match['round_list_raw']) if match['round_list_raw'] else None,
|
||||
'leetify_data': json.loads(match['leetify_data_raw']) if match['leetify_data_raw'] else None
|
||||
}
|
||||
|
||||
return Response(json.dumps(data, indent=2, ensure_ascii=False), mimetype='application/json')
|
||||
35
web/routes/opponents.py
Normal file
@@ -0,0 +1,35 @@
|
||||
from flask import Blueprint, render_template, request, jsonify
|
||||
from web.services.opponent_service import OpponentService
|
||||
from web.config import Config
|
||||
|
||||
bp = Blueprint('opponents', __name__, url_prefix='/opponents')
|
||||
|
||||
@bp.route('/')
|
||||
def index():
|
||||
page = request.args.get('page', 1, type=int)
|
||||
sort_by = request.args.get('sort', 'matches')
|
||||
search = request.args.get('search')
|
||||
|
||||
opponents, total = OpponentService.get_opponent_list(page, Config.ITEMS_PER_PAGE, sort_by, search)
|
||||
total_pages = (total + Config.ITEMS_PER_PAGE - 1) // Config.ITEMS_PER_PAGE
|
||||
|
||||
# Global stats for dashboard
|
||||
stats_summary = OpponentService.get_global_opponent_stats()
|
||||
map_stats = OpponentService.get_map_opponent_stats()
|
||||
|
||||
return render_template('opponents/index.html',
|
||||
opponents=opponents,
|
||||
total=total,
|
||||
page=page,
|
||||
total_pages=total_pages,
|
||||
sort_by=sort_by,
|
||||
stats_summary=stats_summary,
|
||||
map_stats=map_stats)
|
||||
|
||||
@bp.route('/<steam_id>')
|
||||
def detail(steam_id):
|
||||
data = OpponentService.get_opponent_detail(steam_id)
|
||||
if not data:
|
||||
return "Opponent not found", 404
|
||||
|
||||
return render_template('opponents/detail.html', **data)
|
||||
431
web/routes/players.py
Normal file
@@ -0,0 +1,431 @@
|
||||
from flask import Blueprint, render_template, request, jsonify, redirect, url_for, flash, current_app, session
|
||||
from web.services.stats_service import StatsService
|
||||
from web.services.feature_service import FeatureService
|
||||
from web.services.web_service import WebService
|
||||
from web.database import execute_db, query_db
|
||||
from web.config import Config
|
||||
from datetime import datetime
|
||||
import os
|
||||
from werkzeug.utils import secure_filename
|
||||
|
||||
bp = Blueprint('players', __name__, url_prefix='/players')
|
||||
|
||||
@bp.route('/')
|
||||
def index():
|
||||
page = request.args.get('page', 1, type=int)
|
||||
search = request.args.get('search')
|
||||
# Default sort by 'matches' as requested
|
||||
sort_by = request.args.get('sort', 'matches')
|
||||
|
||||
players, total = FeatureService.get_players_list(page, Config.ITEMS_PER_PAGE, sort_by, search)
|
||||
total_pages = (total + Config.ITEMS_PER_PAGE - 1) // Config.ITEMS_PER_PAGE
|
||||
|
||||
return render_template('players/list.html', players=players, total=total, page=page, total_pages=total_pages, sort_by=sort_by)
|
||||
|
||||
@bp.route('/<steam_id>', methods=['GET', 'POST'])
|
||||
def detail(steam_id):
|
||||
if request.method == 'POST':
|
||||
# Check if admin action
|
||||
if 'admin_action' in request.form and session.get('is_admin'):
|
||||
action = request.form.get('admin_action')
|
||||
|
||||
if action == 'update_profile':
|
||||
notes = request.form.get('notes')
|
||||
|
||||
# Handle Avatar Upload
|
||||
if 'avatar' in request.files:
|
||||
file = request.files['avatar']
|
||||
if file and file.filename:
|
||||
try:
|
||||
# Use steam_id as filename to ensure uniqueness per player
|
||||
# Preserve extension
|
||||
ext = os.path.splitext(file.filename)[1].lower()
|
||||
if not ext: ext = '.jpg'
|
||||
|
||||
filename = f"{steam_id}{ext}"
|
||||
upload_folder = os.path.join(current_app.root_path, 'static', 'avatars')
|
||||
os.makedirs(upload_folder, exist_ok=True)
|
||||
|
||||
file_path = os.path.join(upload_folder, filename)
|
||||
file.save(file_path)
|
||||
|
||||
# Generate URL (relative to web root)
|
||||
avatar_url = url_for('static', filename=f'avatars/{filename}')
|
||||
|
||||
# Update L2 DB directly (Immediate effect)
|
||||
execute_db('l2', "UPDATE dim_players SET avatar_url = ? WHERE steam_id_64 = ?", [avatar_url, steam_id])
|
||||
|
||||
flash('Avatar updated successfully.', 'success')
|
||||
except Exception as e:
|
||||
print(f"Avatar upload error: {e}")
|
||||
flash('Error uploading avatar.', 'error')
|
||||
|
||||
WebService.update_player_metadata(steam_id, notes=notes)
|
||||
flash('Profile updated.', 'success')
|
||||
|
||||
elif action == 'add_tag':
|
||||
tag = request.form.get('tag')
|
||||
if tag:
|
||||
meta = WebService.get_player_metadata(steam_id)
|
||||
tags = meta.get('tags', [])
|
||||
if tag not in tags:
|
||||
tags.append(tag)
|
||||
WebService.update_player_metadata(steam_id, tags=tags)
|
||||
flash('Tag added.', 'success')
|
||||
|
||||
elif action == 'remove_tag':
|
||||
tag = request.form.get('tag')
|
||||
if tag:
|
||||
meta = WebService.get_player_metadata(steam_id)
|
||||
tags = meta.get('tags', [])
|
||||
if tag in tags:
|
||||
tags.remove(tag)
|
||||
WebService.update_player_metadata(steam_id, tags=tags)
|
||||
flash('Tag removed.', 'success')
|
||||
|
||||
return redirect(url_for('players.detail', steam_id=steam_id))
|
||||
|
||||
# Add Comment
|
||||
username = request.form.get('username', 'Anonymous')
|
||||
content = request.form.get('content')
|
||||
if content:
|
||||
WebService.add_comment(None, username, 'player', steam_id, content)
|
||||
flash('Comment added!', 'success')
|
||||
return redirect(url_for('players.detail', steam_id=steam_id))
|
||||
|
||||
player = StatsService.get_player_info(steam_id)
|
||||
if not player:
|
||||
return "Player not found", 404
|
||||
|
||||
features = FeatureService.get_player_features(steam_id)
|
||||
|
||||
# --- New: Fetch Detailed Stats from L2 (Clutch, Multi-Kill, Multi-Assist) ---
|
||||
sql_l2 = """
|
||||
SELECT
|
||||
SUM(p.clutch_1v1) as c1, SUM(p.clutch_1v2) as c2, SUM(p.clutch_1v3) as c3, SUM(p.clutch_1v4) as c4, SUM(p.clutch_1v5) as c5,
|
||||
SUM(a.attempt_1v1) as att1, SUM(a.attempt_1v2) as att2, SUM(a.attempt_1v3) as att3, SUM(a.attempt_1v4) as att4, SUM(a.attempt_1v5) as att5,
|
||||
SUM(p.kill_2) as k2, SUM(p.kill_3) as k3, SUM(p.kill_4) as k4, SUM(p.kill_5) as k5,
|
||||
SUM(p.many_assists_cnt2) as a2, SUM(p.many_assists_cnt3) as a3, SUM(p.many_assists_cnt4) as a4, SUM(p.many_assists_cnt5) as a5,
|
||||
COUNT(*) as matches,
|
||||
SUM(p.round_total) as total_rounds
|
||||
FROM fact_match_players p
|
||||
LEFT JOIN fact_match_clutch_attempts a ON p.match_id = a.match_id AND p.steam_id_64 = a.steam_id_64
|
||||
WHERE p.steam_id_64 = ?
|
||||
"""
|
||||
l2_stats = query_db('l2', sql_l2, [steam_id], one=True)
|
||||
l2_stats = dict(l2_stats) if l2_stats else {}
|
||||
|
||||
# Fetch T/CT splits for comparison
|
||||
# Note: We use SUM(clutch...) as Total Clutch Wins. We don't have attempts, so 'Win Rate' is effectively Wins/Rounds or just Wins count.
|
||||
# User asked for 'Win Rate', but without attempts data, we'll provide Rate per Round or just Count.
|
||||
# Let's provide Rate per Round for Multi-Kill/Assist, and maybe just Count for Clutch?
|
||||
# User said: "总残局胜率...分t和ct在下方加入对比".
|
||||
# Since we found clutch == end in DB, we treat it as Wins. We can't calc Win %.
|
||||
# We will display "Clutch Wins / Round" or just "Clutch Wins".
|
||||
|
||||
sql_side = """
|
||||
SELECT
|
||||
'T' as side,
|
||||
SUM(clutch_1v1+clutch_1v2+clutch_1v3+clutch_1v4+clutch_1v5) as total_clutch,
|
||||
SUM(kill_2+kill_3+kill_4+kill_5) as total_multikill,
|
||||
SUM(many_assists_cnt2+many_assists_cnt3+many_assists_cnt4+many_assists_cnt5) as total_multiassist,
|
||||
SUM(round_total) as rounds
|
||||
FROM fact_match_players_t WHERE steam_id_64 = ?
|
||||
UNION ALL
|
||||
SELECT
|
||||
'CT' as side,
|
||||
SUM(clutch_1v1+clutch_1v2+clutch_1v3+clutch_1v4+clutch_1v5) as total_clutch,
|
||||
SUM(kill_2+kill_3+kill_4+kill_5) as total_multikill,
|
||||
SUM(many_assists_cnt2+many_assists_cnt3+many_assists_cnt4+many_assists_cnt5) as total_multiassist,
|
||||
SUM(round_total) as rounds
|
||||
FROM fact_match_players_ct WHERE steam_id_64 = ?
|
||||
"""
|
||||
side_rows = query_db('l2', sql_side, [steam_id, steam_id])
|
||||
side_stats = {row['side']: dict(row) for row in side_rows} if side_rows else {}
|
||||
|
||||
# Ensure basic stats fallback if features missing or incomplete
|
||||
basic = StatsService.get_player_basic_stats(steam_id)
|
||||
|
||||
from collections import defaultdict
|
||||
|
||||
if not features:
|
||||
# Fallback to defaultdict with basic stats
|
||||
features = defaultdict(lambda: None)
|
||||
if basic:
|
||||
features.update({
|
||||
'basic_avg_rating': basic.get('rating', 0),
|
||||
'basic_avg_kd': basic.get('kd', 0),
|
||||
'basic_avg_kast': basic.get('kast', 0),
|
||||
'basic_avg_adr': basic.get('adr', 0),
|
||||
})
|
||||
else:
|
||||
# Convert to defaultdict to handle missing keys gracefully (e.g. newly added columns)
|
||||
# Use lambda: None so that Jinja can check 'if value is not none'
|
||||
features = defaultdict(lambda: None, dict(features))
|
||||
|
||||
# If features exist but ADR is missing (not in L3), try to patch it from basic
|
||||
if 'basic_avg_adr' not in features or features['basic_avg_adr'] is None:
|
||||
features['basic_avg_adr'] = basic.get('adr', 0) if basic else 0
|
||||
|
||||
comments = WebService.get_comments('player', steam_id)
|
||||
metadata = WebService.get_player_metadata(steam_id)
|
||||
|
||||
# Roster Distribution Stats
|
||||
distribution = StatsService.get_roster_stats_distribution(steam_id)
|
||||
|
||||
# History for table (L2 Source) - Fetch ALL for history table/chart
|
||||
history_asc = StatsService.get_player_trend(steam_id, limit=1000)
|
||||
history = history_asc[::-1] if history_asc else []
|
||||
|
||||
# Calculate Map Stats
|
||||
map_stats = {}
|
||||
for match in history:
|
||||
m_name = match['map_name']
|
||||
if m_name not in map_stats:
|
||||
map_stats[m_name] = {'matches': 0, 'wins': 0, 'adr_sum': 0, 'rating_sum': 0}
|
||||
|
||||
map_stats[m_name]['matches'] += 1
|
||||
if match['is_win']:
|
||||
map_stats[m_name]['wins'] += 1
|
||||
map_stats[m_name]['adr_sum'] += (match['adr'] or 0)
|
||||
map_stats[m_name]['rating_sum'] += (match['rating'] or 0)
|
||||
|
||||
map_stats_list = []
|
||||
for m_name, data in map_stats.items():
|
||||
cnt = data['matches']
|
||||
map_stats_list.append({
|
||||
'map_name': m_name,
|
||||
'matches': cnt,
|
||||
'win_rate': data['wins'] / cnt,
|
||||
'adr': data['adr_sum'] / cnt,
|
||||
'rating': data['rating_sum'] / cnt
|
||||
})
|
||||
map_stats_list.sort(key=lambda x: x['matches'], reverse=True)
|
||||
|
||||
# --- New: Recent Performance Stats ---
|
||||
recent_stats = StatsService.get_recent_performance_stats(steam_id)
|
||||
|
||||
return render_template('players/profile.html',
|
||||
player=player,
|
||||
features=features,
|
||||
comments=comments,
|
||||
metadata=metadata,
|
||||
history=history,
|
||||
distribution=distribution,
|
||||
map_stats=map_stats_list,
|
||||
l2_stats=l2_stats,
|
||||
side_stats=side_stats,
|
||||
recent_stats=recent_stats)
|
||||
|
||||
@bp.route('/comment/<int:comment_id>/like', methods=['POST'])
|
||||
def like_comment(comment_id):
|
||||
WebService.like_comment(comment_id)
|
||||
return jsonify({'success': True})
|
||||
|
||||
@bp.route('/<steam_id>/charts_data')
|
||||
def charts_data(steam_id):
|
||||
# ... (existing code) ...
|
||||
# Trend Data
|
||||
trends = StatsService.get_player_trend(steam_id, limit=1000)
|
||||
|
||||
# Radar Data (Construct from features)
|
||||
features = FeatureService.get_player_features(steam_id)
|
||||
radar_data = {}
|
||||
radar_dist = FeatureService.get_roster_features_distribution(steam_id)
|
||||
|
||||
if features:
|
||||
# Dimensions: STA, BAT, HPS, PTL, T/CT, UTIL
|
||||
# Use calculated scores (0-100 scale)
|
||||
|
||||
# Helper to get score safely
|
||||
def get_score(key):
|
||||
val = features[key] if key in features.keys() else 0
|
||||
return float(val) if val else 0
|
||||
|
||||
radar_data = {
|
||||
'STA': get_score('score_sta'),
|
||||
'BAT': get_score('score_bat'),
|
||||
'HPS': get_score('score_hps'),
|
||||
'PTL': get_score('score_ptl'),
|
||||
'SIDE': get_score('score_tct'),
|
||||
'UTIL': get_score('score_util'),
|
||||
'ECO': get_score('score_eco'),
|
||||
'PACE': get_score('score_pace')
|
||||
}
|
||||
|
||||
trend_labels = []
|
||||
trend_values = []
|
||||
match_indices = []
|
||||
for i, row in enumerate(trends):
|
||||
t = dict(row) # Convert sqlite3.Row to dict
|
||||
# Format: Match #Index (Map)
|
||||
# Use backend-provided match_index if available, or just index + 1
|
||||
idx = t.get('match_index', i + 1)
|
||||
map_name = t.get('map_name', 'Unknown')
|
||||
trend_labels.append(f"#{idx} {map_name}")
|
||||
trend_values.append(t['rating'])
|
||||
|
||||
return jsonify({
|
||||
'trend': {'labels': trend_labels, 'values': trend_values},
|
||||
'radar': radar_data,
|
||||
'radar_dist': radar_dist
|
||||
})
|
||||
|
||||
# --- API for Comparison ---
|
||||
@bp.route('/api/search')
|
||||
def api_search():
|
||||
query = request.args.get('q', '')
|
||||
if len(query) < 2:
|
||||
return jsonify([])
|
||||
|
||||
players, _ = FeatureService.get_players_list(page=1, per_page=10, search=query)
|
||||
# Return minimal data
|
||||
results = [{'steam_id': p['steam_id_64'], 'username': p['username'], 'avatar_url': p['avatar_url']} for p in players]
|
||||
return jsonify(results)
|
||||
|
||||
@bp.route('/api/batch_stats')
|
||||
def api_batch_stats():
|
||||
steam_ids = request.args.get('ids', '').split(',')
|
||||
stats = []
|
||||
for sid in steam_ids:
|
||||
if not sid: continue
|
||||
f = FeatureService.get_player_features(sid)
|
||||
p = StatsService.get_player_info(sid)
|
||||
|
||||
if f and p:
|
||||
# Convert sqlite3.Row to dict if necessary
|
||||
if hasattr(f, 'keys'): # It's a Row object or similar
|
||||
f = dict(f)
|
||||
|
||||
# 1. Radar Scores (Normalized 0-100)
|
||||
# Use safe conversion with default 0 if None
|
||||
# Force 0.0 if value is 0 or None to ensure JSON compatibility
|
||||
radar = {
|
||||
'STA': float(f.get('score_sta') or 0.0),
|
||||
'BAT': float(f.get('score_bat') or 0.0),
|
||||
'HPS': float(f.get('score_hps') or 0.0),
|
||||
'PTL': float(f.get('score_ptl') or 0.0),
|
||||
'SIDE': float(f.get('score_tct') or 0.0),
|
||||
'UTIL': float(f.get('score_util') or 0.0)
|
||||
}
|
||||
|
||||
# 2. Basic Stats for Table
|
||||
basic = {
|
||||
'rating': float(f.get('basic_avg_rating') or 0),
|
||||
'kd': float(f.get('basic_avg_kd') or 0),
|
||||
'adr': float(f.get('basic_avg_adr') or 0),
|
||||
'kast': float(f.get('basic_avg_kast') or 0),
|
||||
'hs_rate': float(f.get('basic_headshot_rate') or 0),
|
||||
'fk_rate': float(f.get('basic_first_kill_rate') or 0),
|
||||
'matches': int(f.get('matches_played') or 0)
|
||||
}
|
||||
|
||||
# 3. Side Stats
|
||||
side = {
|
||||
'rating_t': float(f.get('side_rating_t') or 0),
|
||||
'rating_ct': float(f.get('side_rating_ct') or 0),
|
||||
'kd_t': float(f.get('side_kd_t') or 0),
|
||||
'kd_ct': float(f.get('side_kd_ct') or 0),
|
||||
'entry_t': float(f.get('side_entry_rate_t') or 0),
|
||||
'entry_ct': float(f.get('side_entry_rate_ct') or 0),
|
||||
'kast_t': float(f.get('side_kast_t') or 0),
|
||||
'kast_ct': float(f.get('side_kast_ct') or 0),
|
||||
'adr_t': float(f.get('side_adr_t') or 0),
|
||||
'adr_ct': float(f.get('side_adr_ct') or 0)
|
||||
}
|
||||
|
||||
# 4. Detailed Stats (Expanded for Data Center - Aligned with Profile)
|
||||
detailed = {
|
||||
# Row 1
|
||||
'rating_t': float(f.get('side_rating_t') or 0),
|
||||
'rating_ct': float(f.get('side_rating_ct') or 0),
|
||||
'kd_t': float(f.get('side_kd_t') or 0),
|
||||
'kd_ct': float(f.get('side_kd_ct') or 0),
|
||||
|
||||
# Row 2
|
||||
'win_rate_t': float(f.get('side_win_rate_t') or 0),
|
||||
'win_rate_ct': float(f.get('side_win_rate_ct') or 0),
|
||||
'first_kill_t': float(f.get('side_first_kill_rate_t') or 0),
|
||||
'first_kill_ct': float(f.get('side_first_kill_rate_ct') or 0),
|
||||
|
||||
# Row 3
|
||||
'first_death_t': float(f.get('side_first_death_rate_t') or 0),
|
||||
'first_death_ct': float(f.get('side_first_death_rate_ct') or 0),
|
||||
'kast_t': float(f.get('side_kast_t') or 0),
|
||||
'kast_ct': float(f.get('side_kast_ct') or 0),
|
||||
|
||||
# Row 4
|
||||
'rws_t': float(f.get('side_rws_t') or 0),
|
||||
'rws_ct': float(f.get('side_rws_ct') or 0),
|
||||
'multikill_t': float(f.get('side_multikill_rate_t') or 0),
|
||||
'multikill_ct': float(f.get('side_multikill_rate_ct') or 0),
|
||||
|
||||
# Row 5
|
||||
'hs_t': float(f.get('side_headshot_rate_t') or 0),
|
||||
'hs_ct': float(f.get('side_headshot_rate_ct') or 0),
|
||||
'obj_t': float(f.get('side_obj_t') or 0),
|
||||
'obj_ct': float(f.get('side_obj_ct') or 0)
|
||||
}
|
||||
|
||||
stats.append({
|
||||
'username': p['username'],
|
||||
'steam_id': sid,
|
||||
'avatar_url': p['avatar_url'],
|
||||
'radar': radar,
|
||||
'basic': basic,
|
||||
'side': side,
|
||||
'detailed': detailed
|
||||
})
|
||||
return jsonify(stats)
|
||||
|
||||
@bp.route('/api/batch_map_stats')
|
||||
def api_batch_map_stats():
|
||||
steam_ids = request.args.get('ids', '').split(',')
|
||||
steam_ids = [sid for sid in steam_ids if sid]
|
||||
|
||||
if not steam_ids:
|
||||
return jsonify({})
|
||||
|
||||
# Query L2 for Map Stats grouped by Player and Map
|
||||
# We need to construct a query that can be executed via execute_db or query_db
|
||||
# Since StatsService usually handles this, we can write raw SQL here or delegate.
|
||||
# Raw SQL is easier for this specific aggregation.
|
||||
|
||||
placeholders = ','.join('?' for _ in steam_ids)
|
||||
sql = f"""
|
||||
SELECT
|
||||
mp.steam_id_64,
|
||||
m.map_name,
|
||||
COUNT(*) as matches,
|
||||
SUM(CASE WHEN mp.is_win THEN 1 ELSE 0 END) as wins,
|
||||
AVG(mp.rating) as avg_rating,
|
||||
AVG(mp.kd_ratio) as avg_kd,
|
||||
AVG(mp.adr) as avg_adr
|
||||
FROM fact_match_players mp
|
||||
JOIN fact_matches m ON mp.match_id = m.match_id
|
||||
WHERE mp.steam_id_64 IN ({placeholders})
|
||||
GROUP BY mp.steam_id_64, m.map_name
|
||||
ORDER BY matches DESC
|
||||
"""
|
||||
|
||||
# We need to import query_db if not available in current scope (it is imported at top)
|
||||
from web.database import query_db
|
||||
rows = query_db('l2', sql, steam_ids)
|
||||
|
||||
# Structure: {steam_id: [ {map: 'de_mirage', stats...}, ... ]}
|
||||
result = {}
|
||||
for r in rows:
|
||||
sid = r['steam_id_64']
|
||||
if sid not in result:
|
||||
result[sid] = []
|
||||
|
||||
result[sid].append({
|
||||
'map_name': r['map_name'],
|
||||
'matches': r['matches'],
|
||||
'win_rate': (r['wins'] / r['matches']) if r['matches'] else 0,
|
||||
'rating': r['avg_rating'],
|
||||
'kd': r['avg_kd'],
|
||||
'adr': r['avg_adr']
|
||||
})
|
||||
|
||||
return jsonify(result)
|
||||
103
web/routes/tactics.py
Normal file
@@ -0,0 +1,103 @@
|
||||
from flask import Blueprint, render_template, request, jsonify
|
||||
from web.services.web_service import WebService
|
||||
from web.services.stats_service import StatsService
|
||||
from web.services.feature_service import FeatureService
|
||||
import json
|
||||
|
||||
bp = Blueprint('tactics', __name__, url_prefix='/tactics')
|
||||
|
||||
@bp.route('/')
|
||||
def index():
|
||||
return render_template('tactics/index.html')
|
||||
|
||||
# API: Analyze Lineup
|
||||
@bp.route('/api/analyze', methods=['POST'])
|
||||
def api_analyze():
|
||||
data = request.json
|
||||
steam_ids = data.get('steam_ids', [])
|
||||
|
||||
if not steam_ids:
|
||||
return jsonify({'error': 'No players selected'}), 400
|
||||
|
||||
# 1. Get Basic Info & Stats
|
||||
players = StatsService.get_players_by_ids(steam_ids)
|
||||
player_data = []
|
||||
|
||||
total_rating = 0
|
||||
total_kd = 0
|
||||
total_adr = 0
|
||||
count = 0
|
||||
|
||||
for p in players:
|
||||
p_dict = dict(p)
|
||||
# Fetch L3 features
|
||||
f = FeatureService.get_player_features(p_dict['steam_id_64'])
|
||||
stats = dict(f) if f else {}
|
||||
p_dict['stats'] = stats
|
||||
player_data.append(p_dict)
|
||||
|
||||
if stats:
|
||||
total_rating += stats.get('basic_avg_rating', 0) or 0
|
||||
total_kd += stats.get('basic_avg_kd', 0) or 0
|
||||
total_adr += stats.get('basic_avg_adr', 0) or 0
|
||||
count += 1
|
||||
|
||||
# 2. Shared Matches
|
||||
shared_matches = StatsService.get_shared_matches(steam_ids)
|
||||
# They are already dicts now with 'result_str' and 'is_win'
|
||||
|
||||
# 3. Aggregates
|
||||
avg_stats = {
|
||||
'rating': total_rating / count if count else 0,
|
||||
'kd': total_kd / count if count else 0,
|
||||
'adr': total_adr / count if count else 0
|
||||
}
|
||||
|
||||
# 4. Map Stats Calculation
|
||||
map_stats = {} # {map_name: {'count': 0, 'wins': 0}}
|
||||
total_shared_matches = len(shared_matches)
|
||||
|
||||
for m in shared_matches:
|
||||
map_name = m['map_name']
|
||||
if map_name not in map_stats:
|
||||
map_stats[map_name] = {'count': 0, 'wins': 0}
|
||||
|
||||
map_stats[map_name]['count'] += 1
|
||||
if m['is_win']:
|
||||
map_stats[map_name]['wins'] += 1
|
||||
|
||||
# Convert to list for frontend
|
||||
map_stats_list = []
|
||||
for k, v in map_stats.items():
|
||||
win_rate = (v['wins'] / v['count'] * 100) if v['count'] > 0 else 0
|
||||
map_stats_list.append({
|
||||
'map_name': k,
|
||||
'count': v['count'],
|
||||
'wins': v['wins'],
|
||||
'win_rate': win_rate
|
||||
})
|
||||
|
||||
# Sort by count desc
|
||||
map_stats_list.sort(key=lambda x: x['count'], reverse=True)
|
||||
|
||||
return jsonify({
|
||||
'players': player_data,
|
||||
'shared_matches': [dict(m) for m in shared_matches],
|
||||
'avg_stats': avg_stats,
|
||||
'map_stats': map_stats_list,
|
||||
'total_shared_matches': total_shared_matches
|
||||
})
|
||||
|
||||
# API: Save Board
|
||||
@bp.route('/save_board', methods=['POST'])
|
||||
def save_board():
|
||||
data = request.json
|
||||
title = data.get('title', 'Untitled Strategy')
|
||||
map_name = data.get('map_name', 'de_mirage')
|
||||
markers = data.get('markers')
|
||||
|
||||
if not markers:
|
||||
return jsonify({'success': False, 'message': 'No markers to save'})
|
||||
|
||||
WebService.save_strategy_board(title, map_name, json.dumps(markers), 'Anonymous')
|
||||
return jsonify({'success': True, 'message': 'Board saved successfully'})
|
||||
225
web/routes/teams.py
Normal file
@@ -0,0 +1,225 @@
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, session
|
||||
from web.services.web_service import WebService
|
||||
from web.services.stats_service import StatsService
|
||||
from web.services.feature_service import FeatureService
|
||||
import json
|
||||
|
||||
bp = Blueprint('teams', __name__, url_prefix='/teams')
|
||||
|
||||
# --- API Endpoints ---
|
||||
@bp.route('/api/search')
|
||||
def api_search():
|
||||
query = request.args.get('q', '').strip() # Strip whitespace
|
||||
print(f"DEBUG: Search Query Received: '{query}'") # Debug Log
|
||||
|
||||
if len(query) < 2:
|
||||
return jsonify([])
|
||||
|
||||
# Use L2 database for fuzzy search on username
|
||||
from web.services.stats_service import StatsService
|
||||
# Support sorting by matches for better "Find Player" experience
|
||||
sort_by = request.args.get('sort', 'matches')
|
||||
|
||||
print(f"DEBUG: Calling StatsService.get_players with search='{query}'")
|
||||
players, total = StatsService.get_players(page=1, per_page=50, search=query, sort_by=sort_by)
|
||||
print(f"DEBUG: Found {len(players)} players (Total: {total})")
|
||||
|
||||
# Format for frontend
|
||||
results = []
|
||||
for p in players:
|
||||
# Convert sqlite3.Row to dict to avoid AttributeError
|
||||
p_dict = dict(p)
|
||||
|
||||
# Fetch feature stats for better preview
|
||||
f = FeatureService.get_player_features(p_dict['steam_id_64'])
|
||||
|
||||
# Manually attach match count if not present
|
||||
matches_played = p_dict.get('matches_played', 0)
|
||||
|
||||
results.append({
|
||||
'steam_id': p_dict['steam_id_64'],
|
||||
'name': p_dict['username'],
|
||||
'avatar': p_dict['avatar_url'] or 'https://avatars.steamstatic.com/fef49e7fa7e1997310d705b2a6158ff8dc1cdfeb_full.jpg',
|
||||
'rating': (f['basic_avg_rating'] if f else 0.0),
|
||||
'matches': matches_played
|
||||
})
|
||||
|
||||
# Python-side sort if DB sort didn't work for 'matches' (since dim_players doesn't have match_count)
|
||||
if sort_by == 'matches':
|
||||
# We need to fetch match counts to sort!
|
||||
# This is expensive for search results but necessary for "matches sample sort"
|
||||
# Let's batch fetch counts for these 50 players
|
||||
steam_ids = [r['steam_id'] for r in results]
|
||||
if steam_ids:
|
||||
from web.services.web_service import query_db
|
||||
placeholders = ','.join('?' for _ in steam_ids)
|
||||
sql = f"SELECT steam_id_64, COUNT(*) as cnt FROM fact_match_players WHERE steam_id_64 IN ({placeholders}) GROUP BY steam_id_64"
|
||||
counts = query_db('l2', sql, steam_ids)
|
||||
cnt_map = {r['steam_id_64']: r['cnt'] for r in counts}
|
||||
|
||||
for r in results:
|
||||
r['matches'] = cnt_map.get(r['steam_id'], 0)
|
||||
|
||||
results.sort(key=lambda x: x['matches'], reverse=True)
|
||||
|
||||
print(f"DEBUG: Returning {len(results)} results")
|
||||
return jsonify(results)
|
||||
|
||||
@bp.route('/api/roster', methods=['GET', 'POST'])
|
||||
def api_roster():
|
||||
# Assume single team mode, always operating on ID=1 or the first lineup
|
||||
lineups = WebService.get_lineups()
|
||||
if not lineups:
|
||||
# Auto-create default team if none exists
|
||||
WebService.save_lineup("My Team", "Default Roster", [])
|
||||
lineups = WebService.get_lineups()
|
||||
|
||||
target_team = dict(lineups[0]) # Get the latest one
|
||||
|
||||
if request.method == 'POST':
|
||||
# Admin Check
|
||||
if not session.get('is_admin'):
|
||||
return jsonify({'error': 'Unauthorized'}), 403
|
||||
|
||||
data = request.json
|
||||
action = data.get('action')
|
||||
steam_id = data.get('steam_id')
|
||||
|
||||
current_ids = []
|
||||
try:
|
||||
current_ids = json.loads(target_team['player_ids_json'])
|
||||
except:
|
||||
pass
|
||||
|
||||
if action == 'add':
|
||||
if steam_id not in current_ids:
|
||||
current_ids.append(steam_id)
|
||||
elif action == 'remove':
|
||||
if steam_id in current_ids:
|
||||
current_ids.remove(steam_id)
|
||||
|
||||
# Pass lineup_id=target_team['id'] to update existing lineup
|
||||
WebService.save_lineup(target_team['name'], target_team['description'], current_ids, lineup_id=target_team['id'])
|
||||
return jsonify({'status': 'success', 'roster': current_ids})
|
||||
|
||||
# GET: Return detailed player info
|
||||
try:
|
||||
print(f"DEBUG: api_roster GET - Target Team: {target_team.get('id')}")
|
||||
p_ids_json = target_team.get('player_ids_json', '[]')
|
||||
p_ids = json.loads(p_ids_json)
|
||||
print(f"DEBUG: Player IDs: {p_ids}")
|
||||
|
||||
players = StatsService.get_players_by_ids(p_ids)
|
||||
print(f"DEBUG: Players fetched: {len(players) if players else 0}")
|
||||
|
||||
# Add extra stats needed for cards
|
||||
enriched = []
|
||||
if players:
|
||||
for p in players:
|
||||
try:
|
||||
# Convert sqlite3.Row to dict
|
||||
p_dict = dict(p)
|
||||
# print(f"DEBUG: Processing player {p_dict.get('steam_id_64')}")
|
||||
|
||||
# Get features for Rating/KD display
|
||||
f = FeatureService.get_player_features(p_dict['steam_id_64'])
|
||||
# f might be a Row object, convert it
|
||||
p_dict['stats'] = dict(f) if f else {}
|
||||
|
||||
# Fetch Metadata (Tags)
|
||||
meta = WebService.get_player_metadata(p_dict['steam_id_64'])
|
||||
p_dict['tags'] = meta.get('tags', [])
|
||||
|
||||
enriched.append(p_dict)
|
||||
except Exception as inner_e:
|
||||
print(f"ERROR: Processing player failed: {inner_e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'team': dict(target_team), # Ensure target_team is dict too
|
||||
'roster': enriched
|
||||
})
|
||||
except Exception as e:
|
||||
print(f"CRITICAL ERROR in api_roster: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
# --- Views ---
|
||||
@bp.route('/')
|
||||
def index():
|
||||
# Directly render the Clubhouse SPA
|
||||
return render_template('teams/clubhouse.html')
|
||||
|
||||
# Deprecated routes (kept for compatibility if needed, but hidden)
|
||||
@bp.route('/list')
|
||||
def list_view():
|
||||
lineups = WebService.get_lineups()
|
||||
# ... existing logic ...
|
||||
return render_template('teams/list.html', lineups=lineups)
|
||||
|
||||
|
||||
@bp.route('/<int:lineup_id>')
|
||||
def detail(lineup_id):
|
||||
lineup = WebService.get_lineup(lineup_id)
|
||||
if not lineup:
|
||||
return "Lineup not found", 404
|
||||
|
||||
p_ids = json.loads(lineup['player_ids_json'])
|
||||
players = StatsService.get_players_by_ids(p_ids)
|
||||
|
||||
# Shared Matches
|
||||
shared_matches = StatsService.get_shared_matches(p_ids)
|
||||
|
||||
# Calculate Aggregate Stats
|
||||
agg_stats = {
|
||||
'avg_rating': 0,
|
||||
'avg_kd': 0,
|
||||
'avg_kast': 0
|
||||
}
|
||||
|
||||
radar_data = {
|
||||
'STA': 0, 'BAT': 0, 'HPS': 0, 'PTL': 0, 'SIDE': 0, 'UTIL': 0
|
||||
}
|
||||
|
||||
player_features = []
|
||||
|
||||
if players:
|
||||
count = len(players)
|
||||
total_rating = 0
|
||||
total_kd = 0
|
||||
total_kast = 0
|
||||
|
||||
# Radar totals
|
||||
r_totals = {k: 0 for k in radar_data}
|
||||
|
||||
for p in players:
|
||||
# Fetch L3 features for each player
|
||||
f = FeatureService.get_player_features(p['steam_id_64'])
|
||||
if f:
|
||||
player_features.append(f)
|
||||
total_rating += f['basic_avg_rating'] or 0
|
||||
total_kd += f['basic_avg_kd'] or 0
|
||||
total_kast += f['basic_avg_kast'] or 0
|
||||
|
||||
# Radar accumulation
|
||||
r_totals['STA'] += f['basic_avg_rating'] or 0
|
||||
r_totals['BAT'] += f['bat_avg_duel_win_rate'] or 0
|
||||
r_totals['HPS'] += f['hps_clutch_win_rate_1v1'] or 0
|
||||
r_totals['PTL'] += f['ptl_pistol_win_rate'] or 0
|
||||
r_totals['SIDE'] += f['side_rating_ct'] or 0
|
||||
r_totals['UTIL'] += f['util_usage_rate'] or 0
|
||||
else:
|
||||
player_features.append(None)
|
||||
|
||||
if count > 0:
|
||||
agg_stats['avg_rating'] = total_rating / count
|
||||
agg_stats['avg_kd'] = total_kd / count
|
||||
agg_stats['avg_kast'] = total_kast / count
|
||||
|
||||
for k in radar_data:
|
||||
radar_data[k] = r_totals[k] / count
|
||||
|
||||
return render_template('teams/detail.html', lineup=lineup, players=players, agg_stats=agg_stats, shared_matches=shared_matches, radar_data=radar_data)
|
||||
32
web/routes/wiki.py
Normal file
@@ -0,0 +1,32 @@
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, session
|
||||
from web.services.web_service import WebService
|
||||
from web.auth import admin_required
|
||||
|
||||
bp = Blueprint('wiki', __name__, url_prefix='/wiki')
|
||||
|
||||
@bp.route('/')
|
||||
def index():
|
||||
pages = WebService.get_all_wiki_pages()
|
||||
return render_template('wiki/index.html', pages=pages)
|
||||
|
||||
@bp.route('/view/<path:page_path>')
|
||||
def view(page_path):
|
||||
page = WebService.get_wiki_page(page_path)
|
||||
if not page:
|
||||
# If admin, offer to create
|
||||
if session.get('is_admin'):
|
||||
return redirect(url_for('wiki.edit', page_path=page_path))
|
||||
return "Page not found", 404
|
||||
return render_template('wiki/view.html', page=page)
|
||||
|
||||
@bp.route('/edit/<path:page_path>', methods=['GET', 'POST'])
|
||||
@admin_required
|
||||
def edit(page_path):
|
||||
if request.method == 'POST':
|
||||
title = request.form.get('title')
|
||||
content = request.form.get('content')
|
||||
WebService.save_wiki_page(page_path, title, content, 'admin')
|
||||
return redirect(url_for('wiki.view', page_path=page_path))
|
||||
|
||||
page = WebService.get_wiki_page(page_path)
|
||||
return render_template('wiki/edit.html', page=page, page_path=page_path)
|
||||
40
web/services/etl_service.py
Normal file
@@ -0,0 +1,40 @@
|
||||
import subprocess
|
||||
import os
|
||||
import sys
|
||||
from web.config import Config
|
||||
|
||||
class EtlService:
|
||||
@staticmethod
|
||||
def run_script(script_name, args=None):
|
||||
"""
|
||||
Executes an ETL script located in the ETL directory.
|
||||
Returns (success, message)
|
||||
"""
|
||||
script_path = os.path.join(Config.BASE_DIR, 'ETL', script_name)
|
||||
|
||||
if not os.path.exists(script_path):
|
||||
return False, f"Script not found: {script_path}"
|
||||
|
||||
try:
|
||||
# Use the same python interpreter
|
||||
python_exe = sys.executable
|
||||
|
||||
cmd = [python_exe, script_path]
|
||||
if args:
|
||||
cmd.extend(args)
|
||||
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
cwd=Config.BASE_DIR,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=300 # 5 min timeout
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
return True, f"Success:\n{result.stdout}"
|
||||
else:
|
||||
return False, f"Failed (Code {result.returncode}):\n{result.stderr}\n{result.stdout}"
|
||||
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
2256
web/services/feature_service.py
Normal file
404
web/services/opponent_service.py
Normal file
@@ -0,0 +1,404 @@
|
||||
from web.database import query_db
|
||||
from web.services.web_service import WebService
|
||||
import json
|
||||
|
||||
class OpponentService:
|
||||
@staticmethod
|
||||
def _get_active_roster_ids():
|
||||
lineups = WebService.get_lineups()
|
||||
active_roster_ids = []
|
||||
if lineups:
|
||||
try:
|
||||
raw_ids = json.loads(lineups[0]['player_ids_json'])
|
||||
active_roster_ids = [str(uid) for uid in raw_ids]
|
||||
except:
|
||||
pass
|
||||
return active_roster_ids
|
||||
|
||||
@staticmethod
|
||||
def get_opponent_list(page=1, per_page=20, sort_by='matches', search=None):
|
||||
roster_ids = OpponentService._get_active_roster_ids()
|
||||
if not roster_ids:
|
||||
return [], 0
|
||||
|
||||
# Placeholders
|
||||
roster_ph = ','.join('?' for _ in roster_ids)
|
||||
|
||||
# 1. Identify Matches involving our roster (at least 1 member? usually 2 for 'team' match)
|
||||
# Let's say at least 1 for broader coverage as requested ("1 match sample")
|
||||
# But "Our Team" usually implies the entity. Let's stick to matches where we can identify "Us".
|
||||
# If we use >=1, we catch solo Q matches of roster members. The user said "Non-team members or 1 match sample",
|
||||
# but implied "facing different our team lineups".
|
||||
# Let's use the standard "candidate matches" logic (>=2 roster members) to represent "The Team".
|
||||
# OR, if user wants "Opponent Analysis" for even 1 match, maybe they mean ANY match in DB?
|
||||
# "Left Top add Opponent Analysis... (non-team member or 1 sample)"
|
||||
# This implies we analyze PLAYERS who are NOT us.
|
||||
# Let's stick to matches where >= 1 roster member played, to define "Us" vs "Them".
|
||||
|
||||
# Actually, let's look at ALL matches in DB, and any player NOT in active roster is an "Opponent".
|
||||
# This covers "1 sample".
|
||||
|
||||
# Query:
|
||||
# Select all players who are NOT in active roster.
|
||||
# Group by steam_id.
|
||||
# Aggregate stats.
|
||||
|
||||
where_clauses = [f"CAST(mp.steam_id_64 AS TEXT) NOT IN ({roster_ph})"]
|
||||
args = list(roster_ids)
|
||||
|
||||
if search:
|
||||
where_clauses.append("(LOWER(p.username) LIKE LOWER(?) OR mp.steam_id_64 LIKE ?)")
|
||||
args.extend([f"%{search}%", f"%{search}%"])
|
||||
|
||||
where_str = " AND ".join(where_clauses)
|
||||
|
||||
# Sort mapping
|
||||
sort_sql = "matches DESC"
|
||||
if sort_by == 'rating':
|
||||
sort_sql = "avg_rating DESC"
|
||||
elif sort_by == 'kd':
|
||||
sort_sql = "avg_kd DESC"
|
||||
elif sort_by == 'win_rate':
|
||||
sort_sql = "win_rate DESC"
|
||||
|
||||
# Main Aggregation Query
|
||||
# We need to join fact_matches to get match info (win/loss, elo) if needed,
|
||||
# but fact_match_players has is_win (boolean) usually? No, it has team_id.
|
||||
# We need to determine if THEY won.
|
||||
# fact_match_players doesn't store is_win directly in schema (I should check schema, but stats_service calculates it).
|
||||
# Wait, stats_service.get_player_trend uses `mp.is_win`?
|
||||
# Let's check schema. `fact_match_players` usually has `match_id`, `team_id`.
|
||||
# `fact_matches` has `winner_team`.
|
||||
# So we join.
|
||||
|
||||
offset = (page - 1) * per_page
|
||||
|
||||
sql = f"""
|
||||
SELECT
|
||||
mp.steam_id_64,
|
||||
MAX(p.username) as username,
|
||||
MAX(p.avatar_url) as avatar_url,
|
||||
COUNT(DISTINCT mp.match_id) as matches,
|
||||
AVG(mp.rating) as avg_rating,
|
||||
AVG(mp.kd_ratio) as avg_kd,
|
||||
AVG(mp.adr) as avg_adr,
|
||||
SUM(CASE WHEN mp.is_win = 1 THEN 1 ELSE 0 END) as wins,
|
||||
AVG(NULLIF(COALESCE(fmt_gid.group_origin_elo, fmt_tid.group_origin_elo), 0)) as avg_match_elo
|
||||
FROM fact_match_players mp
|
||||
JOIN fact_matches m ON mp.match_id = m.match_id
|
||||
LEFT JOIN dim_players p ON mp.steam_id_64 = p.steam_id_64
|
||||
LEFT JOIN fact_match_teams fmt_gid ON mp.match_id = fmt_gid.match_id AND fmt_gid.group_id = mp.team_id
|
||||
LEFT JOIN fact_match_teams fmt_tid ON mp.match_id = fmt_tid.match_id AND fmt_tid.group_tid = mp.match_team_id
|
||||
WHERE {where_str}
|
||||
GROUP BY mp.steam_id_64
|
||||
ORDER BY {sort_sql}
|
||||
LIMIT ? OFFSET ?
|
||||
"""
|
||||
|
||||
# Count query
|
||||
count_sql = f"""
|
||||
SELECT COUNT(DISTINCT mp.steam_id_64) as cnt
|
||||
FROM fact_match_players mp
|
||||
LEFT JOIN dim_players p ON mp.steam_id_64 = p.steam_id_64
|
||||
WHERE {where_str}
|
||||
"""
|
||||
|
||||
query_args = args + [per_page, offset]
|
||||
rows = query_db('l2', sql, query_args)
|
||||
total = query_db('l2', count_sql, args, one=True)['cnt']
|
||||
|
||||
# Post-process for derived stats
|
||||
results = []
|
||||
# Resolve avatar fallback from local static if missing
|
||||
from web.services.stats_service import StatsService
|
||||
for r in rows or []:
|
||||
d = dict(r)
|
||||
d['win_rate'] = (d['wins'] / d['matches']) if d['matches'] else 0
|
||||
d['avatar_url'] = StatsService.resolve_avatar_url(d.get('steam_id_64'), d.get('avatar_url'))
|
||||
results.append(d)
|
||||
|
||||
return results, total
|
||||
|
||||
@staticmethod
|
||||
def get_global_opponent_stats():
|
||||
"""
|
||||
Calculates aggregate statistics for ALL opponents.
|
||||
Returns:
|
||||
{
|
||||
'elo_dist': {'<1200': 10, '1200-1500': 20...},
|
||||
'rating_dist': {'<0.8': 5, '0.8-1.0': 15...},
|
||||
'win_rate_dist': {'<40%': 5, '40-60%': 10...} (Opponent Win Rate)
|
||||
}
|
||||
"""
|
||||
roster_ids = OpponentService._get_active_roster_ids()
|
||||
if not roster_ids:
|
||||
return {}
|
||||
|
||||
roster_ph = ','.join('?' for _ in roster_ids)
|
||||
|
||||
# 1. Fetch Aggregated Stats for ALL opponents
|
||||
# We group by steam_id first to get each opponent's AVG stats
|
||||
|
||||
sql = f"""
|
||||
SELECT
|
||||
mp.steam_id_64,
|
||||
COUNT(DISTINCT mp.match_id) as matches,
|
||||
AVG(mp.rating) as avg_rating,
|
||||
AVG(NULLIF(COALESCE(fmt_gid.group_origin_elo, fmt_tid.group_origin_elo), 0)) as avg_match_elo,
|
||||
SUM(CASE WHEN mp.is_win = 1 THEN 1 ELSE 0 END) as wins
|
||||
FROM fact_match_players mp
|
||||
JOIN fact_matches m ON mp.match_id = m.match_id
|
||||
LEFT JOIN fact_match_teams fmt_gid ON mp.match_id = fmt_gid.match_id AND fmt_gid.group_id = mp.team_id
|
||||
LEFT JOIN fact_match_teams fmt_tid ON mp.match_id = fmt_tid.match_id AND fmt_tid.group_tid = mp.match_team_id
|
||||
WHERE CAST(mp.steam_id_64 AS TEXT) NOT IN ({roster_ph})
|
||||
GROUP BY mp.steam_id_64
|
||||
"""
|
||||
|
||||
rows = query_db('l2', sql, roster_ids)
|
||||
|
||||
# Initialize Buckets
|
||||
elo_buckets = {'<1000': 0, '1000-1200': 0, '1200-1400': 0, '1400-1600': 0, '1600-1800': 0, '1800-2000': 0, '>2000': 0}
|
||||
rating_buckets = {'<0.8': 0, '0.8-1.0': 0, '1.0-1.2': 0, '1.2-1.4': 0, '>1.4': 0}
|
||||
win_rate_buckets = {'<30%': 0, '30-45%': 0, '45-55%': 0, '55-70%': 0, '>70%': 0}
|
||||
elo_values = []
|
||||
rating_values = []
|
||||
|
||||
for r in rows:
|
||||
elo_val = r['avg_match_elo']
|
||||
if elo_val is None or elo_val <= 0:
|
||||
pass
|
||||
else:
|
||||
elo = elo_val
|
||||
if elo < 1000: k = '<1000'
|
||||
elif elo < 1200: k = '1000-1200'
|
||||
elif elo < 1400: k = '1200-1400'
|
||||
elif elo < 1600: k = '1400-1600'
|
||||
elif elo < 1800: k = '1600-1800'
|
||||
elif elo < 2000: k = '1800-2000'
|
||||
else: k = '>2000'
|
||||
elo_buckets[k] += 1
|
||||
elo_values.append(float(elo))
|
||||
|
||||
rtg = r['avg_rating'] or 0
|
||||
if rtg < 0.8: k = '<0.8'
|
||||
elif rtg < 1.0: k = '0.8-1.0'
|
||||
elif rtg < 1.2: k = '1.0-1.2'
|
||||
elif rtg < 1.4: k = '1.2-1.4'
|
||||
else: k = '>1.4'
|
||||
rating_buckets[k] += 1
|
||||
rating_values.append(float(rtg))
|
||||
|
||||
matches = r['matches'] or 0
|
||||
if matches > 0:
|
||||
wr = (r['wins'] or 0) / matches
|
||||
if wr < 0.30: k = '<30%'
|
||||
elif wr < 0.45: k = '30-45%'
|
||||
elif wr < 0.55: k = '45-55%'
|
||||
elif wr < 0.70: k = '55-70%'
|
||||
else: k = '>70%'
|
||||
win_rate_buckets[k] += 1
|
||||
|
||||
return {
|
||||
'elo_dist': elo_buckets,
|
||||
'rating_dist': rating_buckets,
|
||||
'win_rate_dist': win_rate_buckets,
|
||||
'elo_values': elo_values,
|
||||
'rating_values': rating_values
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def get_opponent_detail(steam_id):
|
||||
# 1. Basic Info
|
||||
info = query_db('l2', "SELECT * FROM dim_players WHERE steam_id_64 = ?", [steam_id], one=True)
|
||||
if not info:
|
||||
return None
|
||||
from web.services.stats_service import StatsService
|
||||
player = dict(info)
|
||||
player['avatar_url'] = StatsService.resolve_avatar_url(steam_id, player.get('avatar_url'))
|
||||
|
||||
# 2. Match History vs Us (All matches this player played)
|
||||
# We define "Us" as matches where this player is an opponent.
|
||||
# But actually, we just show ALL their matches in our DB, assuming our DB only contains matches relevant to us?
|
||||
# Usually yes, but if we have a huge DB, we might want to filter by "Contains Roster Member".
|
||||
# For now, show all matches in DB for this player.
|
||||
|
||||
sql_history = """
|
||||
SELECT
|
||||
m.match_id, m.start_time, m.map_name, m.score_team1, m.score_team2, m.winner_team,
|
||||
mp.team_id, mp.match_team_id, mp.rating, mp.kd_ratio, mp.adr, mp.kills, mp.deaths,
|
||||
mp.is_win as is_win,
|
||||
CASE
|
||||
WHEN COALESCE(fmt_gid.group_origin_elo, fmt_tid.group_origin_elo) > 0
|
||||
THEN COALESCE(fmt_gid.group_origin_elo, fmt_tid.group_origin_elo)
|
||||
END as elo
|
||||
FROM fact_match_players mp
|
||||
JOIN fact_matches m ON mp.match_id = m.match_id
|
||||
LEFT JOIN fact_match_teams fmt_gid ON mp.match_id = fmt_gid.match_id AND fmt_gid.group_id = mp.team_id
|
||||
LEFT JOIN fact_match_teams fmt_tid ON mp.match_id = fmt_tid.match_id AND fmt_tid.group_tid = mp.match_team_id
|
||||
WHERE mp.steam_id_64 = ?
|
||||
ORDER BY m.start_time DESC
|
||||
"""
|
||||
history = query_db('l2', sql_history, [steam_id])
|
||||
|
||||
# 3. Aggregation by ELO
|
||||
elo_buckets = {
|
||||
'<1200': {'matches': 0, 'rating_sum': 0, 'kd_sum': 0},
|
||||
'1200-1500': {'matches': 0, 'rating_sum': 0, 'kd_sum': 0},
|
||||
'1500-1800': {'matches': 0, 'rating_sum': 0, 'kd_sum': 0},
|
||||
'1800-2100': {'matches': 0, 'rating_sum': 0, 'kd_sum': 0},
|
||||
'>2100': {'matches': 0, 'rating_sum': 0, 'kd_sum': 0}
|
||||
}
|
||||
|
||||
# 4. Aggregation by Side (T/CT)
|
||||
# Using fact_match_players_t / ct
|
||||
sql_side = """
|
||||
SELECT
|
||||
(SELECT CASE
|
||||
WHEN SUM(CASE WHEN t.rating2 IS NOT NULL AND t.rating2 != 0 THEN t.round_total END) > 0
|
||||
THEN SUM(CASE WHEN t.rating2 IS NOT NULL AND t.rating2 != 0 THEN t.rating2 * t.round_total END)
|
||||
/ SUM(CASE WHEN t.rating2 IS NOT NULL AND t.rating2 != 0 THEN t.round_total END)
|
||||
WHEN COUNT(*) > 0
|
||||
THEN AVG(NULLIF(t.rating2, 0))
|
||||
END
|
||||
FROM fact_match_players_t t WHERE t.steam_id_64 = ?) as rating_t,
|
||||
(SELECT CASE
|
||||
WHEN SUM(CASE WHEN ct.rating2 IS NOT NULL AND ct.rating2 != 0 THEN ct.round_total END) > 0
|
||||
THEN SUM(CASE WHEN ct.rating2 IS NOT NULL AND ct.rating2 != 0 THEN ct.rating2 * ct.round_total END)
|
||||
/ SUM(CASE WHEN ct.rating2 IS NOT NULL AND ct.rating2 != 0 THEN ct.round_total END)
|
||||
WHEN COUNT(*) > 0
|
||||
THEN AVG(NULLIF(ct.rating2, 0))
|
||||
END
|
||||
FROM fact_match_players_ct ct WHERE ct.steam_id_64 = ?) as rating_ct,
|
||||
(SELECT CASE
|
||||
WHEN SUM(t.deaths) > 0 THEN SUM(t.kills) * 1.0 / SUM(t.deaths)
|
||||
WHEN SUM(t.kills) > 0 THEN SUM(t.kills) * 1.0
|
||||
WHEN COUNT(*) > 0 THEN AVG(NULLIF(t.kd_ratio, 0))
|
||||
END
|
||||
FROM fact_match_players_t t WHERE t.steam_id_64 = ?) as kd_t,
|
||||
(SELECT CASE
|
||||
WHEN SUM(ct.deaths) > 0 THEN SUM(ct.kills) * 1.0 / SUM(ct.deaths)
|
||||
WHEN SUM(ct.kills) > 0 THEN SUM(ct.kills) * 1.0
|
||||
WHEN COUNT(*) > 0 THEN AVG(NULLIF(ct.kd_ratio, 0))
|
||||
END
|
||||
FROM fact_match_players_ct ct WHERE ct.steam_id_64 = ?) as kd_ct,
|
||||
(SELECT SUM(t.round_total) FROM fact_match_players_t t WHERE t.steam_id_64 = ?) as rounds_t,
|
||||
(SELECT SUM(ct.round_total) FROM fact_match_players_ct ct WHERE ct.steam_id_64 = ?) as rounds_ct
|
||||
"""
|
||||
side_stats = query_db('l2', sql_side, [steam_id, steam_id, steam_id, steam_id, steam_id, steam_id], one=True)
|
||||
|
||||
# Process History for ELO & KD Diff
|
||||
# We also want "Our Team KD" in these matches to calc Diff.
|
||||
# This requires querying the OTHER team in these matches.
|
||||
|
||||
match_ids = [h['match_id'] for h in history]
|
||||
|
||||
# Get Our Team Stats per match
|
||||
# "Our Team" = All players in the match EXCEPT this opponent (and their teammates?)
|
||||
# Simplification: "Avg Lobby KD" vs "Opponent KD".
|
||||
# Or better: "Avg KD of Opposing Team".
|
||||
|
||||
match_stats_map = {}
|
||||
if match_ids:
|
||||
ph = ','.join('?' for _ in match_ids)
|
||||
# Calculate Avg KD of the team that is NOT the opponent's team
|
||||
opp_stats_sql = f"""
|
||||
SELECT match_id, match_team_id, AVG(kd_ratio) as team_avg_kd
|
||||
FROM fact_match_players
|
||||
WHERE match_id IN ({ph})
|
||||
GROUP BY match_id, match_team_id
|
||||
"""
|
||||
opp_rows = query_db('l2', opp_stats_sql, match_ids)
|
||||
|
||||
# Organize by match
|
||||
for r in opp_rows:
|
||||
mid = r['match_id']
|
||||
tid = r['match_team_id']
|
||||
if mid not in match_stats_map:
|
||||
match_stats_map[mid] = {}
|
||||
match_stats_map[mid][tid] = r['team_avg_kd']
|
||||
|
||||
processed_history = []
|
||||
for h in history:
|
||||
# ELO Bucketing
|
||||
elo = h['elo'] or 0
|
||||
if elo < 1200: b = '<1200'
|
||||
elif elo < 1500: b = '1200-1500'
|
||||
elif elo < 1800: b = '1500-1800'
|
||||
elif elo < 2100: b = '1800-2100'
|
||||
else: b = '>2100'
|
||||
|
||||
elo_buckets[b]['matches'] += 1
|
||||
elo_buckets[b]['rating_sum'] += (h['rating'] or 0)
|
||||
elo_buckets[b]['kd_sum'] += (h['kd_ratio'] or 0)
|
||||
|
||||
# KD Diff
|
||||
# Find the OTHER team's avg KD
|
||||
my_tid = h['match_team_id']
|
||||
# Assuming 2 teams: if my_tid is 1, other is 2. But IDs can be anything.
|
||||
# Look at match_stats_map[mid] keys.
|
||||
mid = h['match_id']
|
||||
other_team_kd = 1.0 # Default
|
||||
if mid in match_stats_map:
|
||||
for tid, avg_kd in match_stats_map[mid].items():
|
||||
if tid != my_tid:
|
||||
other_team_kd = avg_kd
|
||||
break
|
||||
|
||||
kd_diff = (h['kd_ratio'] or 0) - other_team_kd
|
||||
|
||||
d = dict(h)
|
||||
d['kd_diff'] = kd_diff
|
||||
d['other_team_kd'] = other_team_kd
|
||||
processed_history.append(d)
|
||||
|
||||
# Format ELO Stats
|
||||
elo_stats = []
|
||||
for k, v in elo_buckets.items():
|
||||
if v['matches'] > 0:
|
||||
elo_stats.append({
|
||||
'range': k,
|
||||
'matches': v['matches'],
|
||||
'avg_rating': v['rating_sum'] / v['matches'],
|
||||
'avg_kd': v['kd_sum'] / v['matches']
|
||||
})
|
||||
|
||||
return {
|
||||
'player': player,
|
||||
'history': processed_history,
|
||||
'elo_stats': elo_stats,
|
||||
'side_stats': dict(side_stats) if side_stats else {}
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def get_map_opponent_stats():
|
||||
roster_ids = OpponentService._get_active_roster_ids()
|
||||
if not roster_ids:
|
||||
return []
|
||||
roster_ph = ','.join('?' for _ in roster_ids)
|
||||
sql = f"""
|
||||
SELECT
|
||||
m.map_name as map_name,
|
||||
COUNT(DISTINCT mp.match_id) as matches,
|
||||
AVG(mp.rating) as avg_rating,
|
||||
AVG(mp.kd_ratio) as avg_kd,
|
||||
AVG(NULLIF(COALESCE(fmt_gid.group_origin_elo, fmt_tid.group_origin_elo), 0)) as avg_elo,
|
||||
COUNT(DISTINCT CASE WHEN mp.is_win = 1 THEN mp.match_id END) as wins,
|
||||
COUNT(DISTINCT CASE WHEN mp.rating > 1.5 THEN mp.match_id END) as shark_matches
|
||||
FROM fact_match_players mp
|
||||
JOIN fact_matches m ON mp.match_id = m.match_id
|
||||
LEFT JOIN fact_match_teams fmt_gid ON mp.match_id = fmt_gid.match_id AND fmt_gid.group_id = mp.team_id
|
||||
LEFT JOIN fact_match_teams fmt_tid ON mp.match_id = fmt_tid.match_id AND fmt_tid.group_tid = mp.match_team_id
|
||||
WHERE CAST(mp.steam_id_64 AS TEXT) NOT IN ({roster_ph})
|
||||
AND m.map_name IS NOT NULL AND m.map_name <> ''
|
||||
GROUP BY m.map_name
|
||||
ORDER BY matches DESC
|
||||
"""
|
||||
rows = query_db('l2', sql, roster_ids)
|
||||
results = []
|
||||
for r in rows:
|
||||
d = dict(r)
|
||||
matches = d.get('matches') or 0
|
||||
wins = d.get('wins') or 0
|
||||
d['win_rate'] = (wins / matches) if matches else 0
|
||||
results.append(d)
|
||||
return results
|
||||
1112
web/services/stats_service.py
Normal file
119
web/services/weapon_service.py
Normal file
@@ -0,0 +1,119 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WeaponInfo:
|
||||
name: str
|
||||
price: int
|
||||
side: str
|
||||
category: str
|
||||
|
||||
|
||||
_WEAPON_TABLE = {
|
||||
"glock": WeaponInfo(name="Glock-18", price=200, side="T", category="pistol"),
|
||||
"hkp2000": WeaponInfo(name="P2000", price=200, side="CT", category="pistol"),
|
||||
"usp_silencer": WeaponInfo(name="USP-S", price=200, side="CT", category="pistol"),
|
||||
"elite": WeaponInfo(name="Dual Berettas", price=300, side="Both", category="pistol"),
|
||||
"p250": WeaponInfo(name="P250", price=300, side="Both", category="pistol"),
|
||||
"tec9": WeaponInfo(name="Tec-9", price=500, side="T", category="pistol"),
|
||||
"fiveseven": WeaponInfo(name="Five-SeveN", price=500, side="CT", category="pistol"),
|
||||
"cz75a": WeaponInfo(name="CZ75-Auto", price=500, side="Both", category="pistol"),
|
||||
"revolver": WeaponInfo(name="R8 Revolver", price=600, side="Both", category="pistol"),
|
||||
"deagle": WeaponInfo(name="Desert Eagle", price=700, side="Both", category="pistol"),
|
||||
"mac10": WeaponInfo(name="MAC-10", price=1050, side="T", category="smg"),
|
||||
"mp9": WeaponInfo(name="MP9", price=1250, side="CT", category="smg"),
|
||||
"ump45": WeaponInfo(name="UMP-45", price=1200, side="Both", category="smg"),
|
||||
"bizon": WeaponInfo(name="PP-Bizon", price=1400, side="Both", category="smg"),
|
||||
"mp7": WeaponInfo(name="MP7", price=1500, side="Both", category="smg"),
|
||||
"mp5sd": WeaponInfo(name="MP5-SD", price=1500, side="Both", category="smg"),
|
||||
"nova": WeaponInfo(name="Nova", price=1050, side="Both", category="shotgun"),
|
||||
"mag7": WeaponInfo(name="MAG-7", price=1300, side="CT", category="shotgun"),
|
||||
"sawedoff": WeaponInfo(name="Sawed-Off", price=1100, side="T", category="shotgun"),
|
||||
"xm1014": WeaponInfo(name="XM1014", price=2000, side="Both", category="shotgun"),
|
||||
"galilar": WeaponInfo(name="Galil AR", price=1800, side="T", category="rifle"),
|
||||
"famas": WeaponInfo(name="FAMAS", price=2050, side="CT", category="rifle"),
|
||||
"ak47": WeaponInfo(name="AK-47", price=2700, side="T", category="rifle"),
|
||||
"m4a1": WeaponInfo(name="M4A4", price=2900, side="CT", category="rifle"),
|
||||
"m4a1_silencer": WeaponInfo(name="M4A1-S", price=2900, side="CT", category="rifle"),
|
||||
"aug": WeaponInfo(name="AUG", price=3300, side="CT", category="rifle"),
|
||||
"sg556": WeaponInfo(name="SG 553", price=3300, side="T", category="rifle"),
|
||||
"awp": WeaponInfo(name="AWP", price=4750, side="Both", category="sniper"),
|
||||
"scar20": WeaponInfo(name="SCAR-20", price=5000, side="CT", category="sniper"),
|
||||
"g3sg1": WeaponInfo(name="G3SG1", price=5000, side="T", category="sniper"),
|
||||
"negev": WeaponInfo(name="Negev", price=1700, side="Both", category="lmg"),
|
||||
"m249": WeaponInfo(name="M249", price=5200, side="Both", category="lmg"),
|
||||
}
|
||||
|
||||
_ALIASES = {
|
||||
"weapon_glock": "glock",
|
||||
"weapon_hkp2000": "hkp2000",
|
||||
"weapon_usp_silencer": "usp_silencer",
|
||||
"weapon_elite": "elite",
|
||||
"weapon_p250": "p250",
|
||||
"weapon_tec9": "tec9",
|
||||
"weapon_fiveseven": "fiveseven",
|
||||
"weapon_cz75a": "cz75a",
|
||||
"weapon_revolver": "revolver",
|
||||
"weapon_deagle": "deagle",
|
||||
"weapon_mac10": "mac10",
|
||||
"weapon_mp9": "mp9",
|
||||
"weapon_ump45": "ump45",
|
||||
"weapon_bizon": "bizon",
|
||||
"weapon_mp7": "mp7",
|
||||
"weapon_mp5sd": "mp5sd",
|
||||
"weapon_nova": "nova",
|
||||
"weapon_mag7": "mag7",
|
||||
"weapon_sawedoff": "sawedoff",
|
||||
"weapon_xm1014": "xm1014",
|
||||
"weapon_galilar": "galilar",
|
||||
"weapon_famas": "famas",
|
||||
"weapon_ak47": "ak47",
|
||||
"weapon_m4a1": "m4a1",
|
||||
"weapon_m4a1_silencer": "m4a1_silencer",
|
||||
"weapon_aug": "aug",
|
||||
"weapon_sg556": "sg556",
|
||||
"weapon_awp": "awp",
|
||||
"weapon_scar20": "scar20",
|
||||
"weapon_g3sg1": "g3sg1",
|
||||
"weapon_negev": "negev",
|
||||
"weapon_m249": "m249",
|
||||
"m4a4": "m4a1",
|
||||
"m4a1-s": "m4a1_silencer",
|
||||
"m4a1s": "m4a1_silencer",
|
||||
"sg553": "sg556",
|
||||
"pp-bizon": "bizon",
|
||||
}
|
||||
|
||||
|
||||
def normalize_weapon_name(raw: Optional[str]) -> str:
|
||||
if not raw:
|
||||
return ""
|
||||
s = str(raw).strip().lower()
|
||||
if not s:
|
||||
return ""
|
||||
s = s.replace(" ", "").replace("\t", "").replace("\n", "")
|
||||
s = s.replace("weapon_", "weapon_")
|
||||
if s in _ALIASES:
|
||||
return _ALIASES[s]
|
||||
if s.startswith("weapon_") and s in _ALIASES:
|
||||
return _ALIASES[s]
|
||||
if s.startswith("weapon_"):
|
||||
s2 = s[len("weapon_") :]
|
||||
return _ALIASES.get(s2, s2)
|
||||
return _ALIASES.get(s, s)
|
||||
|
||||
|
||||
def get_weapon_info(raw: Optional[str]) -> Optional[WeaponInfo]:
|
||||
key = normalize_weapon_name(raw)
|
||||
if not key:
|
||||
return None
|
||||
return _WEAPON_TABLE.get(key)
|
||||
|
||||
|
||||
def get_weapon_price(raw: Optional[str]) -> Optional[int]:
|
||||
info = get_weapon_info(raw)
|
||||
return info.price if info else None
|
||||
|
||||
120
web/services/web_service.py
Normal file
@@ -0,0 +1,120 @@
|
||||
from web.database import query_db, execute_db
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
class WebService:
|
||||
# --- Comments ---
|
||||
@staticmethod
|
||||
def get_comments(target_type, target_id):
|
||||
sql = "SELECT * FROM comments WHERE target_type = ? AND target_id = ? AND is_hidden = 0 ORDER BY created_at DESC"
|
||||
return query_db('web', sql, [target_type, target_id])
|
||||
|
||||
@staticmethod
|
||||
def add_comment(user_id, username, target_type, target_id, content):
|
||||
sql = """
|
||||
INSERT INTO comments (user_id, username, target_type, target_id, content)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
"""
|
||||
return execute_db('web', sql, [user_id, username, target_type, target_id, content])
|
||||
|
||||
@staticmethod
|
||||
def like_comment(comment_id):
|
||||
sql = "UPDATE comments SET likes = likes + 1 WHERE id = ?"
|
||||
return execute_db('web', sql, [comment_id])
|
||||
|
||||
# --- Wiki ---
|
||||
@staticmethod
|
||||
def get_wiki_page(path):
|
||||
sql = "SELECT * FROM wiki_pages WHERE path = ?"
|
||||
return query_db('web', sql, [path], one=True)
|
||||
|
||||
@staticmethod
|
||||
def get_all_wiki_pages():
|
||||
sql = "SELECT path, title FROM wiki_pages ORDER BY path"
|
||||
return query_db('web', sql)
|
||||
|
||||
@staticmethod
|
||||
def save_wiki_page(path, title, content, updated_by):
|
||||
# Upsert logic
|
||||
check = query_db('web', "SELECT id FROM wiki_pages WHERE path = ?", [path], one=True)
|
||||
if check:
|
||||
sql = "UPDATE wiki_pages SET title=?, content=?, updated_by=?, updated_at=CURRENT_TIMESTAMP WHERE path=?"
|
||||
execute_db('web', sql, [title, content, updated_by, path])
|
||||
else:
|
||||
sql = "INSERT INTO wiki_pages (path, title, content, updated_by) VALUES (?, ?, ?, ?)"
|
||||
execute_db('web', sql, [path, title, content, updated_by])
|
||||
|
||||
# --- Team Lineups ---
|
||||
@staticmethod
|
||||
def save_lineup(name, description, player_ids, lineup_id=None):
|
||||
# player_ids is a list
|
||||
ids_json = json.dumps(player_ids)
|
||||
if lineup_id:
|
||||
sql = "UPDATE team_lineups SET name=?, description=?, player_ids_json=? WHERE id=?"
|
||||
return execute_db('web', sql, [name, description, ids_json, lineup_id])
|
||||
else:
|
||||
sql = "INSERT INTO team_lineups (name, description, player_ids_json) VALUES (?, ?, ?)"
|
||||
return execute_db('web', sql, [name, description, ids_json])
|
||||
|
||||
@staticmethod
|
||||
def get_lineups():
|
||||
return query_db('web', "SELECT * FROM team_lineups ORDER BY created_at DESC")
|
||||
|
||||
@staticmethod
|
||||
def get_lineup(lineup_id):
|
||||
return query_db('web', "SELECT * FROM team_lineups WHERE id = ?", [lineup_id], one=True)
|
||||
|
||||
|
||||
# --- Users / Auth ---
|
||||
@staticmethod
|
||||
def get_user_by_token(token):
|
||||
sql = "SELECT * FROM users WHERE token = ?"
|
||||
return query_db('web', sql, [token], one=True)
|
||||
|
||||
# --- Player Metadata ---
|
||||
@staticmethod
|
||||
def get_player_metadata(steam_id):
|
||||
sql = "SELECT * FROM player_metadata WHERE steam_id_64 = ?"
|
||||
row = query_db('web', sql, [steam_id], one=True)
|
||||
if row:
|
||||
res = dict(row)
|
||||
try:
|
||||
res['tags'] = json.loads(res['tags']) if res['tags'] else []
|
||||
except:
|
||||
res['tags'] = []
|
||||
return res
|
||||
return {'steam_id_64': steam_id, 'notes': '', 'tags': []}
|
||||
|
||||
@staticmethod
|
||||
def update_player_metadata(steam_id, notes=None, tags=None):
|
||||
# Upsert
|
||||
check = query_db('web', "SELECT steam_id_64 FROM player_metadata WHERE steam_id_64 = ?", [steam_id], one=True)
|
||||
|
||||
tags_json = json.dumps(tags) if tags is not None else None
|
||||
|
||||
if check:
|
||||
# Update
|
||||
clauses = []
|
||||
args = []
|
||||
if notes is not None:
|
||||
clauses.append("notes = ?")
|
||||
args.append(notes)
|
||||
if tags is not None:
|
||||
clauses.append("tags = ?")
|
||||
args.append(tags_json)
|
||||
|
||||
if clauses:
|
||||
clauses.append("updated_at = CURRENT_TIMESTAMP")
|
||||
sql = f"UPDATE player_metadata SET {', '.join(clauses)} WHERE steam_id_64 = ?"
|
||||
args.append(steam_id)
|
||||
execute_db('web', sql, args)
|
||||
else:
|
||||
# Insert
|
||||
sql = "INSERT INTO player_metadata (steam_id_64, notes, tags) VALUES (?, ?, ?)"
|
||||
execute_db('web', sql, [steam_id, notes or '', tags_json or '[]'])
|
||||
|
||||
# --- Strategy Board ---
|
||||
@staticmethod
|
||||
def save_strategy_board(title, map_name, data_json, created_by):
|
||||
sql = "INSERT INTO strategy_boards (title, map_name, data_json, created_by) VALUES (?, ?, ?, ?)"
|
||||
return execute_db('web', sql, [title, map_name, data_json, created_by])
|
||||
BIN
web/static/avatars/76561198330488905.jpg
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
web/static/avatars/76561198970034329.jpg
Normal file
|
After Width: | Height: | Size: 74 KiB |
BIN
web/static/avatars/76561199026688017.jpg
Normal file
|
After Width: | Height: | Size: 49 KiB |
BIN
web/static/avatars/76561199032002725.jpg
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
web/static/avatars/76561199076109761.jpg
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
web/static/avatars/76561199078250590.jpg
Normal file
|
After Width: | Height: | Size: 59 KiB |
BIN
web/static/avatars/76561199106558767.jpg
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
web/static/avatars/76561199390145159.jpg
Normal file
|
After Width: | Height: | Size: 39 KiB |
BIN
web/static/avatars/76561199417030350.jpg
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
web/static/avatars/76561199467422873.jpg
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
web/static/avatars/76561199526984477.jpg
Normal file
|
After Width: | Height: | Size: 39 KiB |
54
web/templates/admin/dashboard.html
Normal file
@@ -0,0 +1,54 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="bg-white dark:bg-slate-800 shadow rounded-lg p-6">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">管理后台 (Admin Dashboard)</h2>
|
||||
<a href="{{ url_for('admin.logout') }}" class="text-red-600 hover:text-red-800">Logout</a>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<!-- ETL Controls -->
|
||||
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||
<h3 class="text-lg font-bold text-gray-900 dark:text-white mb-4">数据管线 (ETL)</h3>
|
||||
<div class="space-y-2">
|
||||
<button onclick="triggerEtl('L1A.py')" class="w-full bg-blue-600 text-white py-2 px-4 rounded hover:bg-blue-700">Trigger L1A (Ingest)</button>
|
||||
<button onclick="triggerEtl('L2_Builder.py')" class="w-full bg-blue-600 text-white py-2 px-4 rounded hover:bg-blue-700">Trigger L2 Builder</button>
|
||||
<button onclick="triggerEtl('L3_Builder.py')" class="w-full bg-blue-600 text-white py-2 px-4 rounded hover:bg-blue-700">Trigger L3 Builder</button>
|
||||
</div>
|
||||
<div id="etlResult" class="mt-4 text-sm text-gray-600 dark:text-gray-400"></div>
|
||||
</div>
|
||||
|
||||
<!-- Tools -->
|
||||
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||
<h3 class="text-lg font-bold text-gray-900 dark:text-white mb-4">工具箱</h3>
|
||||
<div class="space-y-2">
|
||||
<a href="{{ url_for('admin.sql_runner') }}" class="block w-full text-center bg-gray-600 text-white py-2 px-4 rounded hover:bg-gray-700">SQL Runner</a>
|
||||
<a href="{{ url_for('wiki.index') }}" class="block w-full text-center bg-gray-600 text-white py-2 px-4 rounded hover:bg-gray-700">Manage Wiki</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function triggerEtl(scriptName) {
|
||||
const resultDiv = document.getElementById('etlResult');
|
||||
resultDiv.innerText = "Triggering " + scriptName + "...";
|
||||
|
||||
fetch("{{ url_for('admin.trigger_etl') }}", {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: 'script=' + scriptName
|
||||
})
|
||||
.then(response => response.text())
|
||||
.then(text => {
|
||||
resultDiv.innerText = text;
|
||||
})
|
||||
.catch(err => {
|
||||
resultDiv.innerText = "Error: " + err;
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
38
web/templates/admin/login.html
Normal file
@@ -0,0 +1,38 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="min-h-full flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div class="max-w-md w-full space-y-8">
|
||||
<div>
|
||||
<h2 class="mt-6 text-center text-3xl font-extrabold text-gray-900 dark:text-white">
|
||||
Admin Login
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative" role="alert">
|
||||
<span class="block sm:inline">{{ message }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<form class="mt-8 space-y-6" action="{{ url_for('admin.login') }}" method="POST">
|
||||
<div class="rounded-md shadow-sm -space-y-px">
|
||||
<div>
|
||||
<label for="token" class="sr-only">Admin Token</label>
|
||||
<input id="token" name="token" type="password" required class="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md rounded-b-md focus:outline-none focus:ring-yrtv-500 focus:border-yrtv-500 focus:z-10 sm:text-sm" placeholder="Enter Admin Token">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button type="submit" class="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-yrtv-600 hover:bg-yrtv-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-yrtv-500">
|
||||
Sign in
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
52
web/templates/admin/sql.html
Normal file
@@ -0,0 +1,52 @@
|
||||
{% 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">SQL Runner</h2>
|
||||
|
||||
<form action="{{ url_for('admin.sql_runner') }}" method="POST" class="mb-6">
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Database</label>
|
||||
<select name="db_name" class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 dark:bg-slate-700 dark:text-white">
|
||||
<option value="l2" {% if db_name == 'l2' %}selected{% endif %}>L2 (Facts)</option>
|
||||
<option value="l3" {% if db_name == 'l3' %}selected{% endif %}>L3 (Features)</option>
|
||||
<option value="web" {% if db_name == 'web' %}selected{% endif %}>Web (App Data)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Query</label>
|
||||
<textarea name="query" rows="5" class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 font-mono text-sm dark:bg-slate-700 dark:text-white" placeholder="SELECT * FROM table LIMIT 10">{{ query }}</textarea>
|
||||
</div>
|
||||
<button type="submit" class="bg-yrtv-600 text-white py-2 px-4 rounded hover:bg-yrtv-700">Run Query</button>
|
||||
</form>
|
||||
|
||||
{% if error %}
|
||||
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-6">
|
||||
{{ error }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if result %}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700 border">
|
||||
<thead class="bg-gray-50 dark:bg-slate-700">
|
||||
<tr>
|
||||
{% for col in result.columns %}
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider border-b">{{ col }}</th>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white dark:bg-slate-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{% for row in result.rows %}
|
||||
<tr>
|
||||
{% for col in result.columns %}
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400 border-b">{{ row[col] }}</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
160
web/templates/base.html
Normal file
@@ -0,0 +1,160 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}YRTV - CS2 Data Platform{% endblock %}</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/hammer.js/2.0.8/hammer.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-zoom@2.0.1/dist/chartjs-plugin-zoom.min.js"></script>
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
yrtv: {
|
||||
50: '#f5f3ff',
|
||||
100: '#ede9fe',
|
||||
500: '#8b5cf6',
|
||||
600: '#7c3aed',
|
||||
900: '#4c1d95',
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
body { font-family: 'Inter', sans-serif; }
|
||||
</style>
|
||||
{% block head %}{% endblock %}
|
||||
</head>
|
||||
<body class="bg-slate-50 text-slate-900 dark:bg-slate-900 dark:text-slate-100 flex flex-col min-h-screen">
|
||||
|
||||
<!-- Navbar -->
|
||||
<nav class="bg-white dark:bg-slate-800 border-b border-slate-200 dark:border-slate-700" x-data="{ mobileMenuOpen: false }">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex justify-between h-16">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0 flex items-center">
|
||||
<a href="{{ url_for('main.index') }}" class="text-2xl font-bold text-yrtv-600">YRTV</a>
|
||||
</div>
|
||||
<div class="hidden sm:ml-6 sm:flex sm:space-x-8">
|
||||
<a href="{{ url_for('main.index') }}" class="{% if request.endpoint == 'main.index' %}border-yrtv-500 text-gray-900 dark:text-white{% else %}border-transparent text-gray-500 dark:text-gray-300 hover:border-gray-300 hover:text-gray-700 dark:hover:text-white{% endif %} inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">首页</a>
|
||||
<a href="{{ url_for('matches.index') }}" class="{% if request.endpoint and 'matches' in request.endpoint %}border-yrtv-500 text-gray-900 dark:text-white{% else %}border-transparent text-gray-500 dark:text-gray-300 hover:border-gray-300 hover:text-gray-700 dark:hover:text-white{% endif %} inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">比赛</a>
|
||||
<a href="{{ url_for('players.index') }}" class="{% if request.endpoint and 'players' in request.endpoint %}border-yrtv-500 text-gray-900 dark:text-white{% else %}border-transparent text-gray-500 dark:text-gray-300 hover:border-gray-300 hover:text-gray-700 dark:hover:text-white{% endif %} inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">玩家</a>
|
||||
<a href="{{ url_for('teams.index') }}" class="{% if request.endpoint and 'teams' in request.endpoint %}border-yrtv-500 text-gray-900 dark:text-white{% else %}border-transparent text-gray-500 dark:text-gray-300 hover:border-gray-300 hover:text-gray-700 dark:hover:text-white{% endif %} inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">战队</a>
|
||||
<a href="{{ url_for('opponents.index') }}" class="{% if request.endpoint and 'opponents' in request.endpoint %}border-yrtv-500 text-gray-900 dark:text-white{% else %}border-transparent text-gray-500 dark:text-gray-300 hover:border-gray-300 hover:text-gray-700 dark:hover:text-white{% endif %} inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">对手</a>
|
||||
<a href="{{ url_for('tactics.index') }}" class="{% if request.endpoint and 'tactics' in request.endpoint %}border-yrtv-500 text-gray-900 dark:text-white{% else %}border-transparent text-gray-500 dark:text-gray-300 hover:border-gray-300 hover:text-gray-700 dark:hover:text-white{% endif %} inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">战术</a>
|
||||
<a href="{{ url_for('wiki.index') }}" class="{% if request.endpoint and 'wiki' in request.endpoint %}border-yrtv-500 text-gray-900 dark:text-white{% else %}border-transparent text-gray-500 dark:text-gray-300 hover:border-gray-300 hover:text-gray-700 dark:hover:text-white{% endif %} inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">Wiki</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-4">
|
||||
<!-- Mobile menu button -->
|
||||
<div class="flex items-center sm:hidden">
|
||||
<button @click="mobileMenuOpen = !mobileMenuOpen" type="button" class="inline-flex items-center justify-center p-2 rounded-md text-gray-400 hover:text-gray-500 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-slate-700 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-yrtv-500" aria-controls="mobile-menu" aria-expanded="false">
|
||||
<span class="sr-only">Open main menu</span>
|
||||
<svg class="block h-6 w-6" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Dark Mode Toggle -->
|
||||
<button id="theme-toggle" type="button" class="text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none focus:ring-4 focus:ring-gray-200 dark:focus:ring-gray-700 rounded-lg text-sm p-2.5">
|
||||
<svg id="theme-toggle-dark-icon" class="hidden w-5 h-5" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z"></path></svg>
|
||||
<svg id="theme-toggle-light-icon" class="hidden w-5 h-5" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z" fill-rule="evenodd" clip-rule="evenodd"></path></svg>
|
||||
</button>
|
||||
|
||||
{% if session.get('is_admin') %}
|
||||
<a href="{{ url_for('admin.dashboard') }}" class="hidden sm:block text-sm font-medium text-gray-500 hover:text-gray-900 dark:text-gray-300 dark:hover:text-white">Admin</a>
|
||||
{% else %}
|
||||
<a href="{{ url_for('admin.login') }}" class="hidden sm:block bg-yrtv-600 text-white px-4 py-2 rounded-md text-sm font-medium hover:bg-yrtv-500">登录</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile menu, show/hide based on menu state. -->
|
||||
<div class="sm:hidden" id="mobile-menu" x-show="mobileMenuOpen" style="display: none;">
|
||||
<div class="pt-2 pb-3 space-y-1">
|
||||
<a href="{{ url_for('main.index') }}" class="{% if request.endpoint == 'main.index' %}bg-yrtv-50 border-yrtv-500 text-yrtv-700{% else %}border-transparent text-gray-500 hover:bg-gray-50 hover:border-gray-300 hover:text-gray-700{% endif %} block pl-3 pr-4 py-2 border-l-4 text-base font-medium dark:text-white dark:hover:bg-slate-700">首页</a>
|
||||
<a href="{{ url_for('matches.index') }}" class="border-transparent text-gray-500 hover:bg-gray-50 hover:border-gray-300 hover:text-gray-700 block pl-3 pr-4 py-2 border-l-4 text-base font-medium dark:text-white dark:hover:bg-slate-700">比赛</a>
|
||||
<a href="{{ url_for('players.index') }}" class="border-transparent text-gray-500 hover:bg-gray-50 hover:border-gray-300 hover:text-gray-700 block pl-3 pr-4 py-2 border-l-4 text-base font-medium dark:text-white dark:hover:bg-slate-700">玩家</a>
|
||||
<a href="{{ url_for('teams.index') }}" class="border-transparent text-gray-500 hover:bg-gray-50 hover:border-gray-300 hover:text-gray-700 block pl-3 pr-4 py-2 border-l-4 text-base font-medium dark:text-white dark:hover:bg-slate-700">战队</a>
|
||||
<a href="{{ url_for('opponents.index') }}" class="border-transparent text-gray-500 hover:bg-gray-50 hover:border-gray-300 hover:text-gray-700 block pl-3 pr-4 py-2 border-l-4 text-base font-medium dark:text-white dark:hover:bg-slate-700">对手</a>
|
||||
<a href="{{ url_for('tactics.index') }}" class="border-transparent text-gray-500 hover:bg-gray-50 hover:border-gray-300 hover:text-gray-700 block pl-3 pr-4 py-2 border-l-4 text-base font-medium dark:text-white dark:hover:bg-slate-700">战术</a>
|
||||
<a href="{{ url_for('wiki.index') }}" class="border-transparent text-gray-500 hover:bg-gray-50 hover:border-gray-300 hover:text-gray-700 block pl-3 pr-4 py-2 border-l-4 text-base font-medium dark:text-white dark:hover:bg-slate-700">Wiki</a>
|
||||
{% if session.get('is_admin') %}
|
||||
<a href="{{ url_for('admin.dashboard') }}" class="border-transparent text-gray-500 hover:bg-gray-50 hover:border-gray-300 hover:text-gray-700 block pl-3 pr-4 py-2 border-l-4 text-base font-medium dark:text-white dark:hover:bg-slate-700">Admin</a>
|
||||
{% else %}
|
||||
<a href="{{ url_for('admin.login') }}" class="border-transparent text-gray-500 hover:bg-gray-50 hover:border-gray-300 hover:text-gray-700 block pl-3 pr-4 py-2 border-l-4 text-base font-medium dark:text-white dark:hover:bg-slate-700">登录</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="flex-grow max-w-7xl mx-auto py-6 sm:px-6 lg:px-8 w-full">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="bg-white dark:bg-slate-800 border-t border-slate-200 dark:border-slate-700 mt-auto">
|
||||
<div class="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
|
||||
<p class="text-center text-sm text-gray-500">© 2026 YRTV Data Platform. All rights reserved. 赣ICP备2026001600号</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
{% block scripts %}{% endblock %}
|
||||
|
||||
<script>
|
||||
// Dark mode toggle logic
|
||||
var themeToggleDarkIcon = document.getElementById('theme-toggle-dark-icon');
|
||||
var themeToggleLightIcon = document.getElementById('theme-toggle-light-icon');
|
||||
|
||||
// Change the icons inside the button based on previous settings
|
||||
if (localStorage.getItem('color-theme') === 'dark' || (!('color-theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||
themeToggleLightIcon.classList.remove('hidden');
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
themeToggleDarkIcon.classList.remove('hidden');
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
|
||||
var themeToggleBtn = document.getElementById('theme-toggle');
|
||||
|
||||
themeToggleBtn.addEventListener('click', function() {
|
||||
|
||||
// toggle icons inside button
|
||||
themeToggleDarkIcon.classList.toggle('hidden');
|
||||
themeToggleLightIcon.classList.toggle('hidden');
|
||||
|
||||
// if set via local storage previously
|
||||
if (localStorage.getItem('color-theme')) {
|
||||
if (localStorage.getItem('color-theme') === 'light') {
|
||||
document.documentElement.classList.add('dark');
|
||||
localStorage.setItem('color-theme', 'dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
localStorage.setItem('color-theme', 'light');
|
||||
}
|
||||
|
||||
// if NOT set via local storage previously
|
||||
} else {
|
||||
if (document.documentElement.classList.contains('dark')) {
|
||||
document.documentElement.classList.remove('dark');
|
||||
localStorage.setItem('color-theme', 'light');
|
||||
} else {
|
||||
document.documentElement.classList.add('dark');
|
||||
localStorage.setItem('color-theme', 'dark');
|
||||
}
|
||||
}
|
||||
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
195
web/templates/home/index.html
Normal file
@@ -0,0 +1,195 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="space-y-8">
|
||||
<!-- Hero Section -->
|
||||
<div class="bg-gradient-to-r from-yrtv-900 to-yrtv-600 rounded-2xl shadow-xl overflow-hidden">
|
||||
<div class="px-6 py-12 sm:px-12 sm:py-16 lg:py-20 text-center">
|
||||
<h1 class="text-4xl font-extrabold tracking-tight text-white sm:text-5xl lg:text-6xl">
|
||||
JKTV CS2 队伍数据洞察平台
|
||||
</h1>
|
||||
<p class="mt-6 max-w-lg mx-auto text-xl text-yrtv-100 sm:max-w-3xl">
|
||||
深度挖掘比赛数据,提供战术研判、阵容模拟与多维能力分析。
|
||||
</p>
|
||||
<div class="mt-10 max-w-sm mx-auto sm:max-w-none sm:flex sm:justify-center">
|
||||
<a href="{{ url_for('matches.index') }}" class="flex items-center justify-center px-4 py-3 border border-transparent text-base font-medium rounded-md shadow-sm text-yrtv-700 bg-white hover:bg-yrtv-50 dark:bg-slate-800 dark:text-white dark:hover:bg-slate-700 sm:px-8">
|
||||
近期比赛
|
||||
</a>
|
||||
<a href="{{ url_for('players.index') }}" class="mt-3 sm:mt-0 sm:ml-3 flex items-center justify-center px-4 py-3 border border-transparent text-base font-medium rounded-md shadow-sm text-white bg-yrtv-500 bg-opacity-60 hover:bg-opacity-70 dark:bg-yrtv-600 dark:hover:bg-yrtv-700 sm:px-8">
|
||||
数据中心
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Match Parser Input -->
|
||||
<div class="mt-10 max-w-lg mx-auto">
|
||||
<form id="parserForm" class="sm:flex">
|
||||
<label for="match-url" class="sr-only">Match URL</label>
|
||||
<input id="match-url" name="url" type="text" placeholder="Paste 5E Match URL here..." required class="block w-full px-5 py-3 text-base text-gray-900 placeholder-gray-500 border border-transparent rounded-md shadow-sm focus:outline-none focus:border-transparent focus:ring-2 focus:ring-white focus:ring-offset-2 focus:ring-offset-yrtv-600">
|
||||
<button type="submit" class="mt-3 w-full px-6 py-3 border border-transparent text-base font-medium rounded-md text-white bg-yrtv-500 shadow-sm hover:bg-yrtv-400 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-yrtv-600 sm:mt-0 sm:ml-3 sm:flex-shrink-0 sm:inline-flex sm:items-center sm:w-auto">
|
||||
Parse
|
||||
</button>
|
||||
</form>
|
||||
<p id="parserMsg" class="mt-3 text-sm text-yrtv-100"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Live & Recent Status -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<!-- Activity Heatmap -->
|
||||
<div class="lg:col-span-3 bg-white dark:bg-slate-800 shadow rounded-lg p-6">
|
||||
<h3 class="text-lg font-medium leading-6 text-gray-900 dark:text-white mb-4">活跃度 (Activity)</h3>
|
||||
<div class="overflow-x-auto">
|
||||
<div id="calendar-heatmap" class="flex space-x-1 min-w-max pb-2">
|
||||
<!-- JS will populate this -->
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2 flex items-center justify-end text-xs text-gray-500 space-x-1">
|
||||
<span>Less</span>
|
||||
<span class="w-3 h-3 bg-gray-100 dark:bg-slate-700 rounded-sm"></span>
|
||||
<span class="w-3 h-3 bg-green-200 rounded-sm"></span>
|
||||
<span class="w-3 h-3 bg-green-400 rounded-sm"></span>
|
||||
<span class="w-3 h-3 bg-green-600 rounded-sm"></span>
|
||||
<span class="w-3 h-3 bg-green-800 rounded-sm"></span>
|
||||
<span>More</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Live Status -->
|
||||
<div class="bg-white dark:bg-slate-800 shadow rounded-lg p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-medium leading-6 text-gray-900 dark:text-white">正在进行 (Live)</h3>
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||
Online
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-center py-8 text-gray-500">
|
||||
{% if live_matches %}
|
||||
<ul class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{% for m in live_matches %}
|
||||
<li class="py-2">
|
||||
<span class="font-bold">{{ m.map_name }}</span>: {{ m.score_team1 }} - {{ m.score_team2 }}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p>暂无正在进行的比赛</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Matches -->
|
||||
<div class="lg:col-span-2 bg-white dark:bg-slate-800 shadow rounded-lg p-6">
|
||||
<h3 class="text-lg font-medium leading-6 text-gray-900 dark:text-white mb-4">近期战况</h3>
|
||||
<div class="flow-root">
|
||||
<ul class="-my-5 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{% for match in recent_matches %}
|
||||
<li class="py-4">
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-white truncate">
|
||||
{{ match.map_name }}
|
||||
</p>
|
||||
<p class="text-sm text-gray-500 truncate">
|
||||
{{ match.start_time | default('Unknown Date') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="inline-flex items-center text-base font-semibold text-gray-900 dark:text-white">
|
||||
{{ match.score_team1 }} : {{ match.score_team2 }}
|
||||
</div>
|
||||
<div>
|
||||
<a href="{{ url_for('matches.detail', match_id=match.match_id) }}" class="text-sm text-yrtv-600 hover:text-yrtv-900">详情</a>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="py-4 text-center text-gray-500">暂无比赛数据</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// --- Match Parser ---
|
||||
const parserForm = document.getElementById('parserForm');
|
||||
const parserMsg = document.getElementById('parserMsg');
|
||||
|
||||
if (parserForm) {
|
||||
parserForm.addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
const url = document.getElementById('match-url').value;
|
||||
parserMsg.innerText = "Parsing...";
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('url', url);
|
||||
|
||||
fetch("{{ url_for('main.parse_match') }}", {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
parserMsg.innerText = data.message;
|
||||
if(data.success) {
|
||||
document.getElementById('match-url').value = '';
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
parserMsg.innerText = "Error: " + err;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// --- Heatmap ---
|
||||
const heatmapData = {{ heatmap_data|tojson }};
|
||||
const heatmapContainer = document.getElementById('calendar-heatmap');
|
||||
|
||||
if (heatmapContainer) {
|
||||
// Generate last 365 days
|
||||
const today = new Date();
|
||||
const oneDay = 24 * 60 * 60 * 1000;
|
||||
|
||||
let weeks = [];
|
||||
let currentWeek = [];
|
||||
const startDate = new Date(today.getTime() - (52 * 7 * oneDay));
|
||||
|
||||
for (let i = 0; i < 365; i++) {
|
||||
const d = new Date(startDate.getTime() + (i * oneDay));
|
||||
const dateStr = d.toISOString().split('T')[0];
|
||||
const count = heatmapData[dateStr] || 0;
|
||||
|
||||
let colorClass = 'bg-gray-100 dark:bg-slate-700';
|
||||
if (count > 0) colorClass = 'bg-green-200';
|
||||
if (count > 2) colorClass = 'bg-green-400';
|
||||
if (count > 5) colorClass = 'bg-green-600';
|
||||
if (count > 8) colorClass = 'bg-green-800';
|
||||
|
||||
currentWeek.push({date: dateStr, count: count, color: colorClass});
|
||||
|
||||
if (currentWeek.length === 7) {
|
||||
weeks.push(currentWeek);
|
||||
currentWeek = [];
|
||||
}
|
||||
}
|
||||
if (currentWeek.length > 0) weeks.push(currentWeek);
|
||||
|
||||
weeks.forEach(week => {
|
||||
const weekDiv = document.createElement('div');
|
||||
weekDiv.className = 'flex flex-col space-y-1';
|
||||
week.forEach(day => {
|
||||
const dayDiv = document.createElement('div');
|
||||
dayDiv.className = `w-3 h-3 rounded-sm ${day.color}`;
|
||||
dayDiv.title = `${day.date}: ${day.count} matches`;
|
||||
weekDiv.appendChild(dayDiv);
|
||||
});
|
||||
heatmapContainer.appendChild(weekDiv);
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
395
web/templates/matches/detail.html
Normal file
@@ -0,0 +1,395 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="space-y-6" x-data="{ tab: 'overview' }">
|
||||
<!-- Header -->
|
||||
<div class="bg-white dark:bg-slate-800 shadow rounded-lg p-6">
|
||||
<div class="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">{{ match.map_name }}</h1>
|
||||
<p class="text-sm text-gray-500 mt-1">Match ID: {{ match.match_id }} | {{ match.start_time }}</p>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-4xl font-black text-gray-900 dark:text-white">
|
||||
<span class="{% if match.winner_team == 1 %}text-green-600{% endif %}">{{ match.score_team1 }}</span>
|
||||
:
|
||||
<span class="{% if match.winner_team == 2 %}text-green-600{% endif %}">{{ match.score_team2 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<a href="{{ url_for('matches.raw_json', match_id=match.match_id) }}" target="_blank" class="text-sm text-yrtv-600 hover:underline">Download Raw JSON</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab Navigation -->
|
||||
<div class="mt-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<nav class="-mb-px flex space-x-8" aria-label="Tabs">
|
||||
<button @click="tab = 'overview'"
|
||||
:class="tab === 'overview' ? 'border-yrtv-500 text-yrtv-600' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'"
|
||||
class="whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm">
|
||||
Overview
|
||||
</button>
|
||||
<button @click="tab = 'h2h'"
|
||||
:class="tab === 'h2h' ? 'border-yrtv-500 text-yrtv-600' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'"
|
||||
class="whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm">
|
||||
Head to Head
|
||||
</button>
|
||||
<button @click="tab = 'rounds'"
|
||||
:class="tab === 'rounds' ? 'border-yrtv-500 text-yrtv-600' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'"
|
||||
class="whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm">
|
||||
Round History
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab: Overview -->
|
||||
<div x-show="tab === 'overview'" class="space-y-6">
|
||||
<!-- Team 1 Stats -->
|
||||
<div class="bg-white dark:bg-slate-800 shadow rounded-lg overflow-hidden">
|
||||
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-slate-700">
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-white">Team 1</h3>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead class="bg-gray-50 dark:bg-slate-700">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Player</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">K</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">D</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">A</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">+/-</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">ADR</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">KAST</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Rating</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white dark:bg-slate-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{% for p in team1_players %}
|
||||
<tr>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0 h-8 w-8">
|
||||
{% if p.avatar_url %}
|
||||
<img class="h-8 w-8 rounded-full" src="{{ p.avatar_url }}" alt="">
|
||||
{% else %}
|
||||
<div class="h-8 w-8 rounded-full bg-yrtv-100 flex items-center justify-center text-yrtv-600 font-bold text-xs border border-yrtv-200">
|
||||
{{ (p.username or p.steam_id_64)[:2] | upper }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<div class="flex items-center space-x-2">
|
||||
<a href="{{ url_for('players.detail', steam_id=p.steam_id_64) }}" class="text-sm font-medium text-gray-900 dark:text-white hover:text-yrtv-600">
|
||||
{{ p.username or p.steam_id_64 }}
|
||||
</a>
|
||||
{% if p.party_size > 1 %}
|
||||
{% set pc = p.party_size %}
|
||||
{% set p_color = 'bg-blue-100 text-blue-800' %}
|
||||
{% if pc == 2 %}{% set p_color = 'bg-indigo-100 text-indigo-800' %}
|
||||
{% elif pc == 3 %}{% set p_color = 'bg-blue-100 text-blue-800' %}
|
||||
{% elif pc == 4 %}{% set p_color = 'bg-purple-100 text-purple-800' %}
|
||||
{% elif pc >= 5 %}{% set p_color = 'bg-orange-100 text-orange-800' %}
|
||||
{% endif %}
|
||||
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium {{ p_color }} dark:bg-opacity-20" title="Roster Party of {{ p.party_size }}">
|
||||
<svg class="mr-1 h-3 w-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M13 6a3 3 0 11-6 0 3 3 0 016 0zM18 8a2 2 0 11-4 0 2 2 0 014 0zM14 15a4 4 0 00-8 0v3h8v-3zM6 8a2 2 0 11-4 0 2 2 0 014 0zM16 18v-3a5.972 5.972 0 00-.75-2.906A3.005 3.005 0 0119 15v3h-3zM4.75 12.094A5.973 5.973 0 004 15v3H1v-3a3 3 0 013.75-2.906z" />
|
||||
</svg>
|
||||
{{ p.party_size }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-4 whitespace-nowrap text-sm text-right text-gray-900 dark:text-white">{{ p.kills }}</td>
|
||||
<td class="px-4 py-4 whitespace-nowrap text-sm text-right text-gray-500 dark:text-gray-400">{{ p.deaths }}</td>
|
||||
<td class="px-4 py-4 whitespace-nowrap text-sm text-right text-gray-500 dark:text-gray-400">{{ p.assists }}</td>
|
||||
<td class="px-4 py-4 whitespace-nowrap text-sm text-right font-medium {% if (p.kills - p.deaths) >= 0 %}text-green-600{% else %}text-red-600{% endif %}">
|
||||
{{ p.kills - p.deaths }}
|
||||
</td>
|
||||
<td class="px-4 py-4 whitespace-nowrap text-sm text-right text-gray-500 dark:text-gray-400">{{ "%.1f"|format(p.adr or 0) }}</td>
|
||||
<td class="px-4 py-4 whitespace-nowrap text-sm text-right text-gray-500 dark:text-gray-400">{{ "%.1f"|format(p.kast or 0) }}%</td>
|
||||
<td class="px-4 py-4 whitespace-nowrap text-sm text-right font-bold text-gray-900 dark:text-white">{{ "%.2f"|format(p.rating or 0) }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Team 2 Stats -->
|
||||
<div class="bg-white dark:bg-slate-800 shadow rounded-lg overflow-hidden">
|
||||
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-slate-700">
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-white">Team 2</h3>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead class="bg-gray-50 dark:bg-slate-700">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Player</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">K</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">D</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">A</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">+/-</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">ADR</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">KAST</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Rating</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white dark:bg-slate-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{% for p in team2_players %}
|
||||
<tr>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0 h-8 w-8">
|
||||
{% if p.avatar_url %}
|
||||
<img class="h-8 w-8 rounded-full" src="{{ p.avatar_url }}" alt="">
|
||||
{% else %}
|
||||
<div class="h-8 w-8 rounded-full bg-yrtv-100 flex items-center justify-center text-yrtv-600 font-bold text-xs border border-yrtv-200">
|
||||
{{ (p.username or p.steam_id_64)[:2] | upper }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<div class="flex items-center space-x-2">
|
||||
<a href="{{ url_for('players.detail', steam_id=p.steam_id_64) }}" class="text-sm font-medium text-gray-900 dark:text-white hover:text-yrtv-600">
|
||||
{{ p.username or p.steam_id_64 }}
|
||||
</a>
|
||||
{% if p.party_size > 1 %}
|
||||
{% set pc = p.party_size %}
|
||||
{% set p_color = 'bg-blue-100 text-blue-800' %}
|
||||
{% if pc == 2 %}{% set p_color = 'bg-indigo-100 text-indigo-800' %}
|
||||
{% elif pc == 3 %}{% set p_color = 'bg-blue-100 text-blue-800' %}
|
||||
{% elif pc == 4 %}{% set p_color = 'bg-purple-100 text-purple-800' %}
|
||||
{% elif pc >= 5 %}{% set p_color = 'bg-orange-100 text-orange-800' %}
|
||||
{% endif %}
|
||||
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium {{ p_color }} dark:bg-opacity-20" title="Roster Party of {{ p.party_size }}">
|
||||
<svg class="mr-1 h-3 w-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M13 6a3 3 0 11-6 0 3 3 0 016 0zM18 8a2 2 0 11-4 0 2 2 0 014 0zM14 15a4 4 0 00-8 0v3h8v-3zM6 8a2 2 0 11-4 0 2 2 0 014 0zM16 18v-3a5.972 5.972 0 00-.75-2.906A3.005 3.005 0 0119 15v3h-3zM4.75 12.094A5.973 5.973 0 004 15v3H1v-3a3 3 0 013.75-2.906z" />
|
||||
</svg>
|
||||
{{ p.party_size }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-4 whitespace-nowrap text-sm text-right text-gray-900 dark:text-white">{{ p.kills }}</td>
|
||||
<td class="px-4 py-4 whitespace-nowrap text-sm text-right text-gray-500 dark:text-gray-400">{{ p.deaths }}</td>
|
||||
<td class="px-4 py-4 whitespace-nowrap text-sm text-right text-gray-500 dark:text-gray-400">{{ p.assists }}</td>
|
||||
<td class="px-4 py-4 whitespace-nowrap text-sm text-right font-medium {% if (p.kills - p.deaths) >= 0 %}text-green-600{% else %}text-red-600{% endif %}">
|
||||
{{ p.kills - p.deaths }}
|
||||
</td>
|
||||
<td class="px-4 py-4 whitespace-nowrap text-sm text-right text-gray-500 dark:text-gray-400">{{ "%.1f"|format(p.adr or 0) }}</td>
|
||||
<td class="px-4 py-4 whitespace-nowrap text-sm text-right text-gray-500 dark:text-gray-400">{{ "%.1f"|format(p.kast or 0) }}%</td>
|
||||
<td class="px-4 py-4 whitespace-nowrap text-sm text-right font-bold text-gray-900 dark:text-white">{{ "%.2f"|format(p.rating or 0) }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab: Head to Head -->
|
||||
<div x-show="tab === 'h2h'" class="bg-white dark:bg-slate-800 shadow rounded-lg overflow-hidden p-6" style="display: none;">
|
||||
<div class="flex justify-between items-end mb-6">
|
||||
<div>
|
||||
<h3 class="text-lg font-bold text-gray-900 dark:text-white">Head-to-Head Matrix</h3>
|
||||
<p class="text-sm text-gray-500 mt-1">Shows <span class="font-bold text-green-600 bg-green-50 px-1 rounded">Kills</span> : <span class="font-bold text-red-500 bg-red-50 px-1 rounded">Deaths</span> interaction between players</p>
|
||||
</div>
|
||||
<div class="text-xs text-gray-400 font-mono">
|
||||
Row: Team 1 Players<br>
|
||||
Col: Team 2 Players
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto rounded-xl border border-gray-200 dark:border-gray-700">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead class="bg-gray-50 dark:bg-slate-700/50">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-bold text-gray-500 dark:text-gray-400 uppercase tracking-wider bg-gray-50 dark:bg-slate-700/50 sticky left-0 z-10">
|
||||
Team 1 \ Team 2
|
||||
</th>
|
||||
{% for victim in team2_players %}
|
||||
<th class="px-2 py-3 text-center text-xs font-medium text-gray-500 dark:text-gray-300 tracking-wider min-w-[80px]" title="{{ victim.username }}">
|
||||
<div class="flex flex-col items-center group">
|
||||
<div class="relative">
|
||||
{% if victim.avatar_url %}
|
||||
<img class="h-8 w-8 rounded-full mb-1 border-2 border-transparent group-hover:border-yrtv-400 transition-all" src="{{ victim.avatar_url }}">
|
||||
{% else %}
|
||||
<div class="h-8 w-8 rounded-full bg-yrtv-100 flex items-center justify-center text-yrtv-600 font-bold text-xs border-2 border-yrtv-200 mb-1 group-hover:border-yrtv-400 transition-all">
|
||||
{{ (victim.username or victim.steam_id_64)[:2] | upper }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<span class="truncate w-20 text-center font-bold text-gray-700 dark:text-gray-300 group-hover:text-yrtv-600 transition-colors text-[10px]">{{ victim.username or 'Player' }}</span>
|
||||
</div>
|
||||
</th>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white dark:bg-slate-800 divide-y divide-gray-100 dark:divide-gray-700">
|
||||
{% for killer in team1_players %}
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700/30 transition-colors">
|
||||
<td class="px-4 py-3 whitespace-nowrap font-medium text-gray-900 dark:text-white bg-white dark:bg-slate-800 sticky left-0 z-10 border-r border-gray-100 dark:border-gray-700 shadow-sm">
|
||||
<div class="flex items-center group">
|
||||
{% if killer.avatar_url %}
|
||||
<img class="h-8 w-8 rounded-full mr-3 border-2 border-transparent group-hover:border-blue-400 transition-all" src="{{ killer.avatar_url }}">
|
||||
{% else %}
|
||||
<div class="h-8 w-8 rounded-full bg-blue-100 flex items-center justify-center text-blue-600 font-bold text-xs border-2 border-blue-200 mr-3 group-hover:border-blue-400 transition-all">
|
||||
{{ (killer.username or killer.steam_id_64)[:2] | upper }}
|
||||
</div>
|
||||
{% endif %}
|
||||
<span class="truncate w-28 font-bold group-hover:text-blue-600 transition-colors">{{ killer.username or 'Player' }}</span>
|
||||
</div>
|
||||
</td>
|
||||
{% for victim in team2_players %}
|
||||
<!-- Kills: Killer -> Victim -->
|
||||
{% set kills = h2h_matrix.get(killer.steam_id_64, {}).get(victim.steam_id_64, 0) %}
|
||||
<!-- Deaths: Victim -> Killer (which is Killer's death) -->
|
||||
{% set deaths = h2h_matrix.get(victim.steam_id_64, {}).get(killer.steam_id_64, 0) %}
|
||||
|
||||
<td class="px-2 py-3 text-center border-l border-gray-50 dark:border-gray-700/50">
|
||||
<div class="flex items-center justify-center gap-1.5 font-mono">
|
||||
<!-- Kills -->
|
||||
<span class="{% if kills > deaths %}font-black text-lg text-green-600{% elif kills > 0 %}font-bold text-gray-900 dark:text-white{% else %}text-gray-300 dark:text-gray-600 text-xs{% endif %}">
|
||||
{{ kills }}
|
||||
</span>
|
||||
|
||||
<span class="text-gray-300 dark:text-gray-600 text-[10px]">:</span>
|
||||
|
||||
<!-- Deaths -->
|
||||
<span class="{% if deaths > kills %}font-black text-lg text-red-500{% elif deaths > 0 %}font-bold text-gray-900 dark:text-white{% else %}text-gray-300 dark:text-gray-600 text-xs{% endif %}">
|
||||
{{ deaths }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Interaction Bar (Optional visual) -->
|
||||
{% if kills + deaths > 0 %}
|
||||
<div class="w-full h-1 bg-gray-100 dark:bg-slate-700 rounded-full mt-1 overflow-hidden flex">
|
||||
{% set total = kills + deaths %}
|
||||
<div class="bg-green-500 h-full" style="width: {{ (kills / total * 100) }}%"></div>
|
||||
<div class="bg-red-500 h-full" style="width: {{ (deaths / total * 100) }}%"></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab: Round History -->
|
||||
<div x-show="tab === 'rounds'" class="bg-white dark:bg-slate-800 shadow rounded-lg p-6 space-y-4" style="display: none;">
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-4">Round by Round History</h3>
|
||||
|
||||
{% if not round_details %}
|
||||
<p class="text-gray-500">No round detail data available for this match.</p>
|
||||
{% else %}
|
||||
<div class="space-y-2">
|
||||
{% for r_num, data in round_details.items() %}
|
||||
<div x-data="{ expanded: false }" class="border border-gray-200 dark:border-gray-700 rounded-md overflow-hidden">
|
||||
<!-- Round Header -->
|
||||
<div @click="expanded = !expanded"
|
||||
class="flex items-center justify-between px-4 py-3 bg-gray-50 dark:bg-slate-700 cursor-pointer hover:bg-gray-100 dark:hover:bg-slate-600 transition">
|
||||
<div class="flex items-center space-x-4">
|
||||
<span class="text-sm font-bold text-gray-500 dark:text-gray-400">Round {{ r_num }}</span>
|
||||
|
||||
<!-- Winner Icon -->
|
||||
{% if data.info.winner_side == 'CT' %}
|
||||
<span class="px-2 py-0.5 rounded text-xs font-bold bg-blue-100 text-blue-800 border border-blue-200">
|
||||
CT Win
|
||||
</span>
|
||||
{% elif data.info.winner_side == 'T' %}
|
||||
<span class="px-2 py-0.5 rounded text-xs font-bold bg-yellow-100 text-yellow-800 border border-yellow-200">
|
||||
T Win
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="px-2 py-0.5 rounded text-xs font-bold bg-gray-100 text-gray-800">
|
||||
{{ data.info.winner_side }}
|
||||
</span>
|
||||
{% endif %}
|
||||
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ data.info.win_reason_desc }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-4">
|
||||
<span class="text-lg font-mono font-bold text-gray-900 dark:text-white">
|
||||
{{ data.info.ct_score }} - {{ data.info.t_score }}
|
||||
</span>
|
||||
<svg :class="{'rotate-180': expanded}" class="h-5 w-5 text-gray-400 transform transition-transform" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Round Details (Expanded) -->
|
||||
<div x-show="expanded" class="p-4 bg-white dark:bg-slate-800 border-t border-gray-200 dark:border-gray-700">
|
||||
|
||||
<!-- Economy Section (if available) -->
|
||||
{% if data.economy %}
|
||||
<div class="mb-4">
|
||||
<h4 class="text-xs font-bold text-gray-500 uppercase tracking-wider mb-2">Economy Snapshot</h4>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<!-- Left Team (usually CT start, but let's just list keys for now) -->
|
||||
<!-- We can map steam_id to username via existing players list if passed, or just show summary -->
|
||||
<!-- For simplicity v1: Just show count of weapons -->
|
||||
</div>
|
||||
<div class="text-xs text-gray-400 italic">
|
||||
(Detailed economy view coming soon)
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Events Timeline -->
|
||||
<div class="space-y-2">
|
||||
{% for event in data.events %}
|
||||
<div class="flex items-center text-sm">
|
||||
<span class="w-12 text-right text-gray-400 font-mono text-xs mr-4">{{ event.event_time }}s</span>
|
||||
|
||||
{% if event.event_type == 'kill' %}
|
||||
<div class="flex items-center flex-1">
|
||||
<span class="font-medium {% if event.is_headshot %}text-red-600{% else %}text-gray-900 dark:text-white{% endif %}">
|
||||
{{ player_name_map.get(event.attacker_steam_id, event.attacker_steam_id) }}
|
||||
</span>
|
||||
<span class="mx-2 text-gray-400">
|
||||
{% if event.is_headshot %}⌖{% else %}🔫{% endif %}
|
||||
</span>
|
||||
<span class="text-gray-600 dark:text-gray-300">
|
||||
{{ player_name_map.get(event.victim_steam_id, event.victim_steam_id) }}
|
||||
</span>
|
||||
<span class="ml-2 text-xs text-gray-400 bg-gray-100 dark:bg-slate-700 px-1 rounded">{{ event.weapon }}</span>
|
||||
</div>
|
||||
{% elif event.event_type == 'bomb_plant' %}
|
||||
<div class="flex items-center text-yellow-600 font-medium">
|
||||
<span>💣 Bomb Planted</span>
|
||||
</div>
|
||||
{% elif event.event_type == 'bomb_defuse' %}
|
||||
<div class="flex items-center text-blue-600 font-medium">
|
||||
<span>✂️ Bomb Defused</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Player Name Map for JS/Frontend Lookup if needed -->
|
||||
<script>
|
||||
// Optional: Pass player mapping to JS to replace IDs with Names in Timeline
|
||||
// But Jinja is cleaner if we had the map.
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
214
web/templates/matches/list.html
Normal file
@@ -0,0 +1,214 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Team Stats Summary (Party >= 2) -->
|
||||
{% if summary_stats %}
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
||||
<!-- Left Block: Map Stats -->
|
||||
<div class="bg-white dark:bg-slate-800 shadow rounded-lg p-6">
|
||||
<h3 class="text-lg font-bold text-gray-900 dark:text-white mb-4 flex items-center">
|
||||
<span class="mr-2">🗺️</span>
|
||||
地图表现 (Party ≥ 2)
|
||||
</h3>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead class="bg-gray-50 dark:bg-slate-700">
|
||||
<tr>
|
||||
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Map</th>
|
||||
<th class="px-4 py-2 text-right text-xs font-medium text-gray-500 uppercase">Matches</th>
|
||||
<th class="px-4 py-2 text-right text-xs font-medium text-gray-500 uppercase">Win Rate</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{% for stat in summary_stats.map_stats[:6] %}
|
||||
<tr>
|
||||
<td class="px-4 py-2 text-sm font-medium dark:text-white">{{ stat.label }}</td>
|
||||
<td class="px-4 py-2 text-sm text-right text-gray-500 dark:text-gray-400">{{ stat.count }}</td>
|
||||
<td class="px-4 py-2 text-sm text-right">
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<span class="font-bold {% if stat.win_rate >= 50 %}text-green-600{% else %}text-red-500{% endif %}">
|
||||
{{ "%.1f"|format(stat.win_rate) }}%
|
||||
</span>
|
||||
<div class="w-16 h-1.5 bg-gray-200 dark:bg-slate-600 rounded-full overflow-hidden">
|
||||
<div class="h-full {% if stat.win_rate >= 50 %}bg-green-500{% else %}bg-red-500{% endif %}" style="width: {{ stat.win_rate }}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Block: Context Stats -->
|
||||
<div class="bg-white dark:bg-slate-800 shadow rounded-lg p-6">
|
||||
<h3 class="text-lg font-bold text-gray-900 dark:text-white mb-4 flex items-center">
|
||||
<span class="mr-2">📊</span>
|
||||
环境胜率分析
|
||||
</h3>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- ELO Stats -->
|
||||
<div>
|
||||
<h4 class="text-xs font-bold text-gray-500 uppercase mb-2">ELO 层级表现</h4>
|
||||
<div class="grid grid-cols-7 gap-2">
|
||||
{% for stat in summary_stats.elo_stats %}
|
||||
<div class="bg-gray-50 dark:bg-slate-700 p-2 rounded text-center">
|
||||
<div class="text-[9px] text-gray-400 truncate" title="{{ stat.label }}">{{ stat.label }}</div>
|
||||
<div class="text-xs font-bold dark:text-white">{{ "%.0f"|format(stat.win_rate) }}%</div>
|
||||
<div class="text-[9px] text-gray-400">({{ stat.count }})</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Duration Stats -->
|
||||
<div>
|
||||
<h4 class="text-xs font-bold text-gray-500 uppercase mb-2">时长表现</h4>
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
{% for stat in summary_stats.duration_stats %}
|
||||
<div class="bg-gray-50 dark:bg-slate-700 p-2 rounded text-center">
|
||||
<div class="text-[10px] text-gray-400">{{ stat.label }}</div>
|
||||
<div class="text-sm font-bold dark:text-white">{{ "%.0f"|format(stat.win_rate) }}%</div>
|
||||
<div class="text-[10px] text-gray-400">({{ stat.count }})</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Round Stats -->
|
||||
<div>
|
||||
<h4 class="text-xs font-bold text-gray-500 uppercase mb-2">局势表现 (总回合数)</h4>
|
||||
<div class="grid grid-cols-4 gap-2">
|
||||
{% for stat in summary_stats.round_stats %}
|
||||
<div class="bg-gray-50 dark:bg-slate-700 p-2 rounded text-center border {% if 'Stomp' in stat.label %}border-green-200{% elif 'Close' in stat.label %}border-orange-200{% elif 'Choke' in stat.label %}border-red-200{% else %}border-gray-200{% endif %}">
|
||||
<div class="text-[9px] text-gray-400 truncate" title="{{ stat.label }}">{{ stat.label }}</div>
|
||||
<div class="text-sm font-bold dark:text-white">{{ "%.0f"|format(stat.win_rate) }}%</div>
|
||||
<div class="text-[9px] text-gray-400">({{ stat.count }})</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="bg-white dark:bg-slate-800 shadow rounded-lg p-6">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">比赛列表</h2>
|
||||
<!-- Filters (Simple placeholders) -->
|
||||
<div class="flex space-x-2">
|
||||
<!-- <input type="text" placeholder="Map..." class="border rounded px-2 py-1"> -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead class="bg-gray-50 dark:bg-slate-700">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">时间</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">地图</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">比分</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">ELO</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Party</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">时长</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white dark:bg-slate-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{% for match in matches %}
|
||||
<tr>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
|
||||
<script>document.write(new Date({{ match.start_time }} * 1000).toLocaleString())</script>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-white font-medium">
|
||||
{{ match.map_name }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-white">
|
||||
<div class="flex items-center space-x-2">
|
||||
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full {% if match.winner_team == 1 %}bg-green-100 text-green-800 border border-green-200{% else %}bg-gray-100 text-gray-500{% endif %}">
|
||||
{{ match.score_team1 }}
|
||||
{% if match.winner_team == 1 %}
|
||||
<svg class="ml-1 h-3 w-3" fill="currentColor" viewBox="0 0 20 20"><path d="M11.3 1.046A1 1 0 0112 2v5h4a1 1 0 01.82 1.573l-7 10A1 1 0 018 18v-5H4a1 1 0 01-.82-1.573l7-10a1 1 0 011.12-.38z" /></svg>
|
||||
{% endif %}
|
||||
</span>
|
||||
<span class="text-gray-400">-</span>
|
||||
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full {% if match.winner_team == 2 %}bg-green-100 text-green-800 border border-green-200{% else %}bg-gray-100 text-gray-500{% endif %}">
|
||||
{{ match.score_team2 }}
|
||||
{% if match.winner_team == 2 %}
|
||||
<svg class="ml-1 h-3 w-3" fill="currentColor" viewBox="0 0 20 20"><path d="M11.3 1.046A1 1 0 0112 2v5h4a1 1 0 01.82 1.573l-7 10A1 1 0 018 18v-5H4a1 1 0 01-.82-1.573l7-10a1 1 0 011.12-.38z" /></svg>
|
||||
{% endif %}
|
||||
</span>
|
||||
|
||||
<!-- Our Team Result Badge -->
|
||||
{% if match.our_result %}
|
||||
{% if match.our_result == 'win' %}
|
||||
<span class="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-bold bg-green-500 text-white">
|
||||
VICTORY
|
||||
</span>
|
||||
{% elif match.our_result == 'loss' %}
|
||||
<span class="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-bold bg-red-500 text-white">
|
||||
DEFEAT
|
||||
</span>
|
||||
{% elif match.our_result == 'mixed' %}
|
||||
<span class="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-bold bg-yellow-500 text-white">
|
||||
CIVIL WAR
|
||||
</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
|
||||
{% if match.avg_elo and match.avg_elo > 0 %}
|
||||
<span class="font-mono">{{ "%.0f"|format(match.avg_elo) }}</span>
|
||||
{% else %}
|
||||
<span class="text-xs text-gray-300">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
|
||||
{% if match.max_party and match.max_party > 1 %}
|
||||
{% set p = match.max_party %}
|
||||
{% set party_class = 'bg-gray-100 text-gray-800' %}
|
||||
{% if p == 2 %} {% set party_class = 'bg-indigo-100 text-indigo-800 border border-indigo-200' %}
|
||||
{% elif p == 3 %} {% set party_class = 'bg-blue-100 text-blue-800 border border-blue-200' %}
|
||||
{% elif p == 4 %} {% set party_class = 'bg-purple-100 text-purple-800 border border-purple-200' %}
|
||||
{% elif p >= 5 %} {% set party_class = 'bg-orange-100 text-orange-800 border border-orange-200' %}
|
||||
{% endif %}
|
||||
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium {{ party_class }}">
|
||||
👥 {{ match.max_party }}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="text-xs text-gray-300">Solo</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ (match.duration / 60) | int }} min
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
<a href="{{ url_for('matches.detail', match_id=match.match_id) }}" class="text-yrtv-600 hover:text-yrtv-900">详情</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div class="mt-4 flex justify-between items-center">
|
||||
<div class="text-sm text-gray-700 dark:text-gray-400">
|
||||
Total {{ total }} matches
|
||||
</div>
|
||||
<div class="flex space-x-2">
|
||||
{% if page > 1 %}
|
||||
<a href="{{ url_for('matches.index', page=page-1) }}" class="px-3 py-1 border rounded bg-white text-gray-700 hover:bg-gray-50 dark:bg-slate-700 dark:text-white dark:border-slate-600 dark:hover:bg-slate-600">Prev</a>
|
||||
{% endif %}
|
||||
{% if page < total_pages %}
|
||||
<a href="{{ url_for('matches.index', page=page+1) }}" class="px-3 py-1 border rounded bg-white text-gray-700 hover:bg-gray-50 dark:bg-slate-700 dark:text-white dark:border-slate-600 dark:hover:bg-slate-600">Next</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
251
web/templates/opponents/detail.html
Normal file
@@ -0,0 +1,251 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="space-y-8">
|
||||
<!-- 1. Header & Summary -->
|
||||
<div class="bg-white dark:bg-slate-800 shadow-xl rounded-2xl overflow-hidden border border-gray-100 dark:border-slate-700 p-8">
|
||||
<div class="flex flex-col md:flex-row items-center md:items-start gap-8">
|
||||
<!-- Avatar -->
|
||||
<div class="flex-shrink-0">
|
||||
{% if player.avatar_url %}
|
||||
<img class="h-32 w-32 rounded-2xl object-cover border-4 border-white shadow-lg" src="{{ player.avatar_url }}">
|
||||
{% else %}
|
||||
<div class="h-32 w-32 rounded-2xl bg-gradient-to-br from-red-100 to-red-200 flex items-center justify-center text-red-600 font-bold text-4xl border-4 border-white shadow-lg">
|
||||
{{ player.username[:2]|upper if player.username else '??' }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="flex-1 text-center md:text-left">
|
||||
<div class="flex items-center justify-center md:justify-start gap-3 mb-2">
|
||||
<h1 class="text-3xl font-black text-gray-900 dark:text-white">{{ player.username }}</h1>
|
||||
<span class="px-2.5 py-0.5 rounded-md text-xs font-bold bg-gray-100 text-gray-600 dark:bg-slate-700 dark:text-gray-300 font-mono">
|
||||
OPPONENT
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-sm font-mono text-gray-500 mb-6">{{ player.steam_id_64 }}</p>
|
||||
|
||||
<!-- Summary Stats -->
|
||||
<div class="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||
<div class="bg-gray-50 dark:bg-slate-700/50 p-4 rounded-xl border border-gray-100 dark:border-slate-600">
|
||||
<div class="text-xs font-bold text-gray-400 uppercase tracking-wider mb-1">Matches vs Us</div>
|
||||
<div class="text-2xl font-black text-gray-900 dark:text-white">{{ history|length }}</div>
|
||||
</div>
|
||||
|
||||
{% set wins = history | selectattr('is_win') | list | length %}
|
||||
{% set wr = (wins / history|length * 100) if history else 0 %}
|
||||
<div class="bg-gray-50 dark:bg-slate-700/50 p-4 rounded-xl border border-gray-100 dark:border-slate-600">
|
||||
<div class="text-xs font-bold text-gray-400 uppercase tracking-wider mb-1">Their Win Rate</div>
|
||||
<div class="text-2xl font-black {% if wr > 50 %}text-red-500{% else %}text-green-500{% endif %}">
|
||||
{{ "%.1f"|format(wr) }}%
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% set avg_rating = history | map(attribute='rating') | sum / history|length if history else 0 %}
|
||||
<div class="bg-gray-50 dark:bg-slate-700/50 p-4 rounded-xl border border-gray-100 dark:border-slate-600">
|
||||
<div class="text-xs font-bold text-gray-400 uppercase tracking-wider mb-1">Their Avg Rating</div>
|
||||
<div class="text-2xl font-black text-gray-900 dark:text-white">{{ "%.2f"|format(avg_rating) }}</div>
|
||||
</div>
|
||||
|
||||
{% set avg_kd_diff = history | map(attribute='kd_diff') | sum / history|length if history else 0 %}
|
||||
<div class="bg-gray-50 dark:bg-slate-700/50 p-4 rounded-xl border border-gray-100 dark:border-slate-600">
|
||||
<div class="text-xs font-bold text-gray-400 uppercase tracking-wider mb-1">Avg K/D Diff</div>
|
||||
<div class="text-2xl font-black {% if avg_kd_diff > 0 %}text-red-500{% else %}text-green-500{% endif %}">
|
||||
{{ "%+.2f"|format(avg_kd_diff) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 2. Charts & Side Analysis -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
<!-- ELO Performance Chart -->
|
||||
<div class="lg:col-span-2 bg-white dark:bg-slate-800 shadow-lg rounded-2xl p-6 border border-gray-100 dark:border-slate-700">
|
||||
<h3 class="text-lg font-bold text-gray-900 dark:text-white mb-6 flex items-center gap-2">
|
||||
<span>📈</span> Performance vs ELO Segments
|
||||
</h3>
|
||||
<div class="relative h-80 w-full">
|
||||
<canvas id="eloChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Side Stats -->
|
||||
<div class="bg-white dark:bg-slate-800 shadow-lg rounded-2xl p-6 border border-gray-100 dark:border-slate-700">
|
||||
<h3 class="text-lg font-bold text-gray-900 dark:text-white mb-6 flex items-center gap-2">
|
||||
<span>🛡️</span> Side Preference (vs Us)
|
||||
</h3>
|
||||
|
||||
{% macro side_row(label, t_val, ct_val, format_str='{:.2f}') %}
|
||||
<div class="mb-6">
|
||||
<div class="flex justify-between text-xs font-bold text-gray-500 uppercase mb-2">
|
||||
<span>{{ label }}</span>
|
||||
</div>
|
||||
<div class="flex items-end justify-between gap-2 mb-2">
|
||||
<span class="text-2xl font-black text-amber-500">{{ (format_str.format(t_val) if t_val is not none else '—') }}</span>
|
||||
<span class="text-xs font-bold text-gray-400">vs</span>
|
||||
<span class="text-2xl font-black text-blue-500">{{ (format_str.format(ct_val) if ct_val is not none else '—') }}</span>
|
||||
</div>
|
||||
<div class="flex h-2 w-full rounded-full overflow-hidden bg-gray-200 dark:bg-slate-600">
|
||||
{% set has_t = t_val is not none %}
|
||||
{% set has_ct = ct_val is not none %}
|
||||
{% set total = (t_val or 0) + (ct_val or 0) %}
|
||||
{% if total > 0 and has_t and has_ct %}
|
||||
{% set t_pct = ((t_val or 0) / total) * 100 %}
|
||||
<div class="h-full bg-amber-500" style="width: {{ t_pct }}%"></div>
|
||||
<div class="h-full bg-blue-500 flex-1"></div>
|
||||
{% else %}
|
||||
<div class="h-full w-1/2 bg-gray-300"></div>
|
||||
<div class="h-full w-1/2 bg-gray-400"></div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="flex justify-between text-[10px] font-bold text-gray-400 mt-1">
|
||||
<span>T-Side</span>
|
||||
<span>CT-Side</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{{ side_row('Rating', side_stats.get('rating_t'), side_stats.get('rating_ct')) }}
|
||||
{{ side_row('K/D Ratio', side_stats.get('kd_t'), side_stats.get('kd_ct')) }}
|
||||
|
||||
<div class="mt-8 p-4 bg-gray-50 dark:bg-slate-700/30 rounded-xl text-center">
|
||||
<div class="text-xs font-bold text-gray-400 uppercase mb-1">Rounds Sampled</div>
|
||||
<div class="text-xl font-black text-gray-700 dark:text-gray-200">
|
||||
{{ (side_stats.get('rounds_t', 0) or 0) + (side_stats.get('rounds_ct', 0) or 0) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 3. Match History Table -->
|
||||
<div class="bg-white dark:bg-slate-800 shadow-lg rounded-2xl overflow-hidden border border-gray-100 dark:border-slate-700">
|
||||
<div class="p-6 border-b border-gray-100 dark:border-slate-700">
|
||||
<h3 class="text-lg font-bold text-gray-900 dark:text-white">Match History (Head-to-Head)</h3>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-slate-700">
|
||||
<thead class="bg-gray-50 dark:bg-slate-700/50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">Date / Map</th>
|
||||
<th class="px-6 py-3 text-center text-xs font-bold text-gray-500 uppercase tracking-wider">Their Result</th>
|
||||
<th class="px-6 py-3 text-center text-xs font-bold text-gray-500 uppercase tracking-wider">Match Elo</th>
|
||||
<th class="px-6 py-3 text-center text-xs font-bold text-gray-500 uppercase tracking-wider">Their Rating</th>
|
||||
<th class="px-6 py-3 text-center text-xs font-bold text-gray-500 uppercase tracking-wider">Their K/D</th>
|
||||
<th class="px-6 py-3 text-center text-xs font-bold text-gray-500 uppercase tracking-wider">K/D Diff (vs Team)</th>
|
||||
<th class="px-6 py-3 text-center text-xs font-bold text-gray-500 uppercase tracking-wider">K / D</th>
|
||||
<th class="px-6 py-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100 dark:divide-slate-700 bg-white dark:bg-slate-800">
|
||||
{% for m in history %}
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700/50 transition-colors">
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="text-sm font-bold text-gray-900 dark:text-white">{{ m.map_name }}</div>
|
||||
<div class="text-xs text-gray-500 font-mono">
|
||||
<script>document.write(new Date({{ m.start_time }} * 1000).toLocaleDateString())</script>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-center">
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded text-[10px] font-black uppercase tracking-wide
|
||||
{% if m.is_win %}bg-green-100 text-green-700 border border-green-200
|
||||
{% else %}bg-red-50 text-red-600 border border-red-100{% endif %}">
|
||||
{{ 'WON' if m.is_win else 'LOST' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-center text-sm font-mono text-gray-500">
|
||||
{{ "%.0f"|format(m.elo or 0) }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-center">
|
||||
<span class="text-sm font-bold font-mono">{{ "%.2f"|format(m.rating or 0) }}</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-center text-sm font-mono text-gray-600 dark:text-gray-400">
|
||||
{{ "%.2f"|format(m.kd_ratio or 0) }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-center">
|
||||
{% set diff = m.kd_diff %}
|
||||
<span class="text-sm font-bold font-mono {% if diff > 0 %}text-red-500{% else %}text-green-500{% endif %}">
|
||||
{{ "%+.2f"|format(diff) }}
|
||||
</span>
|
||||
<div class="text-[10px] text-gray-400">vs Team Avg {{ "%.2f"|format(m.other_team_kd or 0) }}</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-center text-sm font-mono text-gray-500">
|
||||
{{ m.kills }} / {{ m.deaths }}
|
||||
</td>
|
||||
<td class="px-6 py-4 text-right">
|
||||
<a href="{{ url_for('matches.detail', match_id=m.match_id) }}" class="text-gray-400 hover:text-yrtv-600 transition">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const eloData = {{ elo_stats | tojson }};
|
||||
const labels = eloData.map(d => d.range);
|
||||
const ratings = eloData.map(d => d.avg_rating);
|
||||
const kds = eloData.map(d => d.avg_kd);
|
||||
|
||||
const ctx = document.getElementById('eloChart').getContext('2d');
|
||||
new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [
|
||||
{
|
||||
label: 'Avg Rating',
|
||||
data: ratings,
|
||||
backgroundColor: 'rgba(124, 58, 237, 0.6)',
|
||||
borderColor: 'rgba(124, 58, 237, 1)',
|
||||
borderWidth: 1,
|
||||
yAxisID: 'y'
|
||||
},
|
||||
{
|
||||
type: 'line',
|
||||
label: 'Avg K/D',
|
||||
data: kds,
|
||||
borderColor: 'rgba(234, 179, 8, 1)',
|
||||
borderWidth: 2,
|
||||
tension: 0.3,
|
||||
pointBackgroundColor: '#fff',
|
||||
yAxisID: 'y1'
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
mode: 'index',
|
||||
intersect: false,
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
type: 'linear',
|
||||
display: true,
|
||||
position: 'left',
|
||||
title: { display: true, text: 'Rating' },
|
||||
grid: { color: 'rgba(156, 163, 175, 0.1)' }
|
||||
},
|
||||
y1: {
|
||||
type: 'linear',
|
||||
display: true,
|
||||
position: 'right',
|
||||
title: { display: true, text: 'K/D Ratio' },
|
||||
grid: { drawOnChartArea: false }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
329
web/templates/opponents/index.html
Normal file
@@ -0,0 +1,329 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
<!-- Global Stats Dashboard -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<!-- Opponent ELO Distribution -->
|
||||
<div class="bg-white dark:bg-slate-800 shadow-lg rounded-2xl p-6 border border-gray-100 dark:border-slate-700">
|
||||
<h3 class="text-sm font-bold text-gray-500 uppercase tracking-wider mb-4">Opponent ELO Curve</h3>
|
||||
<div class="relative h-48 w-full">
|
||||
<canvas id="eloDistChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Opponent Rating Distribution -->
|
||||
<div class="bg-white dark:bg-slate-800 shadow-lg rounded-2xl p-6 border border-gray-100 dark:border-slate-700">
|
||||
<h3 class="text-sm font-bold text-gray-500 uppercase tracking-wider mb-4">Opponent Rating Curve</h3>
|
||||
<div class="relative h-48 w-full">
|
||||
<canvas id="ratingDistChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Map-specific Opponent Stats -->
|
||||
<div class="bg-white dark:bg-slate-800 shadow-lg rounded-2xl overflow-hidden border border-gray-100 dark:border-slate-700">
|
||||
<div class="p-6 border-b border-gray-100 dark:border-slate-700">
|
||||
<h3 class="text-lg font-bold text-gray-900 dark:text-white">分地图对手统计</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">各地图下遇到对手的胜率、ELO、Rating、K/D</p>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-slate-700">
|
||||
<thead class="bg-gray-50 dark:bg-slate-700/50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">Map</th>
|
||||
<th class="px-6 py-3 text-center text-xs font-bold text-gray-500 uppercase tracking-wider">Matches</th>
|
||||
<th class="px-6 py-3 text-center text-xs font-bold text-gray-500 uppercase tracking-wider">Win Rate</th>
|
||||
<th class="px-6 py-3 text-center text-xs font-bold text-gray-500 uppercase tracking-wider">Avg Rating</th>
|
||||
<th class="px-6 py-3 text-center text-xs font-bold text-gray-500 uppercase tracking-wider">Avg K/D</th>
|
||||
<th class="px-6 py-3 text-center text-xs font-bold text-gray-500 uppercase tracking-wider">Avg Elo</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white dark:bg-slate-800 divide-y divide-gray-200 dark:divide-slate-700">
|
||||
{% for m in map_stats %}
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700/50 transition-colors">
|
||||
<td class="px-6 py-3 whitespace-nowrap text-sm font-bold text-gray-900 dark:text-white">{{ m.map_name }}</td>
|
||||
<td class="px-6 py-3 whitespace-nowrap text-center">
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800 dark:bg-slate-700 dark:text-gray-300">
|
||||
{{ m.matches }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-3 whitespace-nowrap text-center">
|
||||
{% set wr = (m.win_rate or 0) * 100 %}
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-md text-xs font-bold
|
||||
{% if wr > 60 %}bg-red-100 text-red-800 border border-red-200
|
||||
{% elif wr < 40 %}bg-green-100 text-green-800 border border-green-200
|
||||
{% else %}bg-gray-100 text-gray-800 border border-gray-200{% endif %}">
|
||||
{{ "%.1f"|format(wr) }}%
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-3 whitespace-nowrap text-center text-sm font-mono font-bold text-gray-700 dark:text-gray-300">
|
||||
{{ "%.2f"|format(m.avg_rating or 0) }}
|
||||
</td>
|
||||
<td class="px-6 py-3 whitespace-nowrap text-center text-sm font-mono text-gray-600 dark:text-gray-400">
|
||||
{{ "%.2f"|format(m.avg_kd or 0) }}
|
||||
</td>
|
||||
<td class="px-6 py-3 whitespace-nowrap text-center text-sm font-mono text-gray-500">
|
||||
{% if m.avg_elo %}{{ "%.0f"|format(m.avg_elo) }}{% else %}—{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="6" class="px-6 py-8 text-center text-gray-500 dark:text-gray-400">暂无地图统计数据</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Map-specific Shark Encounters -->
|
||||
<div class="bg-white dark:bg-slate-800 shadow-lg rounded-2xl overflow-hidden border border-gray-100 dark:border-slate-700">
|
||||
<div class="p-6 border-b border-gray-100 dark:border-slate-700">
|
||||
<h3 class="text-lg font-bold text-gray-900 dark:text-white">分地图炸鱼哥遭遇次数</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">统计各地图出现 rating > 1.5 对手的比赛次数</p>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-slate-700">
|
||||
<thead class="bg-gray-50 dark:bg-slate-700/50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">Map</th>
|
||||
<th class="px-6 py-3 text-center text-xs font-bold text-gray-500 uppercase tracking-wider">Encounters</th>
|
||||
<th class="px-6 py-3 text-center text-xs font-bold text-gray-500 uppercase tracking-wider">Frequency</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white dark:bg-slate-800 divide-y divide-gray-200 dark:divide-slate-700">
|
||||
{% for m in map_stats %}
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700/50 transition-colors">
|
||||
<td class="px-6 py-3 whitespace-nowrap text-sm font-bold text-gray-900 dark:text-white">{{ m.map_name }}</td>
|
||||
<td class="px-6 py-3 whitespace-nowrap text-center">
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-800 border border-amber-200 dark:bg-slate-700 dark:text-amber-300 dark:border-slate-600">
|
||||
{{ m.shark_matches or 0 }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-3 whitespace-nowrap text-center">
|
||||
{% set freq = ( (m.shark_matches or 0) / (m.matches or 1) ) * 100 %}
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-md text-[10px] font-bold bg-gray-100 text-gray-800 border border-gray-200 dark:bg-slate-700 dark:text-gray-300 dark:border-slate-600">
|
||||
{{ "%.1f"|format(freq) }}%
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="3" class="px-6 py-8 text-center text-gray-500 dark:text-gray-400">暂无炸鱼哥统计数据</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white dark:bg-slate-800 shadow-lg rounded-2xl overflow-hidden border border-gray-100 dark:border-slate-700 p-6">
|
||||
<div class="flex flex-col sm:flex-row justify-between items-center mb-6 gap-4">
|
||||
<div>
|
||||
<h2 class="text-2xl font-black text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<span>⚔️</span> 对手分析 (Opponent Analysis)
|
||||
</h2>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
Analyze performance against specific players encountered in matches.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col sm:flex-row gap-4 w-full sm:w-auto">
|
||||
<!-- Sort Dropdown -->
|
||||
<div class="relative">
|
||||
<select onchange="location = this.value;" class="w-full sm:w-auto appearance-none pl-3 pr-10 py-2 border border-gray-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-sm focus:outline-none focus:ring-2 focus:ring-yrtv-500 dark:text-white">
|
||||
<option value="{{ url_for('opponents.index', search=request.args.get('search', ''), sort='matches') }}" {% if sort_by == 'matches' %}selected{% endif %}>Sort by Matches</option>
|
||||
<option value="{{ url_for('opponents.index', search=request.args.get('search', ''), sort='rating') }}" {% if sort_by == 'rating' %}selected{% endif %}>Sort by Rating</option>
|
||||
<option value="{{ url_for('opponents.index', search=request.args.get('search', ''), sort='kd') }}" {% if sort_by == 'kd' %}selected{% endif %}>Sort by K/D</option>
|
||||
<option value="{{ url_for('opponents.index', search=request.args.get('search', ''), sort='win_rate') }}" {% if sort_by == 'win_rate' %}selected{% endif %}>Sort by Win Rate (Nemesis)</option>
|
||||
</select>
|
||||
<div class="pointer-events-none absolute inset-y-0 right-0 flex items-center px-2 text-gray-500">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path></svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form action="{{ url_for('opponents.index') }}" method="get" class="flex gap-2">
|
||||
<input type="hidden" name="sort" value="{{ sort_by }}">
|
||||
<input type="text" name="search" placeholder="Search opponent..." class="w-full sm:w-64 px-4 py-2 border border-gray-300 dark:border-slate-600 rounded-lg bg-gray-50 dark:bg-slate-700/50 focus:outline-none focus:ring-2 focus:ring-yrtv-500 dark:text-white transition" value="{{ request.args.get('search', '') }}">
|
||||
<button type="submit" class="px-4 py-2 bg-yrtv-600 text-white font-bold rounded-lg hover:bg-yrtv-700 transition shadow-lg shadow-yrtv-500/30">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path></svg>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-slate-700">
|
||||
<thead class="bg-gray-50 dark:bg-slate-700/50">
|
||||
<tr>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">Opponent</th>
|
||||
<th scope="col" class="px-6 py-3 text-center text-xs font-bold text-gray-500 uppercase tracking-wider">Matches vs Us</th>
|
||||
<th scope="col" class="px-6 py-3 text-center text-xs font-bold text-gray-500 uppercase tracking-wider">Their Win Rate</th>
|
||||
<th scope="col" class="px-6 py-3 text-center text-xs font-bold text-gray-500 uppercase tracking-wider">Their Rating</th>
|
||||
<th scope="col" class="px-6 py-3 text-center text-xs font-bold text-gray-500 uppercase tracking-wider">Their K/D</th>
|
||||
<th scope="col" class="px-6 py-3 text-center text-xs font-bold text-gray-500 uppercase tracking-wider">Avg Match Elo</th>
|
||||
<th scope="col" class="relative px-6 py-3"><span class="sr-only">View</span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white dark:bg-slate-800 divide-y divide-gray-200 dark:divide-slate-700">
|
||||
{% for op in opponents %}
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700/50 transition-colors group">
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0 h-10 w-10">
|
||||
{% if op.avatar_url %}
|
||||
<img class="h-10 w-10 rounded-full object-cover border-2 border-white shadow-sm" src="{{ op.avatar_url }}" alt="">
|
||||
{% else %}
|
||||
<div class="h-10 w-10 rounded-full bg-gradient-to-br from-gray-100 to-gray-300 flex items-center justify-center text-gray-500 font-bold text-xs">
|
||||
{{ op.username[:2]|upper if op.username else '??' }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<div class="text-sm font-bold text-gray-900 dark:text-white">{{ op.username }}</div>
|
||||
<div class="text-xs text-gray-500 font-mono">{{ op.steam_id_64 }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-center">
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800 dark:bg-slate-700 dark:text-gray-300">
|
||||
{{ op.matches }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-center">
|
||||
{% set wr = op.win_rate * 100 %}
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-md text-xs font-bold
|
||||
{% if wr > 60 %}bg-red-100 text-red-800 border border-red-200
|
||||
{% elif wr < 40 %}bg-green-100 text-green-800 border border-green-200
|
||||
{% else %}bg-gray-100 text-gray-800 border border-gray-200{% endif %}">
|
||||
{{ "%.1f"|format(wr) }}%
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-center text-sm font-mono font-bold text-gray-700 dark:text-gray-300">
|
||||
{{ "%.2f"|format(op.avg_rating or 0) }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-center text-sm font-mono text-gray-600 dark:text-gray-400">
|
||||
{{ "%.2f"|format(op.avg_kd or 0) }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-center text-sm font-mono text-gray-500">
|
||||
{% if op.avg_match_elo %}
|
||||
{{ "%.0f"|format(op.avg_match_elo) }}
|
||||
{% else %}—{% endif %}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<a href="{{ url_for('opponents.detail', steam_id=op.steam_id_64) }}" class="text-yrtv-600 hover:text-yrtv-900 font-bold hover:underline">Analyze →</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="7" class="px-6 py-12 text-center text-gray-500 dark:text-gray-400">
|
||||
No opponents found.
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div class="mt-6 flex justify-between items-center border-t border-gray-200 dark:border-slate-700 pt-4">
|
||||
<div class="text-sm text-gray-700 dark:text-gray-400">
|
||||
Total <span class="font-bold">{{ total }}</span> opponents found
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
{% if page > 1 %}
|
||||
<a href="{{ url_for('opponents.index', page=page-1, search=request.args.get('search', ''), sort=sort_by) }}" class="px-4 py-2 border border-gray-300 rounded-lg text-sm font-medium text-gray-700 hover:bg-gray-50 dark:bg-slate-700 dark:text-white dark:border-slate-600 dark:hover:bg-slate-600 transition">Previous</a>
|
||||
{% endif %}
|
||||
{% if page < total_pages %}
|
||||
<a href="{{ url_for('opponents.index', page=page+1, search=request.args.get('search', ''), sort=sort_by) }}" class="px-4 py-2 border border-gray-300 rounded-lg text-sm font-medium text-gray-700 hover:bg-gray-50 dark:bg-slate-700 dark:text-white dark:border-slate-600 dark:hover:bg-slate-600 transition">Next</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Data from Backend
|
||||
const stats = {{ stats_summary | tojson }};
|
||||
|
||||
const createChart = (id, label, labels, data, color, type='line') => {
|
||||
const ctx = document.getElementById(id).getContext('2d');
|
||||
new Chart(ctx, {
|
||||
type: type,
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [{
|
||||
label: label,
|
||||
data: data,
|
||||
backgroundColor: 'rgba(124, 58, 237, 0.1)',
|
||||
borderColor: color,
|
||||
tension: 0.35,
|
||||
fill: true,
|
||||
borderRadius: 4,
|
||||
barPercentage: 0.6
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { display: false }
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
grid: { color: 'rgba(156, 163, 175, 0.1)' },
|
||||
ticks: { display: false } // Hide Y axis labels for cleaner look
|
||||
},
|
||||
x: {
|
||||
grid: { display: false },
|
||||
ticks: { font: { size: 10 } }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const buildBins = (values, step, roundFn) => {
|
||||
if (!values || values.length === 0) return { labels: [], data: [] };
|
||||
const min = Math.min(...values);
|
||||
const max = Math.max(...values);
|
||||
let start = Math.floor(min / step) * step;
|
||||
let end = Math.ceil(max / step) * step;
|
||||
const bins = [];
|
||||
const labels = [];
|
||||
for (let v = start; v <= end; v += step) {
|
||||
bins.push(0);
|
||||
labels.push(roundFn(v));
|
||||
}
|
||||
values.forEach(val => {
|
||||
const idx = Math.floor((val - start) / step);
|
||||
if (idx >= 0 && idx < bins.length) bins[idx] += 1;
|
||||
});
|
||||
return { labels, data: bins };
|
||||
};
|
||||
|
||||
if (stats.elo_values && stats.elo_values.length) {
|
||||
const eloStep = 100; // 可按需改为50
|
||||
const { labels, data } = buildBins(stats.elo_values, eloStep, v => Math.round(v));
|
||||
createChart('eloDistChart', 'Opponents', labels, data, 'rgba(124, 58, 237, 1)', 'line');
|
||||
} else if (stats.elo_dist) {
|
||||
createChart('eloDistChart', 'Opponents', Object.keys(stats.elo_dist), Object.values(stats.elo_dist), 'rgba(124, 58, 237, 1)', 'line');
|
||||
}
|
||||
|
||||
if (stats.rating_values && stats.rating_values.length) {
|
||||
const rStep = 0.1; // 可按需改为0.2
|
||||
const { labels, data } = buildBins(stats.rating_values, rStep, v => Number(v.toFixed(1)));
|
||||
createChart('ratingDistChart', 'Opponents', labels, data, 'rgba(234, 179, 8, 1)', 'line');
|
||||
} else if (stats.rating_dist) {
|
||||
const order = ['<0.8','0.8-1.0','1.0-1.2','1.2-1.4','>1.4'];
|
||||
const labels = order.filter(k => stats.rating_dist.hasOwnProperty(k));
|
||||
const data = labels.map(k => stats.rating_dist[k]);
|
||||
createChart('ratingDistChart', 'Opponents', labels, data, 'rgba(234, 179, 8, 1)', 'line');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
78
web/templates/players/list.html
Normal file
@@ -0,0 +1,78 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="bg-white dark:bg-slate-800 shadow rounded-lg p-6">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">玩家列表</h2>
|
||||
<div class="flex space-x-4">
|
||||
<!-- Sort Dropdown -->
|
||||
<div class="relative inline-block text-left">
|
||||
<select onchange="location = this.value;" class="border rounded px-2 py-1 dark:bg-slate-700 dark:text-white dark:border-slate-600">
|
||||
<option value="{{ url_for('players.index', search=request.args.get('search', ''), sort='rating') }}" {% if sort_by == 'rating' %}selected{% endif %}>Sort by Rating</option>
|
||||
<option value="{{ url_for('players.index', search=request.args.get('search', ''), sort='kd') }}" {% if sort_by == 'kd' %}selected{% endif %}>Sort by K/D</option>
|
||||
<option value="{{ url_for('players.index', search=request.args.get('search', ''), sort='kast') }}" {% if sort_by == 'kast' %}selected{% endif %}>Sort by KAST</option>
|
||||
<option value="{{ url_for('players.index', search=request.args.get('search', ''), sort='matches') }}" {% if sort_by == 'matches' %}selected{% endif %}>Sort by Matches</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<form action="{{ url_for('players.index') }}" method="get" class="flex space-x-2">
|
||||
<input type="hidden" name="sort" value="{{ sort_by }}">
|
||||
<input type="text" name="search" placeholder="Search player..." class="border rounded px-2 py-1 dark:bg-slate-700 dark:text-white dark:border-slate-600" value="{{ request.args.get('search', '') }}">
|
||||
<button type="submit" class="px-3 py-1 bg-yrtv-600 text-white rounded hover:bg-yrtv-500">Search</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{% for player in players %}
|
||||
<div class="bg-gray-50 dark:bg-slate-700 rounded-lg p-4 flex flex-col items-center hover:shadow-lg transition">
|
||||
<!-- Avatar -->
|
||||
{% if player.avatar_url %}
|
||||
<img class="h-20 w-20 rounded-full mb-4 object-cover border-4 border-white shadow-sm" src="{{ player.avatar_url }}" alt="{{ player.username }}">
|
||||
{% else %}
|
||||
<div class="h-20 w-20 rounded-full mb-4 bg-yrtv-100 flex items-center justify-center text-yrtv-600 font-bold text-2xl border-4 border-white shadow-sm">
|
||||
{{ player.username[:2] | upper if player.username else '??' }}
|
||||
</div>
|
||||
{% endif %}
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-white">{{ player.username }}</h3>
|
||||
<p class="text-sm text-gray-500 mb-2">{{ player.steam_id_64 }}</p>
|
||||
|
||||
<!-- Mini Stats -->
|
||||
<div class="grid grid-cols-3 gap-x-4 gap-y-2 text-xs text-gray-600 dark:text-gray-300 mb-4 w-full text-center">
|
||||
<div>
|
||||
<span class="block font-bold">{{ "%.2f"|format(player.basic_avg_rating|default(0)) }}</span>
|
||||
<span class="text-gray-400">Rating</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="block font-bold">{{ "%.2f"|format(player.basic_avg_kd|default(0)) }}</span>
|
||||
<span class="text-gray-400">K/D</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="block font-bold">{{ "%.1f"|format((player.basic_avg_kast|default(0)) * 100) }}%</span>
|
||||
<span class="text-gray-400">KAST</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a href="{{ url_for('players.detail', steam_id=player.steam_id_64) }}" class="mt-auto px-4 py-2 border border-transparent text-sm font-medium rounded-md text-yrtv-700 bg-yrtv-100 hover:bg-yrtv-200 dark:bg-slate-800 dark:text-yrtv-300 dark:hover:bg-slate-600 dark:border-slate-600">
|
||||
View Profile
|
||||
</a>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div class="mt-6 flex justify-between items-center">
|
||||
<div class="text-sm text-gray-700 dark:text-gray-400">
|
||||
Total {{ total }} players
|
||||
</div>
|
||||
<div class="flex space-x-2">
|
||||
{% if page > 1 %}
|
||||
<a href="{{ url_for('players.index', page=page-1, search=request.args.get('search', '')) }}" class="px-3 py-1 border rounded bg-white text-gray-700 hover:bg-gray-50 dark:bg-slate-700 dark:text-white dark:border-slate-600 dark:hover:bg-slate-600">Prev</a>
|
||||
{% endif %}
|
||||
{% if page < total_pages %}
|
||||
<a href="{{ url_for('players.index', page=page+1, search=request.args.get('search', '')) }}" class="px-3 py-1 border rounded bg-white text-gray-700 hover:bg-gray-50 dark:bg-slate-700 dark:text-white dark:border-slate-600 dark:hover:bg-slate-600">Next</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
1276
web/templates/players/profile.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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 %}
|
||||
278
web/templates/teams/clubhouse.html
Normal file
@@ -0,0 +1,278 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}My Team - Clubhouse{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8" x-data="clubhouse()">
|
||||
<!-- Header -->
|
||||
<div class="md:flex md:items-center md:justify-between mb-8">
|
||||
<div class="flex-1 min-w-0">
|
||||
<h2 class="text-2xl font-bold leading-7 text-gray-900 dark:text-white sm:text-3xl sm:truncate">
|
||||
<span x-text="team.name || 'My Team'"></span>
|
||||
<span class="ml-2 text-sm font-normal text-gray-500" x-text="team.description"></span>
|
||||
</h2>
|
||||
</div>
|
||||
<div class="mt-4 flex md:mt-0 md:ml-4">
|
||||
{% if session.get('is_admin') %}
|
||||
<button @click="showScoutModal = true" type="button" class="ml-3 inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-yrtv-600 hover:bg-yrtv-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-yrtv-500">
|
||||
<span class="mr-2">🔍</span> Scout Player
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sorting Controls -->
|
||||
<div class="flex justify-end mb-4">
|
||||
<div class="inline-flex shadow-sm rounded-md" role="group">
|
||||
<button type="button" @click="sortBy('rating')" :class="{'bg-yrtv-600 text-white': currentSort === 'rating', 'bg-white text-gray-700 hover:bg-gray-50': currentSort !== 'rating'}" class="px-4 py-2 text-sm font-medium border border-gray-200 rounded-l-lg dark:bg-slate-700 dark:border-slate-600 dark:text-white dark:hover:bg-slate-600">
|
||||
Rating
|
||||
</button>
|
||||
<button type="button" @click="sortBy('kd')" :class="{'bg-yrtv-600 text-white': currentSort === 'kd', 'bg-white text-gray-700 hover:bg-gray-50': currentSort !== 'kd'}" class="px-4 py-2 text-sm font-medium border-t border-b border-gray-200 dark:bg-slate-700 dark:border-slate-600 dark:text-white dark:hover:bg-slate-600">
|
||||
K/D
|
||||
</button>
|
||||
<button type="button" @click="sortBy('matches')" :class="{'bg-yrtv-600 text-white': currentSort === 'matches', 'bg-white text-gray-700 hover:bg-gray-50': currentSort !== 'matches'}" class="px-4 py-2 text-sm font-medium border border-gray-200 rounded-r-lg dark:bg-slate-700 dark:border-slate-600 dark:text-white dark:hover:bg-slate-600">
|
||||
Matches
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Active Roster (Grid) -->
|
||||
<div class="mb-10">
|
||||
<h3 class="text-lg leading-6 font-medium text-gray-900 dark:text-white mb-4">Active Roster</h3>
|
||||
<!-- Dynamic Grid based on roster size, default to 5 slots + 1 add button -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-6">
|
||||
<!-- Render Actual Roster -->
|
||||
<template x-for="(player, index) in roster" :key="player.steam_id_64">
|
||||
<div class="relative bg-white dark:bg-slate-800 rounded-lg shadow-md border border-gray-200 dark:border-slate-600 h-80 flex flex-col items-center justify-center p-4 transition hover:border-yrtv-400">
|
||||
|
||||
<div class="w-full h-full flex flex-col items-center">
|
||||
<div class="relative w-32 h-32 mb-4">
|
||||
<!-- Avatar Logic: Image or Initials -->
|
||||
<template x-if="player.avatar_url">
|
||||
<img :src="player.avatar_url" class="w-32 h-32 rounded-full object-cover border-4 border-yrtv-500 shadow-lg">
|
||||
</template>
|
||||
<template x-if="!player.avatar_url">
|
||||
<div class="w-32 h-32 rounded-full bg-yrtv-100 flex items-center justify-center border-4 border-yrtv-500 shadow-lg text-yrtv-600 font-bold text-4xl">
|
||||
<span x-text="(player.username || player.name || player.steam_id_64).substring(0, 2).toUpperCase()"></span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<h4 class="text-lg font-bold text-gray-900 dark:text-white truncate w-full text-center" x-text="player.username || player.name || player.steam_id_64"></h4>
|
||||
<div class="flex flex-wrap justify-center gap-1 mb-4 min-h-[1.5rem]">
|
||||
<template x-for="tag in (player.tags || [])">
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300" x-text="tag"></span>
|
||||
</template>
|
||||
<template x-if="!player.tags || player.tags.length === 0">
|
||||
<span class="text-xs text-gray-400 italic">No tags</span>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Stats Grid -->
|
||||
<div class="grid grid-cols-2 gap-2 w-full text-center mb-auto">
|
||||
<div class="bg-gray-50 dark:bg-slate-700 rounded p-1">
|
||||
<div class="text-xs text-gray-400">Rating</div>
|
||||
<div class="font-bold text-yrtv-600 dark:text-yrtv-400" x-text="(player.stats?.basic_avg_rating || 0).toFixed(2)"></div>
|
||||
</div>
|
||||
<div class="bg-gray-50 dark:bg-slate-700 rounded p-1">
|
||||
<div class="text-xs text-gray-400">K/D</div>
|
||||
<div class="font-bold" x-text="(player.stats?.basic_avg_kd || 0).toFixed(2)"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex space-x-2 mt-2">
|
||||
<a :href="'/players/' + player.steam_id_64" class="text-yrtv-600 hover:text-yrtv-800 text-sm font-medium">Profile</a>
|
||||
{% if session.get('is_admin') %}
|
||||
<button @click="removePlayer(player.steam_id_64)" class="text-red-500 hover:text-red-700 text-sm font-medium">Release</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Add Player Slot (Only for Admin) -->
|
||||
{% if session.get('is_admin') %}
|
||||
<div class="relative bg-gray-50 dark:bg-slate-800/50 rounded-lg shadow-sm border-2 border-dashed border-gray-300 dark:border-slate-600 h-80 flex flex-col items-center justify-center p-4 hover:border-yrtv-400 transition cursor-pointer" @click="showScoutModal = true">
|
||||
<div class="w-16 h-16 rounded-full bg-white dark:bg-slate-700 flex items-center justify-center mb-3 group-hover:bg-yrtv-100 dark:group-hover:bg-slate-600 transition">
|
||||
<svg class="w-8 h-8 text-gray-400 group-hover:text-yrtv-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path></svg>
|
||||
</div>
|
||||
<span class="text-sm font-medium text-gray-500 dark:text-gray-400 group-hover:text-yrtv-600">Add Player</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bench / Extended Roster (Hidden as logic is merged into main grid) -->
|
||||
<!-- The grid above now handles unlimited players, so we remove the separate Bench section to avoid duplication -->
|
||||
|
||||
<!-- Scout Modal -->
|
||||
<div x-show="showScoutModal" class="fixed inset-0 z-10 overflow-y-auto" style="display: none;">
|
||||
<div class="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
||||
<div class="fixed inset-0 transition-opacity" aria-hidden="true" @click="showScoutModal = false">
|
||||
<div class="absolute inset-0 bg-gray-500 opacity-75"></div>
|
||||
</div>
|
||||
|
||||
<span class="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">​</span>
|
||||
|
||||
<div class="inline-block align-bottom bg-white dark:bg-slate-800 rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg w-full">
|
||||
<div class="bg-white dark:bg-slate-800 px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
|
||||
<h3 class="text-lg leading-6 font-medium text-gray-900 dark:text-white mb-4">Scout New Player</h3>
|
||||
|
||||
<!-- Search Input -->
|
||||
<div class="mt-2 relative rounded-md shadow-sm">
|
||||
<input type="text" x-model="searchQuery" @input.debounce.300ms="searchPlayers()" placeholder="Search by name..." class="focus:ring-yrtv-500 focus:border-yrtv-500 block w-full pl-4 pr-12 sm:text-sm border-gray-300 dark:bg-slate-700 dark:border-slate-600 dark:text-white rounded-md h-12">
|
||||
</div>
|
||||
|
||||
<!-- Results List -->
|
||||
<div class="mt-4 max-h-60 overflow-y-auto">
|
||||
<template x-if="searchResults.length === 0 && searchQuery.length > 1">
|
||||
<p class="text-sm text-gray-500 text-center py-4">No players found.</p>
|
||||
</template>
|
||||
|
||||
<ul class="divide-y divide-gray-200 dark:divide-slate-700">
|
||||
<template x-for="player in searchResults" :key="player.steam_id">
|
||||
<li class="py-3 flex items-center justify-between hover:bg-gray-50 dark:hover:bg-slate-700 px-2 rounded cursor-pointer">
|
||||
<div class="flex items-center">
|
||||
<img :src="player.avatar" class="h-10 w-10 rounded-full">
|
||||
<div class="ml-3">
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-white" x-text="player.name"></p>
|
||||
<p class="text-xs text-gray-500" x-text="player.matches + ' matches'"></p>
|
||||
</div>
|
||||
</div>
|
||||
<button @click="signPlayer(player.steam_id)" class="inline-flex items-center px-3 py-1 border border-transparent text-xs font-medium rounded text-yrtv-700 bg-yrtv-100 hover:bg-yrtv-200 dark:bg-yrtv-700 dark:text-white dark:hover:bg-yrtv-600">
|
||||
Sign
|
||||
</button>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-gray-50 dark:bg-slate-700 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
|
||||
<button type="button" @click="showScoutModal = false" class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm dark:bg-slate-600 dark:text-white dark:border-slate-500">
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function clubhouse() {
|
||||
return {
|
||||
team: {},
|
||||
roster: [],
|
||||
currentSort: 'rating', // Default sort
|
||||
showScoutModal: false,
|
||||
searchQuery: '',
|
||||
searchResults: [],
|
||||
|
||||
init() {
|
||||
this.fetchRoster();
|
||||
},
|
||||
|
||||
fetchRoster() {
|
||||
fetch('/teams/api/roster')
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
this.team = data.team;
|
||||
this.roster = data.roster;
|
||||
this.sortRoster(); // Apply default sort
|
||||
});
|
||||
},
|
||||
|
||||
sortBy(key) {
|
||||
this.currentSort = key;
|
||||
this.sortRoster();
|
||||
},
|
||||
|
||||
sortRoster() {
|
||||
if (!this.roster || this.roster.length === 0) return;
|
||||
|
||||
this.roster.sort((a, b) => {
|
||||
let valA = 0, valB = 0;
|
||||
|
||||
if (this.currentSort === 'rating') {
|
||||
valA = a.stats?.basic_avg_rating || 0;
|
||||
valB = b.stats?.basic_avg_rating || 0;
|
||||
} else if (this.currentSort === 'kd') {
|
||||
valA = a.stats?.basic_avg_kd || 0;
|
||||
valB = b.stats?.basic_avg_kd || 0;
|
||||
} else if (this.currentSort === 'matches') {
|
||||
// matches_played is usually on the player object now? or stats?
|
||||
// Check API: it's not explicitly in 'stats', but search added it.
|
||||
// Roster API usually doesn't attach matches_played unless we ask.
|
||||
// Let's assume stats.total_matches or check object root.
|
||||
// Looking at roster API: we attach match counts? No, only search.
|
||||
// But we can use total_matches from stats.
|
||||
valA = a.stats?.total_matches || 0;
|
||||
valB = b.stats?.total_matches || 0;
|
||||
}
|
||||
|
||||
return valB - valA; // Descending
|
||||
});
|
||||
},
|
||||
|
||||
searchPlayers() {
|
||||
if (this.searchQuery.length < 2) {
|
||||
this.searchResults = [];
|
||||
return;
|
||||
}
|
||||
// Use encodeURIComponent for safety
|
||||
const q = encodeURIComponent(this.searchQuery);
|
||||
console.log(`Searching for: ${q}`); // Debug Log
|
||||
|
||||
fetch(`/teams/api/search?q=${q}&sort=matches`)
|
||||
.then(res => {
|
||||
console.log('Response status:', res.status);
|
||||
const contentType = res.headers.get("content-type");
|
||||
if (contentType && contentType.indexOf("application/json") !== -1) {
|
||||
return res.json();
|
||||
} else {
|
||||
// Not JSON, probably HTML error page
|
||||
return res.text().then(text => {
|
||||
console.error("Non-JSON response:", text.substring(0, 500));
|
||||
throw new Error("Server returned non-JSON response");
|
||||
});
|
||||
}
|
||||
})
|
||||
.then(data => {
|
||||
console.log('Search results:', data); // Debug Log
|
||||
this.searchResults = data;
|
||||
})
|
||||
.catch(err => console.error('Search error:', err));
|
||||
},
|
||||
|
||||
signPlayer(steamId) {
|
||||
fetch('/teams/api/roster', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'add', steam_id: steamId })
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
this.showScoutModal = false;
|
||||
this.searchQuery = '';
|
||||
this.searchResults = [];
|
||||
this.fetchRoster(); // Refresh
|
||||
});
|
||||
},
|
||||
|
||||
removePlayer(steamId) {
|
||||
if(!confirm('Are you sure you want to release this player?')) return;
|
||||
|
||||
fetch('/teams/api/roster', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'remove', steam_id: steamId })
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
this.fetchRoster();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
71
web/templates/teams/create.html
Normal file
@@ -0,0 +1,71 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-2xl mx-auto 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>
|
||||
|
||||
<form action="{{ url_for('teams.create') }}" method="POST" class="space-y-6">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">阵容名称</label>
|
||||
<input type="text" name="name" required class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-yrtv-500 focus:border-yrtv-500 dark:bg-slate-700 dark:border-slate-600 dark:text-white">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">描述</label>
|
||||
<textarea name="description" rows="3" class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-yrtv-500 focus:border-yrtv-500 dark:bg-slate-700 dark:border-slate-600 dark:text-white"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4" id="players-container">
|
||||
<div class="flex justify-between items-center">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">选择队员 (不限人数)</label>
|
||||
<button type="button" onclick="addPlayerSelect()" class="text-sm text-yrtv-600 hover:text-yrtv-800 font-medium">+ 添加队员</button>
|
||||
</div>
|
||||
|
||||
<!-- Template for JS -->
|
||||
<div id="player-select-template" class="hidden">
|
||||
<div class="flex gap-2 mb-2 player-row">
|
||||
<select name="player_ids" class="block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-yrtv-500 focus:border-yrtv-500 dark:bg-slate-700 dark:border-slate-600 dark:text-white">
|
||||
<option value="">选择队员</option>
|
||||
{% for p in players %}
|
||||
<option value="{{ p.steam_id_64 }}">{{ p.username }} ({{ (p.rating or 0)|round(2) }})</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button type="button" onclick="this.parentElement.remove()" class="text-red-500 hover:text-red-700 px-2">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Initial Selects -->
|
||||
<div id="active-players">
|
||||
{% for i in range(1, 6) %}
|
||||
<div class="flex gap-2 mb-2 player-row">
|
||||
<select name="player_ids" class="block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-yrtv-500 focus:border-yrtv-500 dark:bg-slate-700 dark:border-slate-600 dark:text-white">
|
||||
<option value="">(空缺) 队员 {{ i }}</option>
|
||||
{% for p in players %}
|
||||
<option value="{{ p.steam_id_64 }}">{{ p.username }} ({{ (p.rating or 0)|round(2) }})</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button type="button" onclick="this.parentElement.remove()" class="text-red-500 hover:text-red-700 px-2">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function addPlayerSelect() {
|
||||
const template = document.getElementById('player-select-template').firstElementChild.cloneNode(true);
|
||||
document.getElementById('active-players').appendChild(template);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="pt-4">
|
||||
<button type="submit" class="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-yrtv-600 hover:bg-yrtv-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-yrtv-500">
|
||||
创建阵容
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
116
web/templates/teams/detail.html
Normal file
@@ -0,0 +1,116 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="bg-white dark:bg-slate-800 shadow rounded-lg p-6">
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">{{ lineup.name }}</h1>
|
||||
<p class="text-gray-500 mt-2">{{ lineup.description }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Players Grid -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-5 gap-4">
|
||||
{% for p in players %}
|
||||
<div class="bg-white dark:bg-slate-800 shadow rounded-lg p-4 flex flex-col items-center">
|
||||
<img class="h-16 w-16 rounded-full mb-2" src="{{ p.avatar_url or 'https://via.placeholder.com/64' }}" alt="">
|
||||
<a href="{{ url_for('players.detail', steam_id=p.steam_id_64) }}" class="text-sm font-medium text-gray-900 dark:text-white hover:text-yrtv-600 truncate w-full text-center">
|
||||
{{ p.username }}
|
||||
</a>
|
||||
<span class="text-xs text-gray-500">Rating: {{ "%.2f"|format(p.rating if p.rating else 0) }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Aggregate Stats -->
|
||||
<div class="bg-white dark:bg-slate-800 shadow rounded-lg p-6">
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-4">阵容综合能力</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
<div>
|
||||
<dl class="grid grid-cols-1 gap-5 sm:grid-cols-2">
|
||||
<div class="px-4 py-5 bg-gray-50 dark:bg-slate-700 shadow rounded-lg overflow-hidden sm:p-6">
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 truncate">平均 Rating</dt>
|
||||
<dd class="mt-1 text-3xl font-semibold text-gray-900 dark:text-white">{{ "%.2f"|format(agg_stats.avg_rating or 0) }}</dd>
|
||||
</div>
|
||||
<div class="px-4 py-5 bg-gray-50 dark:bg-slate-700 shadow rounded-lg overflow-hidden sm:p-6">
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 truncate">平均 K/D</dt>
|
||||
<dd class="mt-1 text-3xl font-semibold text-gray-900 dark:text-white">{{ "%.2f"|format(agg_stats.avg_kd or 0) }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<!-- Radar Chart -->
|
||||
<div class="relative h-64">
|
||||
<canvas id="teamRadarChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Shared History -->
|
||||
<div class="bg-white dark:bg-slate-800 shadow rounded-lg p-6">
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-4">共同经历 (Shared Matches)</h3>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead class="bg-gray-50 dark:bg-slate-700">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Date</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Map</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Score</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Link</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white dark:bg-slate-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{% for m in shared_matches %}
|
||||
<tr>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">{{ m.start_time }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-white">{{ m.map_name }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-white">{{ m.score_team1 }} : {{ m.score_team2 }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
<a href="{{ url_for('matches.detail', match_id=m.match_id) }}" class="text-yrtv-600 hover:text-yrtv-900">View</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="4" class="px-6 py-4 text-center text-gray-500">No shared matches found for this lineup.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const radarData = {{ radar_data|tojson }};
|
||||
const ctx = document.getElementById('teamRadarChart').getContext('2d');
|
||||
|
||||
new Chart(ctx, {
|
||||
type: 'radar',
|
||||
data: {
|
||||
labels: ['STA', 'BAT', 'HPS', 'PTL', 'SIDE', 'UTIL'],
|
||||
datasets: [{
|
||||
label: 'Team Average',
|
||||
data: [
|
||||
radarData.STA, radarData.BAT, radarData.HPS,
|
||||
radarData.PTL, radarData.SIDE, radarData.UTIL
|
||||
],
|
||||
backgroundColor: 'rgba(124, 58, 237, 0.2)',
|
||||
borderColor: 'rgba(124, 58, 237, 1)',
|
||||
pointBackgroundColor: 'rgba(124, 58, 237, 1)',
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
maintainAspectRatio: false,
|
||||
scales: {
|
||||
r: {
|
||||
beginAtZero: true,
|
||||
suggestedMax: 2.0
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
34
web/templates/teams/list.html
Normal file
@@ -0,0 +1,34 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="bg-white dark:bg-slate-800 shadow rounded-lg p-6">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">战队阵容库</h2>
|
||||
<a href="{{ url_for('teams.create') }}" class="px-4 py-2 bg-yrtv-600 text-white rounded hover:bg-yrtv-500">
|
||||
新建阵容
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{% for lineup in lineups %}
|
||||
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4 hover:shadow-md transition">
|
||||
<h3 class="text-lg font-bold text-gray-900 dark:text-white">{{ lineup.name }}</h3>
|
||||
<p class="text-sm text-gray-500 mb-4">{{ lineup.description }}</p>
|
||||
|
||||
<div class="flex -space-x-2 overflow-hidden mb-4">
|
||||
{% for p in lineup.players %}
|
||||
<img class="inline-block h-8 w-8 rounded-full ring-2 ring-white dark:ring-slate-800"
|
||||
src="{{ p.avatar_url or 'https://via.placeholder.com/32' }}"
|
||||
alt="{{ p.username }}"
|
||||
title="{{ p.username }}">
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<a href="{{ url_for('teams.detail', lineup_id=lineup.id) }}" class="text-sm text-yrtv-600 hover:text-yrtv-800 font-medium">
|
||||
查看分析 →
|
||||
</a>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
30
web/templates/wiki/edit.html
Normal file
@@ -0,0 +1,30 @@
|
||||
{% 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">Edit Wiki Page</h2>
|
||||
|
||||
<form method="POST" class="space-y-6">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Page Path (Unique ID)</label>
|
||||
<input type="text" disabled value="{{ page_path }}" class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 bg-gray-100 dark:bg-slate-600 dark:text-white">
|
||||
<p class="text-xs text-gray-500 mt-1">Path cannot be changed after creation (unless new).</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Title</label>
|
||||
<input type="text" name="title" value="{{ page.title if page else '' }}" required class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 dark:bg-slate-700 dark:text-white">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Content (Markdown)</label>
|
||||
<textarea name="content" rows="15" class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 font-mono text-sm dark:bg-slate-700 dark:text-white">{{ page.content if page else '' }}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end space-x-4">
|
||||
<a href="{{ url_for('wiki.index') }}" class="px-4 py-2 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50">Cancel</a>
|
||||
<button type="submit" class="px-4 py-2 bg-yrtv-600 text-white rounded-md hover:bg-yrtv-700">Save Page</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
25
web/templates/wiki/index.html
Normal file
@@ -0,0 +1,25 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="bg-white dark:bg-slate-800 shadow rounded-lg p-6">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">知识库 (Wiki)</h2>
|
||||
{% if session.get('is_admin') %}
|
||||
<a href="{{ url_for('wiki.edit', page_path='new') }}" class="px-4 py-2 bg-yrtv-600 text-white rounded hover:bg-yrtv-500">New Page</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
{% for page in pages %}
|
||||
<a href="{{ url_for('wiki.view', page_path=page.path) }}" class="block p-4 border border-gray-200 dark:border-gray-700 rounded hover:bg-gray-50 dark:hover:bg-slate-700">
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-lg font-medium text-yrtv-600">{{ page.title }}</span>
|
||||
<span class="text-sm text-gray-500">{{ page.path }}</span>
|
||||
</div>
|
||||
</a>
|
||||
{% else %}
|
||||
<p class="text-gray-500">暂无文档。</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
33
web/templates/wiki/view.html
Normal file
@@ -0,0 +1,33 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block head %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="bg-white dark:bg-slate-800 shadow rounded-lg p-6">
|
||||
<div class="flex justify-between items-center mb-6 border-b pb-4 border-gray-200 dark:border-gray-700">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">{{ page.title }}</h1>
|
||||
<p class="text-sm text-gray-500 mt-1">Path: {{ page.path }} | Updated: {{ page.updated_at }}</p>
|
||||
</div>
|
||||
{% if session.get('is_admin') %}
|
||||
<a href="{{ url_for('wiki.edit', page_path=page.path) }}" class="px-4 py-2 bg-gray-200 text-gray-700 rounded hover:bg-gray-300">Edit</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div id="wiki-content" class="prose dark:prose-invert max-w-none">
|
||||
<!-- Content will be rendered here -->
|
||||
</div>
|
||||
|
||||
<!-- Hidden source for JS -->
|
||||
<div id="raw-content" class="hidden">{{ page.content }}</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const rawContent = document.getElementById('raw-content').textContent;
|
||||
document.getElementById('wiki-content').innerHTML = marked.parse(rawContent);
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||