Compare commits

...

31 Commits

Author SHA1 Message Date
f110ae52f0 feat: Add recent performance stability stats (matches/days) to player profile 2026-01-28 15:11:31 +08:00
a5a9016b7f 2.1.1: Fixed new profile. 2026-01-28 01:38:45 +08:00
4afb728bfa 2.1 : New 2026-01-28 01:20:26 +08:00
b3941cad3b 2.0.1: Updated solo 2026-01-27 22:01:17 +08:00
c4607d8080 2.0.0 RELEASE : Released. 2026-01-27 21:44:30 +08:00
6b4cc048b3 1.7.0: New features. 2026-01-27 21:26:07 +08:00
5693eb84ee 1.6.3: fixed opponent total. 2026-01-27 19:29:08 +08:00
e019d3e458 1.6.2: Refreshed. 2026-01-27 19:23:05 +08:00
92ad093895 1.6.1: Updated matches. 2026-01-27 19:11:36 +08:00
86d2dbebe8 1.6.0: Opponent system. 2026-01-27 19:06:20 +08:00
50428ae2ac 1.5.0: Clutch fully recovered. 2026-01-27 17:53:09 +08:00
0be68a86f6 1.4.0: Updated Profile 2026-01-27 16:51:53 +08:00
28dc02c0c4 1.3.1: Removed unused scripts. 2026-01-27 03:11:17 +08:00
a148c2d511 1.3.0: Updated ONLINE. 2026-01-27 02:20:55 +08:00
e006772e9c 1.2.4: Updated Tactics 2026-01-27 01:53:59 +08:00
2e0bedb5ff 1.2.3-hotfix: Fixed data center not showing graphs. 2026-01-27 01:40:56 +08:00
b9c1af5d70 1.2.2: Profile Upgraded 2026-01-27 00:57:35 +08:00
1b9cab5628 1.2.1 : Updated calculation 2026-01-26 22:04:29 +08:00
ade29ec1e8 1.2.0: Refined all 6D calcs and UI/UX Experiences. 2026-01-26 21:10:42 +08:00
8cc359b0ec 1.1.0: Updated Profile 2026-01-26 18:36:47 +08:00
727105f11e 1.0.4: Updated Tactics-DeepAnalysis 2026-01-26 17:26:43 +08:00
4cee0fab59 1.0.3-hotfix: Added requirements.txt. 2026-01-26 17:10:24 +08:00
57fb6ce1f4 1.0.3: Enhanced match detail - Added h2h and roundhistory. 2026-01-26 17:08:43 +08:00
f147b4d65a 1.0.2-hotfix: new readme 2026-01-26 16:39:19 +08:00
d8b70c1cf7 1.0.2-hotfix: Added matchlist new features. 2026-01-26 02:53:31 +08:00
81739392da 1.0.1-fix: Fixed 'winner-team' regarded as win. 2026-01-26 02:22:09 +08:00
8dabf0b097 1.0.0 : Web Implemented. 2026-01-26 02:13:06 +08:00
026a8fe65d 0.5.4 : Reorganize demo features. 2026-01-25 18:28:20 +08:00
8e4cc07c6b 0.5.3 +feat: L1A Incremental-refresh implemented.
Reviewed-on: #3
Accepted as fully meet all demands.
2026-01-25 01:35:36 +08:00
8977a8d6bd Merge branch 'main' into master 2026-01-25 01:32:10 +08:00
70c13ef622 0.5.2: Update WebRDD 2026-01-25 01:25:32 +08:00
82 changed files with 11714 additions and 406 deletions

4
.gitignore vendored
View File

@@ -5,6 +5,7 @@ __pycache__/
*.so
*.dylib
*.dll
.trae/
.Python
build/
@@ -66,3 +67,6 @@ venv.bak/
output/
output_arena/
arena/
scripts/
experiment
yrtv.zip

View File

