1.6.0: Opponent system.
This commit is contained in:
@@ -15,7 +15,7 @@ def create_app():
|
||||
app.teardown_appcontext(close_dbs)
|
||||
|
||||
# Register Blueprints
|
||||
from web.routes import main, matches, players, teams, tactics, admin, wiki
|
||||
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)
|
||||
@@ -23,6 +23,7 @@ def create_app():
|
||||
app.register_blueprint(tactics.bp)
|
||||
app.register_blueprint(admin.bp)
|
||||
app.register_blueprint(wiki.bp)
|
||||
app.register_blueprint(opponents.bp)
|
||||
|
||||
@app.route('/')
|
||||
def index():
|
||||
|
||||
35
web/routes/opponents.py
Normal file
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)
|
||||
374
web/services/opponent_service.py
Normal file
374
web/services/opponent_service.py
Normal file
@@ -0,0 +1,374 @@
|
||||
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 = []
|
||||
for r in rows:
|
||||
d = dict(r)
|
||||
d['win_rate'] = (d['wins'] / d['matches']) if d['matches'] else 0
|
||||
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
|
||||
|
||||
# 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 SUM(t.rating * t.round_total) / SUM(t.round_total) FROM fact_match_players_t t WHERE t.steam_id_64 = ?) as rating_t,
|
||||
(SELECT SUM(ct.rating * ct.round_total) / SUM(ct.round_total) FROM fact_match_players_ct ct WHERE ct.steam_id_64 = ?) as rating_ct,
|
||||
(SELECT SUM(t.kd_ratio * t.round_total) / SUM(t.round_total) FROM fact_match_players_t t WHERE t.steam_id_64 = ?) as kd_t,
|
||||
(SELECT SUM(ct.kd_ratio * ct.round_total) / SUM(ct.round_total) 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': info,
|
||||
'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
|
||||
@@ -47,6 +47,7 @@
|
||||
<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>
|
||||
@@ -84,6 +85,7 @@
|
||||
<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') %}
|
||||
|
||||
251
web/templates/opponents/detail.html
Normal file
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
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 %}
|
||||
Reference in New Issue
Block a user