@@ -118,6 +118,13 @@ class PlayerStats:
sts_raw: str = ""
level_info_raw: str = ""
# Utility Usage
util_flash_usage: int = 0
util_smoke_usage: int = 0
util_molotov_usage: int = 0
util_he_usage: int = 0
util_decoy_usage: int = 0
@dataclass
class RoundEvent:
event_id: str
@@ -150,6 +157,7 @@ class PlayerEconomy:
main_weapon: str = ""
has_helmet: bool = False
has_defuser: bool = False
has_zeus: bool = False
round_performance_score: float = 0.0
@dataclass
@@ -582,6 +590,7 @@ class MatchParser:
side_stats.rating2 = safe_float(fight_side.get('rating2'))
side_stats.rating3 = safe_float(fight_side.get('rating3'))
side_stats.rws = safe_float(fight_side.get('rws'))
side_stats.kast = safe_float(fight_side.get('kast'))
side_stats.mvp_count = safe_int(fight_side.get('is_mvp'))
side_stats.flash_duration = safe_float(fight_side.get('flash_enemy_time'))
side_stats.jump_count = safe_int(fight_side.get('jump_total'))
@@ -659,6 +668,13 @@ class MatchParser:
stats.team_id = team_id_value
stats.kills = safe_int(get_stat('kill'))
stats.deaths = safe_int(get_stat('death'))
# Force calculate K/D
if stats.deaths > 0:
stats.kd_ratio = stats.kills / stats.deaths
else:
stats.kd_ratio = float(stats.kills)
stats.assists = safe_int(get_stat('assist'))
stats.headshot_count = safe_int(get_stat('headshot'))
@@ -792,6 +808,22 @@ class MatchParser:
round_list = l_data.get('round_stat', [])
for idx, r in enumerate(round_list):
# Utility Usage (Leetify)
bron = r.get('bron_equipment', {})
for sid, items in bron.items():
sid = str(sid)
if sid in self.match_data.players:
p = self.match_data.players[sid]
if isinstance(items, list):
for item in items:
if not isinstance(item, dict): continue
name = item.get('WeaponName', '')
if name == 'weapon_flashbang': p.util_flash_usage += 1
elif name == 'weapon_smokegrenade': p.util_smoke_usage += 1
elif name in ['weapon_molotov', 'weapon_incgrenade']: p.util_molotov_usage += 1
elif name == 'weapon_hegrenade': p.util_he_usage += 1
elif name == 'weapon_decoy': p.util_decoy_usage += 1
rd = RoundData(
round_num=r.get('round', idx + 1),
winner_side='CT' if r.get('win_reason') in [7, 8, 9] else 'T', # Approximate logic, need real enum
@@ -834,6 +866,9 @@ class MatchParser:
if evt.get('trade_score_change'):
re.trade_killer_steam_id = list(evt['trade_score_change'].keys())[0]
if evt.get('assist_killer_score_change'):
re.assister_steam_id = list(evt['assist_killer_score_change'].keys())[0]
if evt.get('flash_assist_killer_score_change'):
re.flash_assist_steam_id = list(evt['flash_assist_killer_score_change'].keys())[0]
@@ -913,6 +948,7 @@ class MatchParser:
has_helmet = False
has_defuser = False
has_zeus = False
if isinstance(items, list):
for it in items:
if isinstance(it, dict):
@@ -921,6 +957,8 @@ class MatchParser:
has_helmet = True
elif name == 'item_defuser':
has_defuser = True
elif name and ('taser' in name or 'zeus' in name):
has_zeus = True
rd.economies.append(PlayerEconomy(
steam_id_64=str(sid),
@@ -930,6 +968,7 @@ class MatchParser:
main_weapon=main_weapon,
has_helmet=has_helmet,
has_defuser=has_defuser,
has_zeus=has_zeus,
round_performance_score=float(score)
))
@@ -942,6 +981,21 @@ class MatchParser:
# Check schema: 'current_score' -> ct/t
cur_score = r.get('current_score', {})
# Utility Usage (Classic)
equiped = r.get('equiped', {})
for sid, items in equiped.items():
# Ensure sid is string
sid = str(sid)
if sid in self.match_data.players:
p = self.match_data.players[sid]
if isinstance(items, list):
for item in items:
if item == 'flashbang': p.util_flash_usage += 1
elif item == 'smokegrenade': p.util_smoke_usage += 1
elif item in ['molotov', 'incgrenade']: p.util_molotov_usage += 1
elif item == 'hegrenade': p.util_he_usage += 1
elif item == 'decoy': p.util_decoy_usage += 1
rd = RoundData(
round_num=idx + 1,
winner_side='None', # Default to None if unknown
@@ -981,6 +1035,28 @@ class MatchParser:
)
rd.events.append(re)
c4_events = r.get('c4_event', [])
for e in c4_events:
if not isinstance(e, dict):
continue
event_name = str(e.get('event_name') or '').lower()
if not event_name:
continue
if 'plant' in event_name:
etype = 'bomb_plant'
elif 'defus' in event_name:
etype = 'bomb_defuse'
else:
continue
sid = e.get('steamid_64')
re = RoundEvent(
event_id=f"{self.match_id}_{rd.round_num}_{etype}_{e.get('pasttime', 0)}_{sid}",
event_type=etype,
event_time=int(e.get('pasttime', 0) or 0),
attacker_steam_id=str(sid) if sid is not None else None,
)
rd.events.append(re)
self.match_data.rounds.append(rd)
# --- Main Execution ---
@@ -1054,7 +1130,11 @@ def save_match(cursor, m: MatchData):
ON CONFLICT(steam_id_64) DO UPDATE SET
uid=excluded.uid,
username=excluded.username,
avatar_url=excluded.avatar_url,
avatar_url=CASE
WHEN excluded.avatar_url IS NOT NULL AND excluded.avatar_url != ''
THEN excluded.avatar_url
ELSE dim_players.avatar_url
END,
domain=excluded.domain,
created_at=excluded.created_at,
updated_at=excluded.updated_at,
@@ -1207,7 +1287,8 @@ def save_match(cursor, m: MatchData):
"many_assists_cnt3", "many_assists_cnt4", "many_assists_cnt5", "map",
"match_code", "match_mode", "match_team_id", "match_time", "per_headshot",
"perfect_kill", "planted_bomb", "revenge_kill", "round_total", "season",
"team_kill", "throw_harm", "throw_harm_enemy", "uid", "year", "sts_raw", "level_info_raw"
"team_kill", "throw_harm", "throw_harm_enemy", "uid", "year", "sts_raw", "level_info_raw",
"util_flash_usage", "util_smoke_usage", "util_molotov_usage", "util_he_usage", "util_decoy_usage"
]
player_placeholders = ",".join(["?"] * len(player_columns))
player_columns_sql = ",".join(player_columns)
@@ -1231,7 +1312,8 @@ def save_match(cursor, m: MatchData):
p.many_assists_cnt5, p.map, p.match_code, p.match_mode, p.match_team_id,
p.match_time, p.per_headshot, p.perfect_kill, p.planted_bomb, p.revenge_kill,
p.round_total, p.season, p.team_kill, p.throw_harm, p.throw_harm_enemy,
p.uid, p.year, p.sts_raw, p.level_info_raw
p.uid, p.year, p.sts_raw, p.level_info_raw,
p.util_flash_usage, p.util_smoke_usage, p.util_molotov_usage, p.util_he_usage, p.util_decoy_usage
]
for sid, p in m.players.items():
@@ -1273,14 +1355,14 @@ def save_match(cursor, m: MatchData):
cursor.execute("""
INSERT OR REPLACE INTO fact_round_events
(event_id, match_id, round_num, event_type, event_time, attacker_steam_id, victim_steam_id,
(event_id, match_id, round_num, event_type, event_time, attacker_steam_id, victim_steam_id, assister_steam_id,
weapon, is_headshot, is_wallbang, is_blind, is_through_smoke, is_noscope,
trade_killer_steam_id, flash_assist_steam_id, score_change_attacker, score_change_victim,
attacker_pos_x, attacker_pos_y, attacker_pos_z, victim_pos_x, victim_pos_y, victim_pos_z)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
e.event_id, m.match_id, r.round_num, e.event_type, e.event_time, e.attacker_steam_id, e.victim_steam_id,
e.weapon, e.is_headshot, e.is_wallbang, e.is_blind, e.is_through_smoke, e.is_noscope,
e.assister_steam_id, e.weapon, e.is_headshot, e.is_wallbang, e.is_blind, e.is_through_smoke, e.is_noscope,
e.trade_killer_steam_id, e.flash_assist_steam_id, e.score_change_attacker, e.score_change_victim,
ax, ay, az, vx, vy, vz
))
@@ -1288,11 +1370,100 @@ def save_match(cursor, m: MatchData):
for pe in r.economies:
cursor.execute("""
INSERT OR REPLACE INTO fact_round_player_economy
(match_id, round_num, steam_id_64, side, start_money, equipment_value, main_weapon, has_helmet, has_defuser, round_performance_score)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
(match_id, round_num, steam_id_64, side, start_money, equipment_value, main_weapon, has_helmet, has_defuser, has_zeus, round_performance_score)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
m.match_id, r.round_num, pe.steam_id_64, pe.side, pe.start_money, pe.equipment_value, pe.main_weapon, pe.has_helmet, pe.has_defuser, pe.round_performance_score
m.match_id, r.round_num, pe.steam_id_64, pe.side, pe.start_money, pe.equipment_value, pe.main_weapon, pe.has_helmet, pe.has_defuser, pe.has_zeus, pe.round_performance_score
))
# 6. Calculate & Save Clutch Attempts
_calculate_and_save_clutch_attempts(cursor, m.match_id, m.round_list_raw)
def _calculate_and_save_clutch_attempts(cursor, match_id, round_list_raw):
if not round_list_raw:
return
try:
round_list = json.loads(round_list_raw)
except:
return
player_attempts = {}
for round_data in round_list:
all_kills = round_data.get('all_kill', [])
if not all_kills:
continue
team_members = {1: set(), 2: set()}
# Scan for team members
for k in all_kills:
if k.get('attacker') and k['attacker'].get('steamid_64'):
tid = k['attacker'].get('team')
if tid in [1, 2]:
team_members[tid].add(k['attacker']['steamid_64'])
if k.get('victim') and k['victim'].get('steamid_64'):
tid = k['victim'].get('team')
if tid in [1, 2]:
team_members[tid].add(k['victim']['steamid_64'])
if not team_members[1] or not team_members[2]:
continue
alive = {1: team_members[1].copy(), 2: team_members[2].copy()}
clutch_triggered_players = set()
# Sort kills by time
sorted_kills = sorted(all_kills, key=lambda x: x.get('pasttime', 0))
for k in sorted_kills:
victim = k.get('victim')
if not victim: continue
v_sid = victim.get('steamid_64')
v_team = victim.get('team')
if v_team not in [1, 2] or v_sid not in alive[v_team]:
continue
alive[v_team].remove(v_sid)
if len(alive[v_team]) == 1:
survivor_sid = list(alive[v_team])[0]
if survivor_sid not in clutch_triggered_players:
opponent_team = 3 - v_team
opponents_alive_count = len(alive[opponent_team])
if opponents_alive_count >= 1:
if survivor_sid not in player_attempts:
player_attempts[survivor_sid] = {'1v1': 0, '1v2': 0, '1v3': 0, '1v4': 0, '1v5': 0}
n = min(opponents_alive_count, 5)
key = f'1v{n}'
player_attempts[survivor_sid][key] += 1
clutch_triggered_players.add(survivor_sid)
# Save to DB
cursor.execute("""
CREATE TABLE IF NOT EXISTS fact_match_clutch_attempts (
match_id TEXT,
steam_id_64 TEXT,
attempt_1v1 INTEGER DEFAULT 0,
attempt_1v2 INTEGER DEFAULT 0,
attempt_1v3 INTEGER DEFAULT 0,
attempt_1v4 INTEGER DEFAULT 0,
attempt_1v5 INTEGER DEFAULT 0,
PRIMARY KEY (match_id, steam_id_64)
)
""")
for pid, att in player_attempts.items():
cursor.execute("""
INSERT OR REPLACE INTO fact_match_clutch_attempts
(match_id, steam_id_64, attempt_1v1, attempt_1v2, attempt_1v3, attempt_1v4, attempt_1v5)
VALUES (?, ?, ?, ?, ?, ?, ?)
""", (match_id, pid, att['1v1'], att['1v2'], att['1v3'], att['1v4'], att['1v5']))
if __name__ == "__main__":
process_matches()

View File

@@ -1,329 +1,108 @@
import sqlite3
import logging
import os
import numpy as np
import pandas as pd
from datetime import datetime
import sys
# Add parent directory to path to allow importing web module
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from web.services.feature_service import FeatureService
from web.config import Config
from web.app import create_app
import sqlite3
# Setup logging
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
# Constants
L2_DB_PATH = 'database/L2/L2_Main.sqlite'
L3_DB_PATH = 'database/L3/L3_Features.sqlite'
SCHEMA_PATH = 'database/L3/schema.sql'
L3_DB_PATH = Config.DB_L3_PATH
SCHEMA_PATH = os.path.join(Config.BASE_DIR, 'database', 'L3', 'schema.sql')
def _get_existing_columns(conn, table_name):
cur = conn.execute(f"PRAGMA table_info({table_name})")
return {row[1] for row in cur.fetchall()}
def _ensure_columns(conn, table_name, columns):
existing = _get_existing_columns(conn, table_name)
for col, col_type in columns.items():
if col in existing:
continue
conn.execute(f"ALTER TABLE {table_name} ADD COLUMN {col} {col_type}")
def init_db():
if not os.path.exists('database/L3'):
os.makedirs('database/L3')
l3_dir = os.path.dirname(L3_DB_PATH)
if not os.path.exists(l3_dir):
os.makedirs(l3_dir)
conn = sqlite3.connect(L3_DB_PATH)
with open(SCHEMA_PATH, 'r', encoding='utf-8') as f:
conn.executescript(f.read())
_ensure_columns(
conn,
"dm_player_features",
{
"rd_phase_kill_early_share": "REAL",
"rd_phase_kill_mid_share": "REAL",
"rd_phase_kill_late_share": "REAL",
"rd_phase_death_early_share": "REAL",
"rd_phase_death_mid_share": "REAL",
"rd_phase_death_late_share": "REAL",
"rd_phase_kill_early_share_t": "REAL",
"rd_phase_kill_mid_share_t": "REAL",
"rd_phase_kill_late_share_t": "REAL",
"rd_phase_kill_early_share_ct": "REAL",
"rd_phase_kill_mid_share_ct": "REAL",
"rd_phase_kill_late_share_ct": "REAL",
"rd_phase_death_early_share_t": "REAL",
"rd_phase_death_mid_share_t": "REAL",
"rd_phase_death_late_share_t": "REAL",
"rd_phase_death_early_share_ct": "REAL",
"rd_phase_death_mid_share_ct": "REAL",
"rd_phase_death_late_share_ct": "REAL",
"rd_firstdeath_team_first_death_rounds": "INTEGER",
"rd_firstdeath_team_first_death_win_rate": "REAL",
"rd_invalid_death_rounds": "INTEGER",
"rd_invalid_death_rate": "REAL",
"rd_pressure_kpr_ratio": "REAL",
"rd_pressure_perf_ratio": "REAL",
"rd_pressure_rounds_down3": "INTEGER",
"rd_pressure_rounds_normal": "INTEGER",
"rd_matchpoint_kpr_ratio": "REAL",
"rd_matchpoint_perf_ratio": "REAL",
"rd_matchpoint_rounds": "INTEGER",
"rd_comeback_kill_share": "REAL",
"rd_comeback_rounds": "INTEGER",
"rd_trade_response_10s_rate": "REAL",
"rd_weapon_top_json": "TEXT",
"rd_roundtype_split_json": "TEXT",
"map_stability_coef": "REAL",
"basic_avg_knife_kill": "REAL",
"basic_avg_zeus_kill": "REAL",
"basic_zeus_pick_rate": "REAL",
},
)
conn.commit()
conn.close()
logger.info("L3 DB Initialized.")
logger.info("L3 DB Initialized/Updated with Schema.")
def get_db_connection(db_path):
conn = sqlite3.connect(db_path)
return conn
def main():
logger.info("Starting L3 Builder (Delegating to FeatureService)...")
def safe_div(a, b, default=0.0):
return a / b if b and b != 0 else default
# 1. Ensure Schema is up to date
init_db()
def calculate_basic_features(df):
if df.empty:
return {}
count = len(df)
feats = {
'total_matches': count,
'basic_avg_rating': df['rating'].mean(),
'basic_avg_kd': df['kd_ratio'].mean(),
'basic_avg_kast': df['kast'].mean(),
'basic_avg_rws': df['rws'].mean(),
'basic_avg_headshot_kills': df['headshot_count'].sum() / count,
'basic_headshot_rate': safe_div(df['headshot_count'].sum(), df['kills'].sum()),
'basic_avg_first_kill': df['first_kill'].mean(),
'basic_avg_first_death': df['first_death'].mean(),
'basic_first_kill_rate': safe_div(df['first_kill'].sum(), df['first_kill'].sum() + df['first_death'].sum()),
'basic_first_death_rate': safe_div(df['first_death'].sum(), df['first_kill'].sum() + df['first_death'].sum()),
'basic_avg_kill_2': df['kill_2'].mean(),
'basic_avg_kill_3': df['kill_3'].mean(),
'basic_avg_kill_4': df['kill_4'].mean(),
'basic_avg_kill_5': df['kill_5'].mean(),
'basic_avg_assisted_kill': df['assisted_kill'].mean(),
'basic_avg_perfect_kill': df['perfect_kill'].mean(),
'basic_avg_revenge_kill': df['revenge_kill'].mean(),
'basic_avg_awp_kill': df['awp_kill'].mean(),
'basic_avg_jump_count': df['jump_count'].mean(),
}
return feats
def calculate_sta_features(df):
if df.empty:
return {}
df = df.sort_values('match_time')
last_30 = df.tail(30)
last_10 = df.tail(10)
feats = {
'sta_last_30_rating': last_30['rating'].mean(),
'sta_win_rating': df[df['is_win'] == 1]['rating'].mean() if not df[df['is_win'] == 1].empty else 0.0,
'sta_loss_rating': df[df['is_win'] == 0]['rating'].mean() if not df[df['is_win'] == 0].empty else 0.0,
'sta_rating_volatility': last_10['rating'].std() if len(last_10) > 1 else 0.0,
}
df['date'] = pd.to_datetime(df['match_time'], unit='s').dt.date
day_counts = df.groupby('date').size()
busy_days = day_counts[day_counts >= 4].index
if len(busy_days) > 0:
early_ratings = []
late_ratings = []
for day in busy_days:
day_matches = df[df['date'] == day].sort_values('match_time')
early = day_matches.head(3)
late = day_matches.tail(len(day_matches) - 3)
early_ratings.extend(early['rating'].tolist())
late_ratings.extend(late['rating'].tolist())
feats['sta_fatigue_decay'] = np.mean(early_ratings) - np.mean(late_ratings) if early_ratings and late_ratings else 0.0
else:
feats['sta_fatigue_decay'] = 0.0
df['hour_of_day'] = pd.to_datetime(df['match_time'], unit='s').dt.hour
if len(df) > 5:
corr = df['hour_of_day'].corr(df['rating'])
feats['sta_time_rating_corr'] = corr if not np.isnan(corr) else 0.0
else:
feats['sta_time_rating_corr'] = 0.0
return feats
def calculate_util_features(df):
if df.empty:
return {}
feats = {
'util_avg_nade_dmg': df['throw_harm'].mean() if 'throw_harm' in df.columns else 0.0,
'util_avg_flash_time': df['flash_duration'].mean() if 'flash_duration' in df.columns else 0.0,
'util_avg_flash_enemy': df['flash_enemy'].mean() if 'flash_enemy' in df.columns else 0.0,
'util_avg_flash_team': df['flash_team'].mean() if 'flash_team' in df.columns else 0.0,
'util_usage_rate': (df['flash_enemy'].mean() + df['throw_harm'].mean() / 50.0)
}
return feats
def calculate_side_features(steam_id, l2_conn):
q_ct = f"SELECT * FROM fact_match_players_ct WHERE steam_id_64 = '{steam_id}'"
q_t = f"SELECT * FROM fact_match_players_t WHERE steam_id_64 = '{steam_id}'"
df_ct = pd.read_sql_query(q_ct, l2_conn)
df_t = pd.read_sql_query(q_t, l2_conn)
feats = {}
if not df_ct.empty:
feats['side_rating_ct'] = df_ct['rating'].mean()
feats['side_first_kill_rate_ct'] = safe_div(df_ct['first_kill'].sum(), df_ct['first_kill'].sum() + df_ct['first_death'].sum())
feats['side_hold_success_rate_ct'] = 0.0
feats['side_defused_bomb_count'] = df_ct['defused_bomb'].sum() if 'defused_bomb' in df_ct.columns else 0
else:
feats.update({'side_rating_ct': 0.0, 'side_first_kill_rate_ct': 0.0, 'side_hold_success_rate_ct': 0.0, 'side_defused_bomb_count': 0})
if not df_t.empty:
feats['side_rating_t'] = df_t['rating'].mean()
feats['side_first_kill_rate_t'] = safe_div(df_t['first_kill'].sum(), df_t['first_kill'].sum() + df_t['first_death'].sum())
feats['side_entry_success_rate_t'] = 0.0
feats['side_planted_bomb_count'] = df_t['planted_bomb'].sum() if 'planted_bomb' in df_t.columns else 0
else:
feats.update({'side_rating_t': 0.0, 'side_first_kill_rate_t': 0.0, 'side_entry_success_rate_t': 0.0, 'side_planted_bomb_count': 0})
feats['side_kd_diff_ct_t'] = (df_ct['kd_ratio'].mean() if not df_ct.empty else 0) - (df_t['kd_ratio'].mean() if not df_t.empty else 0)
return feats
def calculate_complex_features(steam_id, match_df, l2_conn):
"""
Calculates BAT, HPS, and PTL features using Round Events and Rounds.
"""
feats = {}
# 1. HPS: Clutch from match stats (easier part)
# clutch_1vX are wins. end_1vX are total attempts (assuming mapping logic).
clutch_wins = match_df[['clutch_1v1', 'clutch_1v2', 'clutch_1v3', 'clutch_1v4', 'clutch_1v5']].sum().sum()
clutch_attempts = match_df[['end_1v1', 'end_1v2', 'end_1v3', 'end_1v4', 'end_1v5']].sum().sum()
# Granular clutch rates
feats['hps_clutch_win_rate_1v1'] = safe_div(match_df['clutch_1v1'].sum(), match_df['end_1v1'].sum())
feats['hps_clutch_win_rate_1v2'] = safe_div(match_df['clutch_1v2'].sum(), match_df['end_1v2'].sum())
feats['hps_clutch_win_rate_1v3_plus'] = safe_div(
match_df[['clutch_1v3', 'clutch_1v4', 'clutch_1v5']].sum().sum(),
match_df[['end_1v3', 'end_1v4', 'end_1v5']].sum().sum()
)
# 2. Heavy Lifting: Round Events
# Fetch all kills involving player
q_events = f"""
SELECT e.*,
p_vic.rank_score as victim_rank,
p_att.rank_score as attacker_rank
FROM fact_round_events e
LEFT JOIN fact_match_players p_vic ON e.match_id = p_vic.match_id AND e.victim_steam_id = p_vic.steam_id_64
LEFT JOIN fact_match_players p_att ON e.match_id = p_att.match_id AND e.attacker_steam_id = p_att.steam_id_64
WHERE (e.attacker_steam_id = '{steam_id}' OR e.victim_steam_id = '{steam_id}')
AND e.event_type = 'kill'
"""
# 2. Rebuild Features using the centralized logic
try:
events = pd.read_sql_query(q_events, l2_conn)
app = create_app()
with app.app_context():
count = FeatureService.rebuild_all_features()
logger.info(f"Successfully rebuilt features for {count} players.")
except Exception as e:
logger.error(f"Error fetching events for {steam_id}: {e}")
events = pd.DataFrame()
if not events.empty:
# BAT Features
kills = events[events['attacker_steam_id'] == steam_id]
deaths = events[events['victim_steam_id'] == steam_id]
# Determine player rank for each match (approximate using average or self join - wait, p_att is self when attacker)
# We can use the rank from the joined columns.
# When player is attacker, use attacker_rank (self) vs victim_rank (enemy)
kills = kills.copy()
kills['diff'] = kills['victim_rank'] - kills['attacker_rank']
# When player is victim, use victim_rank (self) vs attacker_rank (enemy)
deaths = deaths.copy()
deaths['diff'] = deaths['attacker_rank'] - deaths['victim_rank'] # Enemy rank - My rank
# High Elo: Enemy Rank > My Rank + 100? Or just > My Rank?
# Let's say High Elo = Enemy Rank > My Rank
high_elo_kills = kills[kills['diff'] > 0].shape[0]
high_elo_deaths = deaths[deaths['diff'] > 0].shape[0] # Enemy (Attacker) > Me (Victim)
low_elo_kills = kills[kills['diff'] < 0].shape[0]
low_elo_deaths = deaths[deaths['diff'] < 0].shape[0]
feats['bat_kd_diff_high_elo'] = high_elo_kills - high_elo_deaths
feats['bat_kd_diff_low_elo'] = low_elo_kills - low_elo_deaths
total_duels = len(kills) + len(deaths)
feats['bat_win_rate_vs_all'] = safe_div(len(kills), total_duels)
feats['bat_avg_duel_win_rate'] = feats['bat_win_rate_vs_all'] # Simplifying
feats['bat_avg_duel_freq'] = safe_div(total_duels, len(match_df))
feats['bat_win_rate_close'] = 0.0 # Placeholder for distance logic
feats['bat_win_rate_mid'] = 0.0
feats['bat_win_rate_far'] = 0.0
else:
feats.update({
'bat_kd_diff_high_elo': 0, 'bat_kd_diff_low_elo': 0,
'bat_win_rate_vs_all': 0.0, 'bat_avg_duel_win_rate': 0.0,
'bat_avg_duel_freq': 0.0, 'bat_win_rate_close': 0.0,
'bat_win_rate_mid': 0.0, 'bat_win_rate_far': 0.0
})
# 3. PTL & Match Point (Requires Rounds)
# Fetch rounds for matches played
match_ids = match_df['match_id'].unique().tolist()
if not match_ids:
return feats
match_ids_str = "'" + "','".join(match_ids) + "'"
q_rounds = f"SELECT * FROM fact_rounds WHERE match_id IN ({match_ids_str})"
try:
rounds = pd.read_sql_query(q_rounds, l2_conn)
except:
rounds = pd.DataFrame()
if not rounds.empty and not events.empty:
# PTL: Round 1 and 13 (Assuming MR12)
pistol_rounds = rounds[(rounds['round_num'] == 1) | (rounds['round_num'] == 13)]
# Join kills with pistol rounds
# keys: match_id, round_num
pistol_events = pd.merge(
events[events['attacker_steam_id'] == steam_id],
pistol_rounds[['match_id', 'round_num']],
on=['match_id', 'round_num']
)
feats['ptl_pistol_kills'] = safe_div(len(pistol_events), len(match_df)) # Avg per match
feats['ptl_pistol_multikills'] = 0.0 # Complex to calc without grouping per round
feats['ptl_pistol_win_rate'] = 0.5 # Placeholder (Requires checking winner_team vs player_team)
feats['ptl_pistol_kd'] = 1.0 # Placeholder
feats['ptl_pistol_util_efficiency'] = 0.0
# Match Point (HPS)
# Logic: Score is 12 (MR12) or 15 (MR15).
# We assume MR12 for simplicity or check max score.
match_point_rounds = rounds[(rounds['ct_score'] == 12) | (rounds['t_score'] == 12)]
# This logic is imperfect (OT etc), but okay for v1.
feats['hps_match_point_win_rate'] = 0.5 # Placeholder
else:
feats.update({
'ptl_pistol_kills': 0.0, 'ptl_pistol_multikills': 0.0,
'ptl_pistol_win_rate': 0.0, 'ptl_pistol_kd': 0.0,
'ptl_pistol_util_efficiency': 0.0, 'hps_match_point_win_rate': 0.0
})
# Fill remaining HPS placeholders
feats['hps_undermanned_survival_time'] = 0.0
feats['hps_pressure_entry_rate'] = 0.0
feats['hps_momentum_multikill_rate'] = 0.0
feats['hps_tilt_rating_drop'] = 0.0
feats['hps_clutch_rating_rise'] = 0.0
feats['hps_comeback_kd_diff'] = 0.0
feats['hps_losing_streak_kd_diff'] = 0.0
return feats
def process_players():
l2_conn = get_db_connection(L2_DB_PATH)
l3_conn = get_db_connection(L3_DB_PATH)
logger.info("Fetching player list...")
players = pd.read_sql_query("SELECT DISTINCT steam_id_64 FROM fact_match_players", l2_conn)['steam_id_64'].tolist()
logger.info(f"Found {len(players)} players. Processing...")
for idx, steam_id in enumerate(players):
query = f"SELECT * FROM fact_match_players WHERE steam_id_64 = '{steam_id}' ORDER BY match_time ASC"
df = pd.read_sql_query(query, l2_conn)
if df.empty:
continue
feats = calculate_basic_features(df)
feats.update(calculate_sta_features(df))
feats.update(calculate_side_features(steam_id, l2_conn))
feats.update(calculate_util_features(df))
feats.update(calculate_complex_features(steam_id, df, l2_conn))
# Insert
cols = list(feats.keys())
vals = list(feats.values())
vals = [float(v) if isinstance(v, (np.float32, np.float64)) else v for v in vals]
vals = [int(v) if isinstance(v, (np.int32, np.int64)) else v for v in vals]
col_str = ", ".join(cols)
q_marks = ", ".join(["?"] * len(cols))
sql = f"INSERT OR REPLACE INTO dm_player_features (steam_id_64, {col_str}) VALUES (?, {q_marks})"
l3_conn.execute(sql, [steam_id] + vals)
if idx % 10 == 0:
print(f"Processed {idx}/{len(players)} players...", end='\r')
l3_conn.commit()
l3_conn.commit()
l2_conn.close()
l3_conn.close()
logger.info("\nDone.")
logger.error(f"Error rebuilding features: {e}")
import traceback
traceback.print_exc()
if __name__ == "__main__":
init_db()
process_players()
main()

48
ETL/refresh.py Normal file
View File

@@ -0,0 +1,48 @@
import os
import sys
import subprocess
import time
def run_script(script_path, args=None):
cmd = [sys.executable, script_path]
if args:
cmd.extend(args)
print(f"\n[REFRESH] Running: {' '.join(cmd)}")
start_time = time.time()
result = subprocess.run(cmd)
elapsed = time.time() - start_time
if result.returncode != 0:
print(f"[REFRESH] Error running {script_path}. Exit code: {result.returncode}")
sys.exit(result.returncode)
else:
print(f"[REFRESH] Finished {script_path} in {elapsed:.2f}s")
def main():
base_dir = os.path.dirname(os.path.abspath(__file__))
project_root = os.path.dirname(base_dir)
print("="*50)
print("STARTING FULL DATABASE REFRESH")
print("="*50)
# 1. L1A --force (Re-ingest all raw data)
l1a_script = os.path.join(base_dir, 'L1A.py')
run_script(l1a_script, ['--force'])
# 2. L2 Builder (Rebuild Fact Tables with fixed K/D logic)
l2_script = os.path.join(base_dir, 'L2_Builder.py')
run_script(l2_script)
# 3. L3 Builder (Rebuild Feature Store)
l3_script = os.path.join(base_dir, 'L3_Builder.py')
run_script(l3_script)
print("="*50)
print("DATABASE REFRESH COMPLETED SUCCESSFULLY")
print("="*50)
if __name__ == "__main__":
main()

View File

@@ -1,13 +1,60 @@
# YRTV 项目说明 till 0.5.0
# YRTV 项目说明 till 1.0.2hotfix
## 项目概览
yrtv这一块
YRTV 是一个基于 CS2 比赛数据的综合分析与战队管理平台。它集成了数据采集、ETL 清洗建模、特征挖掘以及现代化的 Web 交互界面
核心目标是为战队提供数据驱动的决策支持包括战术分析、队员表现评估、阵容管理Clubhouse以及实时战术板功能。
---
您可以使用以下命令快速配置环境:
pip install -r requirements.txt
数据来源与处理核心包括:
- 比赛页面的 iframe JSON 数据(`iframe_network.json`
- 可选的 demo 文件(`.zip/.dem`
- L1A/L2/L3 分层数据库建模与校验
## Web 交互系统 (New in v0.5.0)
基于 Flask + TailwindCSS + Alpine.js 构建的现代化 Web 应用。
### 核心功能模块
1. **Clubhouse (战队管理)**
- **Roster Management**: 拖拽式管理当前激活阵容 (Active Roster)。
- **Scout System**: 全库模糊搜索玩家,支持按 Rating/Matches/KD 排序筛选。
- **Contract System**: 模拟签约/解约流程 (Sign/Release),管理战队资产。
- **Identity**: 统一的头像与 ID 显示逻辑 (SteamID/Name),支持自动生成首字母头像。
2. **Tactics Board (战术终端)**
- **SPA 架构**: 基于 Alpine.js 的单页应用,无刷新切换四大功能区。
- **Board (战术板)**: 集成 Leaflet.js 的交互式地图,支持战术点位标记。
- **Data (数据中心)**: 实时查看全队近期数据表现。
- **Analysis (深度分析)**:
- **Chemistry**: 任意组合 (2-5人) 的共同比赛胜率与数据分析。
- **Depth**: 阵容深度与位置分析。
- **Economy (经济计算)**: 简单的经济局/长枪局计算器。
3. **Match Center (比赛中心)**
- **List View**:
- 显示比赛平均 ELO。
- **Party Identification**: 自动识别组排车队 (👥 2-5),并用颜色区分规模 (Indigo/Blue/Purple/Orange)。
- **Result Tracking**: 基于 "Our Team" (Active Roster) 的胜负判定 (VICTORY/DEFEAT/CIVIL WAR)。
- **Detail View**:
- 按 Rating 降序排列双方队员。
- 高亮显示组排关系。
- 集成 Round-by-Round 经济与事件详情。
4. **Player Profile (玩家档案)**
- 综合能力雷达图 (六维数据)。
- 近期 Rating/KD/ADR 趋势折线图。
- 详细的历史比赛记录(含 Party info 与 Result
- 头像上传与管理。
## 自动化与运维
新增 `ETL/refresh.py` 自动化脚本,用于一键执行全量数据刷新:
- 自动清理旧数据库。
- 顺序执行 L1A -> L2 -> L3 构建。
- 自动处理 schema 迁移。
## 数据流程
1. **下载与落盘**
通过 `downloader/downloader.py` 抓取比赛页面数据,生成 `output_arena/<match_id>/iframe_network.json`,并可同时下载 demo 文件。
@@ -24,31 +71,32 @@ yrtv这一块。
```
yrtv/
├── downloader/ # 下载器(抓取 iframe JSON 与 demo
│ ├── downloader.py
│ └── README.md
├── ETL/ # ETL 脚本
│ ├── L1A.py
│ ├── L2_Builder.py
│ ├── L3_Builder.py
│ ├── README.md
│ ├── refresh.py # [NEW] 一键刷新脚本
│ └── verify/
│ ├── verify_L2.py
└── verify_deep.py
├── database/
│ ├── L1A/ # L1A SQLite 与说明
── L1B/ # L1B 目录demo 解析结果说明)
├── L2/ # L2 SQLite 与 schema
│ ├── L3/ # L3 SQLite 与 schema (特征集市)
── original_json_schema/ # schema 扁平化与未覆盖字段清单
├── database/ # SQLite 数据库存储
├── L1A/
│ ├── L2/
│ ├── L3/
── original_json_schema/
├── web/ # [NEW] Web 应用程序
│ ├── app.py # 应用入口
── routes/ # 路由 (matches, players, teams, tactics)
│ ├── services/ # 业务逻辑 (stats, web)
│ ├── templates/ # Jinja2 模板 (TailwindCSS + Alpine.js)
│ └── static/ # 静态资源 (CSS, JS, Uploads)
└── utils/
└── json_extractor/ # JSON Schema 抽取工具
```
## 环境要求
- Python 3.11.4+
- Flask, Jinja2
- Playwright下载器依赖
- pandasnumpy校验脚本依赖)
- pandas, numpy数据处理依赖)
## 数据库层级说明
### L1A

Binary file not shown.

Binary file not shown.

View File

@@ -195,6 +195,13 @@ CREATE TABLE IF NOT EXISTS fact_match_players (
flash_assists INTEGER,
flash_duration REAL,
jump_count INTEGER,
-- Utility Usage Stats (Parsed from round details)
util_flash_usage INTEGER DEFAULT 0,
util_smoke_usage INTEGER DEFAULT 0,
util_molotov_usage INTEGER DEFAULT 0,
util_he_usage INTEGER DEFAULT 0,
util_decoy_usage INTEGER DEFAULT 0,
damage_total INTEGER,
damage_received INTEGER,
damage_receive INTEGER,
@@ -365,6 +372,14 @@ CREATE TABLE IF NOT EXISTS fact_match_players_t (
year TEXT,
sts_raw TEXT,
level_info_raw TEXT,
-- Utility Usage Stats (Parsed from round details)
util_flash_usage INTEGER DEFAULT 0,
util_smoke_usage INTEGER DEFAULT 0,
util_molotov_usage INTEGER DEFAULT 0,
util_he_usage INTEGER DEFAULT 0,
util_decoy_usage INTEGER DEFAULT 0,
PRIMARY KEY (match_id, steam_id_64),
FOREIGN KEY (match_id) REFERENCES fact_matches(match_id) ON DELETE CASCADE
);
@@ -466,6 +481,14 @@ CREATE TABLE IF NOT EXISTS fact_match_players_ct (
year TEXT,
sts_raw TEXT,
level_info_raw TEXT,
-- Utility Usage Stats (Parsed from round details)
util_flash_usage INTEGER DEFAULT 0,
util_smoke_usage INTEGER DEFAULT 0,
util_molotov_usage INTEGER DEFAULT 0,
util_he_usage INTEGER DEFAULT 0,
util_decoy_usage INTEGER DEFAULT 0,
PRIMARY KEY (match_id, steam_id_64),
FOREIGN KEY (match_id) REFERENCES fact_matches(match_id) ON DELETE CASCADE
);
@@ -550,6 +573,7 @@ CREATE TABLE IF NOT EXISTS fact_round_player_economy (
main_weapon TEXT,
has_helmet BOOLEAN,
has_defuser BOOLEAN,
has_zeus BOOLEAN,
-- Round Performance Summary (Leetify)
round_performance_score REAL,

Binary file not shown.

View File

@@ -33,7 +33,7 @@
2. 对位最低Rating对手的KD差自身击杀-被该对手击杀)
3. 对位所有对手的胜率(自身击杀>被击杀的对手占比)
4. 平均对枪成功率(对所有对手的对枪成功率求平均)
5. 与单个对手的交火次数(相遇频率)
* ~~A. 对枪反应时间(遇敌到开火平均时长,需录像解析)~~ (Phase 5)
* B. 近/中/远距对枪占比及各自胜率 (仅 Classic 可行)

View File

@@ -14,6 +14,7 @@ CREATE TABLE IF NOT EXISTS dm_player_features (
-- ==========================================
basic_avg_rating REAL,
basic_avg_kd REAL,
basic_avg_adr REAL,
basic_avg_kast REAL,
basic_avg_rws REAL,
basic_avg_headshot_kills REAL,
@@ -31,6 +32,13 @@ CREATE TABLE IF NOT EXISTS dm_player_features (
basic_avg_revenge_kill REAL,
basic_avg_awp_kill REAL,
basic_avg_jump_count REAL,
basic_avg_knife_kill REAL,
basic_avg_zeus_kill REAL,
basic_zeus_pick_rate REAL,
basic_avg_mvps REAL,
basic_avg_plants REAL,
basic_avg_defuses REAL,
basic_avg_flash_assists REAL,
-- ==========================================
-- 1. STA: Stability & Time Series
@@ -47,7 +55,7 @@ CREATE TABLE IF NOT EXISTS dm_player_features (
-- ==========================================
bat_kd_diff_high_elo REAL,
bat_kd_diff_low_elo REAL,
bat_win_rate_vs_all REAL,
-- bat_win_rate_vs_all REAL, -- Removed
bat_avg_duel_win_rate REAL,
bat_avg_duel_freq REAL,
-- Distance based stats (Placeholder for Classic data)
@@ -82,13 +90,75 @@ CREATE TABLE IF NOT EXISTS dm_player_features (
-- ==========================================
-- 5. T/CT: Side Preference
-- ==========================================
side_rating_ct REAL,
side_rating_ct REAL, -- Currently calculated as K/D
side_rating_t REAL,
side_kd_ct REAL, -- Explicit K/D
side_kd_t REAL,
side_win_rate_ct REAL, -- Round Win %
side_win_rate_t REAL,
side_first_kill_rate_ct REAL,
side_first_kill_rate_t REAL,
side_hold_success_rate_ct REAL,
side_entry_success_rate_t REAL,
side_kd_diff_ct_t REAL, -- CT KD - T KD
-- New Side Comparisons
side_rating_diff_ct_t REAL,
-- ==========================================
-- 6. Party Size Performance
-- ==========================================
party_1_win_rate REAL,
party_1_rating REAL,
party_1_adr REAL,
party_2_win_rate REAL,
party_2_rating REAL,
party_2_adr REAL,
party_3_win_rate REAL,
party_3_rating REAL,
party_3_adr REAL,
party_4_win_rate REAL,
party_4_rating REAL,
party_4_adr REAL,
party_5_win_rate REAL,
party_5_rating REAL,
party_5_adr REAL,
-- ==========================================
-- 7. Rating Distribution (Performance Tiers)
-- ==========================================
rating_dist_carry_rate REAL, -- > 1.5
rating_dist_normal_rate REAL, -- 1.0 - 1.5
rating_dist_sacrifice_rate REAL, -- 0.6 - 1.0
rating_dist_sleeping_rate REAL, -- < 0.6
-- ==========================================
-- 8. ELO Stratification (Performance vs ELO)
-- ==========================================
elo_lt1200_rating REAL,
elo_1200_1400_rating REAL,
elo_1400_1600_rating REAL,
elo_1600_1800_rating REAL,
elo_1800_2000_rating REAL,
elo_gt2000_rating REAL,
-- ==========================================
-- 9. More Side Stats (Restored)
-- ==========================================
side_kast_ct REAL,
side_kast_t REAL,
side_rws_ct REAL,
side_rws_t REAL,
side_first_death_rate_ct REAL,
side_first_death_rate_t REAL,
side_multikill_rate_ct REAL,
side_multikill_rate_t REAL,
side_headshot_rate_ct REAL,
side_headshot_rate_t REAL,
side_defuses_ct REAL,
side_plants_t REAL,
side_planted_bomb_count INTEGER,
side_defused_bomb_count INTEGER,
@@ -99,7 +169,70 @@ CREATE TABLE IF NOT EXISTS dm_player_features (
util_avg_flash_time REAL,
util_avg_flash_enemy REAL,
util_avg_flash_team REAL,
util_usage_rate REAL
util_usage_rate REAL,
-- ==========================================
-- 7. Scores (0-100)
-- ==========================================
score_bat REAL,
score_sta REAL,
score_hps REAL,
score_ptl REAL,
score_tct REAL,
score_util REAL,
score_eco REAL,
score_pace REAL,
-- ==========================================
-- 8. ECO: Economy Efficiency
-- ==========================================
eco_avg_damage_per_1k REAL,
eco_rating_eco_rounds REAL,
eco_kd_ratio REAL,
eco_avg_rounds REAL,
-- ==========================================
-- 9. PACE: Aggression & Trade
-- ==========================================
pace_avg_time_to_first_contact REAL,
pace_trade_kill_rate REAL,
pace_opening_kill_time REAL,
pace_avg_life_time REAL,
rd_phase_kill_early_share REAL,
rd_phase_kill_mid_share REAL,
rd_phase_kill_late_share REAL,
rd_phase_death_early_share REAL,
rd_phase_death_mid_share REAL,
rd_phase_death_late_share REAL,
rd_phase_kill_early_share_t REAL,
rd_phase_kill_mid_share_t REAL,
rd_phase_kill_late_share_t REAL,
rd_phase_kill_early_share_ct REAL,
rd_phase_kill_mid_share_ct REAL,
rd_phase_kill_late_share_ct REAL,
rd_phase_death_early_share_t REAL,
rd_phase_death_mid_share_t REAL,
rd_phase_death_late_share_t REAL,
rd_phase_death_early_share_ct REAL,
rd_phase_death_mid_share_ct REAL,
rd_phase_death_late_share_ct REAL,
rd_firstdeath_team_first_death_rounds INTEGER,
rd_firstdeath_team_first_death_win_rate REAL,
rd_invalid_death_rounds INTEGER,
rd_invalid_death_rate REAL,
rd_pressure_kpr_ratio REAL,
rd_pressure_perf_ratio REAL,
rd_pressure_rounds_down3 INTEGER,
rd_pressure_rounds_normal INTEGER,
rd_matchpoint_kpr_ratio REAL,
rd_matchpoint_perf_ratio REAL,
rd_matchpoint_rounds INTEGER,
rd_comeback_kill_share REAL,
rd_comeback_rounds INTEGER,
rd_trade_response_10s_rate REAL,
rd_weapon_top_json TEXT,
rd_roundtype_split_json TEXT,
map_stability_coef REAL
);
-- Optional: Detailed per-match feature table for time-series analysis

BIN
database/Web/Web_App.sqlite Normal file

Binary file not shown.

83
docs/6D_README.md Normal file
View File

@@ -0,0 +1,83 @@
# YRTV Player Capability Model (6-Dimension System)
This document outlines the calculation principles and formulas for the 6-dimensional player capability model used in the YRTV platform.
## Overview
The model evaluates players across 6 key dimensions:
1. **BAT (Battle Power)**: Aim and direct combat ability.
2. **PTL (Pistol)**: Performance in pistol rounds.
3. **HPS (High Pressure)**: Performance in clutch and high-stakes situations.
4. **SIDE (Side Proficiency)**: T vs CT side performance balance and rating.
5. **UTIL (Utility)**: Usage and effectiveness of grenades/utility.
6. **STA (Stability)**: Consistency and endurance over matches/time.
Each dimension score is normalized to a 0-100 scale using min-max normalization against the player pool (with outlier clipping at 5th/95th percentiles).
---
## 1. BAT (Battle Power)
*Focus: Raw aiming and dueling mechanics.*
**Features & Weights:**
- **Rating (40%)**: Average Match Rating (Rating 2.0).
- **KD Ratio (20%)**: Average Kill/Death Ratio.
- **ADR (20%)**: Average Damage per Round.
- **Headshot% (10%)**: Headshot kills / Total kills.
- **First Kill Success (10%)**: Entry Kills / (Entry Kills + Entry Deaths).
- **Duel Win Rate (High Elo) (10%)**: KD Ratio specifically against high-Elo opponents.
## 2. PTL (Pistol Round)
*Focus: Proficiency in pistol rounds (R1 & R13).*
**Features & Weights:**
- **Pistol KD (50%)**: Kill/Death ratio in pistol rounds.
- **Pistol Util Efficiency (25%)**: Headshot rate in pistol rounds (proxy for precision).
- **Pistol Multi-Kills (25%)**: Frequency of multi-kills in pistol rounds.
## 3. HPS (High Pressure)
*Focus: Clutching and performing under stress.*
**Features & Weights:**
- **1v1 Win Rate (20%)**: Percentage of 1v1 clutches won.
- **1v3+ Win Rate (30%)**: Percentage of 1vN (N>=3) clutches won (High impact).
- **Match Point Win Rate (20%)**: Win rate in rounds where team is at match point.
- **Comeback KD Diff (15%)**: KD difference when playing from behind (score gap >= 4).
- **Undermanned Survival (15%)**: Ability to survive or trade when team is outnumbered.
## 4. SIDE (Side Proficiency)
*Focus: Tactical versatility and side bias.*
**Features & Weights:**
- **CT Rating (35%)**: Average Rating on CT side.
- **T Rating (35%)**: Average Rating on T side.
- **Side Balance (15%)**: Penalty for high disparity between T and CT performance (1 - |T_Rating - CT_Rating|).
- **Entry Rate T (15%)**: Frequency of attempting entry kills on T side.
## 5. UTIL (Utility)
*Focus: Strategic use of grenades.*
**Features & Weights:**
- **Util Usage Rate (25%)**: Frequency of buying/using utility items.
- **Flash Assists (20%)**: Average flash assists per match.
- **Util Damage (20%)**: Average grenade damage per match.
- **Flash Blind Time (15%)**: Average enemy blind time per match.
- **Flash Efficiency (20%)**: Enemies blinded per flash thrown.
## 6. STA (Stability)
*Focus: Consistency and mental resilience.*
**Features & Weights:**
- **Rating Consistency (30%)**: Inverse of Rating Standard Deviation (Lower variance = Higher score).
- **Fatigue Resistance (20%)**: Performance drop-off in later matches of the day (vs first 3 matches).
- **Win/Loss Gap (30%)**: Difference in Rating between Won and Lost matches (Smaller gap = More stable).
- **Time/Rating Correlation (20%)**: Ability to maintain rating in long matches.
---
## Calculation Process (ETL)
1. **L2 Aggregation**: Raw match data is aggregated into `fact_match_players` (L2).
2. **Feature Extraction**: Complex features (e.g., Pistol KD, Side Rating) are calculated per player.
3. **Normalization**: Each feature is scaled to 0-100 based on population distribution.
4. **Weighted Sum**: Dimension scores are calculated using the weights above.
5. **Radar Chart**: Final scores are displayed on the 6-axis radar chart in the player profile.

44
docs/FeatureDemoRDD.md Normal file
View File

@@ -0,0 +1,44 @@
---
## demo维度
### d1、经济管理特征
1. 每局平均道具数量与使用率(烟雾、闪光、燃烧弹、手雷)
2. 伤害性道具效率(手雷/燃烧弹造成伤害值/投掷次数)
3. 细分武器KDAWP、AK-47、M4A4等
4. 武器选择与回合胜率相关系数(某武器使用时胜率-整体胜率)
5. 保枪成功率(需保枪回合中成功保下武器次数/总机会)
6. 经济溢出率(每局剩余金钱>3000的回合占比
### d2、团队协同特征后续进行详细设计计算暂时有较大缺陷
1. 补枪成功次数队友阵亡后10秒内完成击杀
2. 补枪反应时间(队友阵亡到自身补枪击杀的平均时长)
3. 与队友A的补枪成功率对队友A的补枪成功次数/其阵亡次数)
4. 被补枪率自身阵亡后10秒内被队友补枪次数/总阵亡次数)
5. 道具配合得分(被队友闪光致盲后击杀的敌人数量)
6. 辅助道具价值(自身烟雾/燃烧弹帮助队友下包/拆包次数)
7. 拉枪线贡献(自身阵亡后队友获得多杀的次数)
8. 疑似卖队友次数(自身附近队友存活但未补枪的阵亡次数)
### d3、经济影响力特征自定义计算方案
1. 累计缴获敌方武器的经济价值如AWP按4750计算
2. 保枪致胜次数(保下的武器在下一回合帮助获胜的次数)
3. 单局经济扭转值(因自身行为导致的双方经济差变化)
4. 回合致胜首杀贡献分首杀为胜利带来的权重分如5v4优势计0.3分)
5. 回合致胜道具贡献分(关键烟雾/闪光为胜利带来的权重分)
6. 回合致胜残局贡献分1vN残局胜利的权重分1v3+计1分
### d4、热图与站位特征预留demoparser阶段开发
1. 各地图区域击杀数如Inferno的A区、B区、中路等
2. 各地图区域死亡数(同上区域划分)
3. 常用站位区域占比(某区域停留时间/总回合时间)
4. 区域对枪胜率(某区域内击杀数/死亡数)
---
完整了解代码库与web端需求文档 WebRDD.md 开始计划开发web端完成web端的所有需求。
注意不需要实现注册登录系统最好核心是token系统。
严格按照需求部分规划开发方案与开发顺序。不要忽略内容。
utils下还会有哪些需要打包成可快速调用的工具针对这个项目你有什么先见

View File

@@ -12,7 +12,7 @@
11. 每局2+杀/3+杀/4+杀/5杀次数多杀
12. 连续击杀累计次数(连杀)
15. **(New) 助攻次数 (assisted_kill)**
16. **(New) 无伤击杀 (perfect_kill)**
16. **(New) 完美击杀 (perfect_kill)**
17. **(New) 复仇击杀 (revenge_kill)**
18. **(New) AWP击杀数 (awp_kill)**
19. **(New) 总跳跃次数 (jump_count)**
@@ -75,45 +75,6 @@
4. 每局平均道具数量与使用率(烟雾、闪光、燃烧弹、手雷)
---
## demo维度
### d1、经济管理特征
1. 每局平均道具数量与使用率(烟雾、闪光、燃烧弹、手雷)
2. 伤害性道具效率(手雷/燃烧弹造成伤害值/投掷次数)
3. 细分武器KDAWP、AK-47、M4A4等
4. 武器选择与回合胜率相关系数(某武器使用时胜率-整体胜率)
5. 保枪成功率(需保枪回合中成功保下武器次数/总机会)
6. 经济溢出率(每局剩余金钱>3000的回合占比
### d2、团队协同特征后续进行详细设计计算暂时有较大缺陷
1. 补枪成功次数队友阵亡后10秒内完成击杀
2. 补枪反应时间(队友阵亡到自身补枪击杀的平均时长)
3. 与队友A的补枪成功率对队友A的补枪成功次数/其阵亡次数)
4. 被补枪率自身阵亡后10秒内被队友补枪次数/总阵亡次数)
5. 道具配合得分(被队友闪光致盲后击杀的敌人数量)
6. 辅助道具价值(自身烟雾/燃烧弹帮助队友下包/拆包次数)
7. 拉枪线贡献(自身阵亡后队友获得多杀的次数)
8. 疑似卖队友次数(自身附近队友存活但未补枪的阵亡次数)
### d3、经济影响力特征自定义计算方案
1. 累计缴获敌方武器的经济价值如AWP按4750计算
2. 保枪致胜次数(保下的武器在下一回合帮助获胜的次数)
3. 单局经济扭转值(因自身行为导致的双方经济差变化)
4. 回合致胜首杀贡献分首杀为胜利带来的权重分如5v4优势计0.3分)
5. 回合致胜道具贡献分(关键烟雾/闪光为胜利带来的权重分)
6. 回合致胜残局贡献分1vN残局胜利的权重分1v3+计1分
### d4、热图与站位特征预留demoparser阶段开发
1. 各地图区域击杀数如Inferno的A区、B区、中路等
2. 各地图区域死亡数(同上区域划分)
3. 常用站位区域占比(某区域停留时间/总回合时间)
4. 区域对枪胜率(某区域内击杀数/死亡数)
---
### 手调1.、指挥手动调节因子主观评价0-10分
1. 沟通量(信息传递频率与有效性)
2. 辅助决策能力(半区决策建议的合理性)

View File

@@ -3,7 +3,7 @@
## 1. 项目概述 (Overview)
### 1.1 项目背景
YRTV 是一个面向 CS2 战队数据洞察与战术研判的 Web 平台。该平台基于现有的 `ETL` 数据管线与 `L2_Main.sqlite` 核心数据库,旨在通过 Web 界面提供可视化的数据查询、战队管理、战术模拟及深度分析功能。
YRTV 是一个面向 CS2 战队数据洞察与战术研判的 Web 平台,旨在通过 Web 界面提供可视化的数据查询、战队管理、战术模拟及深度分析功能。
### 1.2 核心目标
* **数据可视化**: 将复杂的 SQLite 比赛数据转化为易读的图表、雷达图和趋势线。
@@ -18,7 +18,7 @@ YRTV 是一个面向 CS2 战队数据洞察与战术研判的 Web 平台。该
* **L3**: SQLite (`database/L3/L3_Features.sqlite`) - 高级衍生特征 (Read-Only for Web)
* **Web**: SQLite (`database/Web/Web_App.sqlite`) - [新增] 业务数据 (用户、评论、阵容配置、策略板存档)
* **模板引擎**: Jinja2 (服务端渲染)
* **前端样式**: Tailwind CSS (CDN 引入,快速开发)
* **前端样式**: Tailwind CSS (CDN 引入,快速开发) + PC-First 响应式设计 (适配手机、平板与桌面端),主题色紫色,可切换黑白模式。
* **前端交互**:
* **图表**: Chart.js / ECharts (雷达图、趋势图)
* **交互**: Alpine.js 或原生 JS (处理模态框、异步请求)
@@ -36,16 +36,18 @@ yrtv/
│ ├── app.py # Flask 应用入口
│ ├── config.py # 配置文件
│ ├── routes/ # 路由模块
│ │ ├── main.py # 首页与通用
│ │ ├── players.py # 玩家模块
│ │ ├── teams.py # 战队模块
│ │ ├── matches.py # 比赛模块
│ │ ├── tactics.py # 战术与分析模块
│ │ ── admin.py # 管理后台
├── services/ # 业务逻辑层 (数据计算)
│ ├── stats_service.py # 核心指标计算
│ │ ├── feature_store.py # L3 特征读取与计算
│ │ ── etl_trigger.py # ETL 调度
│ │ ├── main.py # 首页与通用 (Home)
│ │ ├── players.py # 玩家模块 (List, Detail, Compare)
│ │ ├── teams.py # 战队模块 (Lineup, Stats)
│ │ ├── matches.py # 比赛模块 (List, Detail, Demo)
│ │ ├── tactics.py # 战术模块 (Lineup Builder, Map, Nade)
│ │ ── wiki.py # 知识库模块 (Wiki, Docs)
│ └── admin.py # 管理后台 (ETL Trigger, User Mgmt)
│ ├── services/ # 业务逻辑层 (连接 L2/L3/Web DB)
│ │ ├── stats_service.py # 基础数据查询 (L2)
│ │ ── feature_service.py # 高级特征查询 (L3)
│ │ ├── wiki_service.py # 知识库管理
│ │ └── user_service.py # 用户与评论管理
│ ├── static/ # 静态资源
│ │ ├── css/
│ │ ├── js/
@@ -53,25 +55,37 @@ yrtv/
│ └── templates/ # Jinja2 模板
│ ├── base.html
│ ├── components/
── ... (各页面模板)
── home/
│ ├── players/
│ ├── teams/
│ ├── matches/
│ ├── tactics/
│ ├── wiki/
│ └── admin/
├── database/ # 数据存储
│ ├── L1A/ # 原始爬虫数据
│ ├── L2/ # 结构化事实数据
── L3/ # [新增] 衍生特征库 (Feature Store)
└── ETL/ # ETL 脚本
├── L1A.py
├── L2_Builder.py
── L3_FeatureEng.py # [新增] L3 特征工程脚本
── L3/ # 衍生特征库 (Feature Store)
└── Web/ # [新增] 业务数据库 (User, Comment, Wiki)
└── ETL/ # 数据处理层 (ETL Pipeline)
├── L1A.py # L1A Ingest
── L2_Builder.py # L2 Transform
└── L3_Builder.py # L3 Feature Engineering (原 feature_store.py 逻辑)
```
### 2.2 数据流向
1. **ETL 层**:
1. **ETL 层 (数据处理核心)**:
* L1 (Raw): 爬虫 -> JSON 存储。
* L2 (Fact): JSON -> 清洗/标准化 -> Fact/Dim Tables。
* **L3 (Features)**: L2 -> 聚合/滑窗计算/模型推理 -> Player/Team Derived Features (e.g., 近期状态分, 地图熟练度, 关键局胜率)。
2. **Service 层**: Flask Service 读取 L2 (基础数据) 和 L3 (高级特征),执行业务逻辑。
3. **View 层**: Jinja2 渲染 HTML,嵌入计算后的数据
4. **Client 层**: 浏览器展示页面JS 处理局部交互。
* **L3 (Features)**: L2 -> 聚合/滑窗计算/模型推理 -> Player/Team Derived Features。**数据处理逻辑收敛于 ETL 目录下的脚本Web 端仅负责读取 L2/L3 结果。**
2. **Service 层**: Flask Service 仅负责 SQL 查询与简单的业务组装(如评论关联),不再包含复杂的数据计算逻辑。
3. **View 层**: Jinja2 渲染 HTML。
4. **Client 层**: 浏览器交互。
### 2.3 开发与启动 (Development & Startup)
* **启动方式**:
* 在项目根目录下运行: `python web/app.py`
* 访问地址: `http://127.0.0.1:5000`
---
@@ -92,10 +106,10 @@ yrtv/
* **比赛解析器**: 输入 5E 比赛链接,点击按钮触发后台 ETL 任务(异步),前端显示 Loading 状态或 Toast 提示。
### 3.2 玩家模块 (Players)
#### 3.2.1 玩家列表
#### 3.2.1 玩家列表 PlayerList
* **筛选/搜索**: 按 ID/昵称搜索,按 K/D、Rating、MVP 等指标排序。
* **展示**: 卡片式布局显示头像、ID、主队、核心数据 (Rating, K/D, ADR)。
#### 3.2.2 玩家详情
#### 3.2.2 玩家详情 PlayerProfile
* **基础信息**: 头像、SteamID、5E ID、注册时间。可以手动分配Tag。
* **核心指标**: 赛季平均 Rating, ADR, KAST, 首杀成功率等。
* **能力雷达图**: *计算规则需在 Service 层定义*
@@ -109,11 +123,11 @@ yrtv/
* **统计概览**: 战队整体胜率、近期战绩、地图胜率分布,个人关键数据。
### 3.4 比赛模块 (Matches)
#### 3.4.1 比赛列表
#### 3.4.1 比赛列表 MatchList
* **筛选**: 按地图、日期范围筛选。
* **展示**: 列表视图显示时间、地图、比分、胜负、MVP。
#### 3.4.2 比赛详情
#### 3.4.2 比赛详情 MatchDetail
* **头部**: 比分板CT/T 分数、地图、时长、Demo 下载链接。
* **数据表**: 双方队伍的完整数据表K, D, A, FK, FD, ADR, Rating, KAST, AWP Kills 等)。
* *利用 `fact_match_players` 中的丰富字段*
@@ -126,7 +140,7 @@ yrtv/
* **共同经历**: 查询这 5 人共同参与过的比赛场次及胜率。
* **协同矩阵**: 选择特定阵容展示两两之间的协同数据A 补枪 B 的次数A 与 B 同时在场时的胜率)。
* **最佳/短板分析**: 基于历史数据分析该阵容在特定地图上的强弱项。
#### 3.5.2 数据对比
#### 3.5.2 数据对比 Data Center
* **多选对比**: 选择多名玩家,并在同一雷达图/柱状图中对比各项数据。
* **地图筛选**: 查看特定玩家在特定地图上的表现差异。
#### 3.5.3 道具与策略板 (Grenades & Strategy Board)
@@ -159,7 +173,7 @@ yrtv/
* 上传 demo 文件或修正比赛数据。
* **配置**: 管理员账号管理、全局公告设置。查看网站访问数等后台统计。
### 3.7E 管理后台查询工具 (SQL Runner)
### 3.8 管理后台查询工具 (SQL Runner)
* **功能**: 提供一个 Web 版的 SQLite 查询窗口。
* **限制**: 只读权限(防止 `DROP/DELETE`),仅供高级用户进行自定义数据挖掘。

View File

@@ -31,7 +31,7 @@ python downloader.py --url https://arena.5eplay.com/data/match/g161-202601182227
批量下载(从文件读取 URL
```bash
python downloader.py --url-list gamelist/match_list_2026.txt
python downloader/downloader.py --url-list downloader/match_list_temp.txt --concurrency 4 --headless true --fetch-type iframe
```
指定输出目录:

View File

@@ -0,0 +1,12 @@
https://arena.5eplay.com/data/match/g161-20260120090500700546858
https://arena.5eplay.com/data/match/g161-20260123152313646137189
https://arena.5eplay.com/data/match/g161-20260123155331151172258
https://arena.5eplay.com/data/match/g161-20260123163155468519060
https://arena.5eplay.com/data/match/g161-20260125163636663072260
https://arena.5eplay.com/data/match/g161-20260125171525375681453
https://arena.5eplay.com/data/match/g161-20260125174806246015320
https://arena.5eplay.com/data/match/g161-20260125182858851607650
https://arena.5eplay.com/data/match/g161-20260127133354952029097
https://arena.5eplay.com/data/match/g161-20260127141401965388621
https://arena.5eplay.com/data/match/g161-20260127144918246454523
https://arena.5eplay.com/data/match/g161-20260127161541951490476

View File

@@ -0,0 +1,21 @@
https://arena.5eplay.com/data/match/g161-20260116113753599674563
https://arena.5eplay.com/data/match/g161-20260116105442247840198
https://arena.5eplay.com/data/match/g161-20260116102417845632390
https://arena.5eplay.com/data/match/g161-20260116091335547226912
https://arena.5eplay.com/data/match/g161-20260115174926535143518
https://arena.5eplay.com/data/match/g161-20260115171408550328234
https://arena.5eplay.com/data/match/g161-20260115161507644198027
https://arena.5eplay.com/data/match/g161-20260115153741594547847
https://arena.5eplay.com/data/match/g161-20260115150134653528666
https://arena.5eplay.com/data/match/g161-20260115142248467942413
https://arena.5eplay.com/data/match/g161-20260115134537148483852
https://arena.5eplay.com/data/match/g161-b-20251220170603831835021
https://arena.5eplay.com/data/match/g161-b-20251220163145714630262
https://arena.5eplay.com/data/match/g161-b-20251220154644424162461
https://arena.5eplay.com/data/match/g161-20251220151348629917836
https://arena.5eplay.com/data/match/g161-20251220143804815413986
https://arena.5eplay.com/data/match/g161-20251213224016824985377
https://arena.5eplay.com/data/match/g161-20251119220301211708132
https://arena.5eplay.com/data/match/g161-20251119212237018904830
https://arena.5eplay.com/data/match/g161-20251119220301211708132
https://arena.5eplay.com/data/match/g161-20251114142342512006943

7
requirements.txt Normal file
View File

@@ -0,0 +1,7 @@
Flask
pandas
numpy
playwright
gunicorn
gevent
matplotlib

View File

@@ -0,0 +1,74 @@
import sqlite3
import pandas as pd
import os
# Config
L2_DB_PATH = r'database/L2/L2_Main.sqlite'
L3_DB_PATH = r'database/L3/L3_Features.sqlite'
def analyze_team_dmg_per_1k():
if not os.path.exists(L3_DB_PATH):
print(f"Error: L3 DB not found at {L3_DB_PATH}")
return
conn_l3 = sqlite3.connect(L3_DB_PATH)
conn_l2 = sqlite3.connect(L2_DB_PATH)
print("--- Analysis: Team Dmg/$1k (Economy Efficiency) ---")
try:
# 1. Get all L3 features
query = """
SELECT f.steam_id_64, f.eco_avg_damage_per_1k, p.username
FROM dm_player_features f
LEFT JOIN dim_players p ON f.steam_id_64 = p.steam_id_64
ORDER BY f.eco_avg_damage_per_1k DESC
"""
# Attach L2 for username lookup
# We can't attach across connections easily in sqlite python without ATTACH DATABASE command
# So let's fetch L3 first, then map names from L2
df_l3 = pd.read_sql_query("SELECT steam_id_64, eco_avg_damage_per_1k FROM dm_player_features", conn_l3)
if df_l3.empty:
print("No data in L3 Features.")
return
# Fetch names
ids = tuple(df_l3['steam_id_64'].tolist())
placeholders = ','.join(['?'] * len(ids))
q_names = f"SELECT steam_id_64, username FROM dim_players WHERE steam_id_64 IN ({placeholders})"
df_names = pd.read_sql_query(q_names, conn_l2, params=ids)
# Merge
df = df_l3.merge(df_names, on='steam_id_64', how='left')
# Sort
df = df.sort_values('eco_avg_damage_per_1k', ascending=False)
print(f"{'Rank':<5} {'Player':<20} {'Dmg/$1k':<10}")
print("-" * 40)
for idx, row in df.iterrows():
rank = idx + 1 # This index is not rank if we iterated row by row after sort, wait.
# reset_index to get rank
pass
df = df.reset_index(drop=True)
for idx, row in df.iterrows():
name = row['username'] if row['username'] else row['steam_id_64']
val = row['eco_avg_damage_per_1k']
print(f"#{idx+1:<4} {name:<20} {val:.2f}")
except Exception as e:
print(f"Error: {e}")
import traceback
traceback.print_exc()
finally:
conn_l2.close()
conn_l3.close()
if __name__ == "__main__":
analyze_team_dmg_per_1k()

45
scripts/debug_dist.py Normal file
View File

@@ -0,0 +1,45 @@
import sqlite3
import pandas as pd
from web.services.feature_service import FeatureService
from web.config import Config
from web.app import create_app
def check_distribution():
app = create_app()
with app.app_context():
# Get a player ID from L3
conn = sqlite3.connect(Config.DB_L3_PATH)
row = conn.execute("SELECT steam_id_64 FROM dm_player_features LIMIT 1").fetchone()
if not row:
print("No players in L3")
return
sid = row[0]
print(f"Checking distribution for {sid}...")
dist = FeatureService.get_roster_features_distribution(sid)
if not dist:
print("Distribution returned None")
return
keys_to_check = [
'eco_avg_damage_per_1k', # Working
'eco_rating_eco_rounds', # Working
'eco_kd_ratio', # Broken
'eco_avg_rounds', # Broken
'pace_avg_time_to_first_contact', # Working
'pace_trade_kill_rate', # Working
'pace_opening_kill_time', # Broken
'pace_avg_life_time' # Broken
]
print(f"{'Key':<35} | {'Present':<7} | {'Value'}")
print("-" * 60)
for k in keys_to_check:
is_present = k in dist
val = dist.get(k)
print(f"{k:<35} | {str(is_present):<7} | {val}")
if __name__ == "__main__":
check_distribution()

94
scripts/debug_jacky.py Normal file
View File

@@ -0,0 +1,94 @@
import sqlite3
import pandas as pd
import os
# Config
L2_DB_PATH = r'database/L2/L2_Main.sqlite'
def debug_player_data(username_pattern='jAckY'):
if not os.path.exists(L2_DB_PATH):
print(f"Error: L2 DB not found at {L2_DB_PATH}")
return
conn_l2 = sqlite3.connect(L2_DB_PATH)
print(f"--- Debugging Player: {username_pattern} ---")
try:
# 1. Find the player ID
q_id = f"SELECT steam_id_64, username FROM dim_players WHERE username LIKE '%{username_pattern}%'"
df_player = pd.read_sql_query(q_id, conn_l2)
if df_player.empty:
print("Player not found.")
return
target_id = df_player.iloc[0]['steam_id_64']
name = df_player.iloc[0]['username']
print(f"Found: {name} ({target_id})")
# 2. Check Match Stats (ADR, Rounds)
q_matches = f"""
SELECT match_id, round_total, adr, (adr * round_total) as damage_calc
FROM fact_match_players
WHERE steam_id_64 = '{target_id}'
"""
df_matches = pd.read_sql_query(q_matches, conn_l2)
total_dmg = df_matches['damage_calc'].sum()
total_rounds = df_matches['round_total'].sum()
print(f"\nMatch Stats:")
print(f"Matches Played: {len(df_matches)}")
print(f"Total Rounds: {total_rounds}")
print(f"Total Damage (Calc): {total_dmg:,.0f}")
# 3. Check Economy Stats (Spend)
q_eco = f"""
SELECT match_id, COUNT(*) as rounds_with_eco, SUM(equipment_value) as spend
FROM fact_round_player_economy
WHERE steam_id_64 = '{target_id}'
GROUP BY match_id
"""
df_eco = pd.read_sql_query(q_eco, conn_l2)
total_spend = df_eco['spend'].sum()
total_eco_rounds = df_eco['rounds_with_eco'].sum()
print(f"\nEconomy Stats:")
print(f"Matches with Eco Data: {len(df_eco)}")
print(f"Rounds with Eco Data: {total_eco_rounds}")
print(f"Total Spend: ${total_spend:,.0f}")
# 4. Compare
print(f"\nComparison:")
print(f"Rounds in Match Stats: {total_rounds}")
print(f"Rounds in Eco Stats: {total_eco_rounds}")
if total_eco_rounds < total_rounds:
print(f"⚠️ WARNING: Missing economy data for {total_rounds - total_eco_rounds} rounds!")
# Find matches with missing eco data
merged = df_matches.merge(df_eco, on='match_id', how='left')
missing = merged[merged['spend'].isna() | (merged['spend'] == 0)]
if not missing.empty:
print(f"\nMatches with ZERO spend/Missing Eco:")
print(missing[['match_id', 'round_total', 'damage_calc']])
# Check calculation impact
valid_dmg = merged[merged['spend'] > 0]['damage_calc'].sum()
print(f"\nRecalculation ignoring missing matches:")
print(f"Valid Damage: {valid_dmg:,.0f}")
print(f"Total Spend: ${total_spend:,.0f}")
if total_spend > 0:
new_val = valid_dmg / (total_spend / 1000)
print(f"Corrected Dmg/$1k: {new_val:.2f}")
except Exception as e:
print(f"Error: {e}")
finally:
conn_l2.close()
if __name__ == "__main__":
debug_player_data()

36
web/app.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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)

View 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)

File diff suppressed because it is too large Load Diff

View 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

File diff suppressed because it is too large Load Diff

View 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
View 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])

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

View 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 %}

View 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 %}

View 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
View 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">&copy; 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>

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 &rarr;</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 %}

View 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 %}

File diff suppressed because it is too large Load Diff

View 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 %}

View 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 %}

View 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>
&times;
</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 %}

View 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">
&times;
</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>

View 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 %}

View 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">&times;</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 %}

View 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 %}

View 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 %}

View 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">&#8203;</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 %}

View 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">
&times;
</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">
&times;
</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 %}

View 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 %}

View 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">
查看分析 &rarr;
</a>
</div>
{% endfor %}
</div>
</div>
{% endblock %}

View 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 %}

View 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 %}

View 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 %}

12
wsgi.py Normal file
View File

@@ -0,0 +1,12 @@
import sys
import os
# Ensure the project root is in sys.path
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from web.app import create_app
app = create_app()
if __name__ == "__main__":
app.run()