diff --git a/.gitignore b/.gitignore index 16de753..7f0e7f8 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ __pycache__/ *.so *.dylib *.dll +.trae/ .Python build/ diff --git a/ETL/L2_Builder.py b/ETL/L2_Builder.py index 79adfc9..36308d7 100644 --- a/ETL/L2_Builder.py +++ b/ETL/L2_Builder.py @@ -659,6 +659,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')) diff --git a/ETL/L3_Builder.py b/ETL/L3_Builder.py index 5adb721..610882c 100644 --- a/ETL/L3_Builder.py +++ b/ETL/L3_Builder.py @@ -43,6 +43,7 @@ def calculate_basic_features(df): 'total_matches': count, 'basic_avg_rating': df['rating'].mean(), 'basic_avg_kd': df['kd_ratio'].mean(), + 'basic_avg_adr': df['adr'].mean() if 'adr' in df.columns else 0.0, 'basic_avg_kast': df['kast'].mean(), 'basic_avg_rws': df['rws'].mean(), 'basic_avg_headshot_kills': df['headshot_count'].sum() / count, diff --git a/ETL/refresh.py b/ETL/refresh.py new file mode 100644 index 0000000..2930d0e --- /dev/null +++ b/ETL/refresh.py @@ -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() diff --git a/FeatureDemoRDD.md b/FeatureDemoRDD.md index c1a16fd..d477072 100644 --- a/FeatureDemoRDD.md +++ b/FeatureDemoRDD.md @@ -35,4 +35,10 @@ 3. 常用站位区域占比(某区域停留时间/总回合时间) 4. 区域对枪胜率(某区域内击杀数/死亡数) ---- \ No newline at end of file +--- + +完整了解代码库与web端需求文档 WebRDD.md ,开始计划开发web端,完成web端的所有需求。 +注意不需要实现注册登录系统,最好核心是token系统。 +严格按照需求部分规划开发方案与开发顺序。不要忽略内容。 + +utils下还会有哪些需要打包成可快速调用的工具?针对这个项目,你有什么先见? \ No newline at end of file diff --git a/WebRDD.md b/WebRDD.md index 3ac177a..427d014 100644 --- a/WebRDD.md +++ b/WebRDD.md @@ -82,6 +82,11 @@ yrtv/ 3. **View 层**: Jinja2 渲染 HTML。 4. **Client 层**: 浏览器交互。 +### 2.3 开发与启动 (Development & Startup) +* **启动方式**: + * 在项目根目录下运行: `python web/app.py` + * 访问地址: `http://127.0.0.1:5000` + --- ## 3. 功能需求详解 (Functional Requirements) @@ -105,7 +110,7 @@ yrtv/ * **筛选/搜索**: 按 ID/昵称搜索,按 K/D、Rating、MVP 等指标排序。 * **展示**: 卡片式布局,显示头像、ID、主队、核心数据 (Rating, K/D, ADR)。 #### 3.2.2 玩家详情 PlayerProfile -* **基础信息**: 头像、SteamID、5E ID、注册时间。可以手动分配Tag。玩家列表 Players +* **基础信息**: 头像、SteamID、5E ID、注册时间。可以手动分配Tag。 * **核心指标**: 赛季平均 Rating, ADR, KAST, 首杀成功率等。 * **能力雷达图**: *计算规则需在 Service 层定义*。 * **趋势图**: 近 10/20 场比赛 Rating 走势 (Chart.js)。 @@ -168,7 +173,7 @@ yrtv/ * 上传 demo 文件或修正比赛数据。 * **配置**: 管理员账号管理、全局公告设置。查看网站访问数等后台统计。 -### 3.7E 管理后台查询工具 (SQL Runner) +### 3.8 管理后台查询工具 (SQL Runner) * **功能**: 提供一个 Web 版的 SQLite 查询窗口。 * **限制**: 只读权限(防止 `DROP/DELETE`),仅供高级用户进行自定义数据挖掘。 diff --git a/database/L1A/L1A.sqlite b/database/L1A/L1A.sqlite index f31ebce..85860d4 100644 Binary files a/database/L1A/L1A.sqlite and b/database/L1A/L1A.sqlite differ diff --git a/database/L2/L2_Main.sqlite b/database/L2/L2_Main.sqlite index 6f58450..125dcf0 100644 Binary files a/database/L2/L2_Main.sqlite and b/database/L2/L2_Main.sqlite differ diff --git a/database/L3/L3_Features.sqlite b/database/L3/L3_Features.sqlite index 03604a1..61798b6 100644 Binary files a/database/L3/L3_Features.sqlite and b/database/L3/L3_Features.sqlite differ diff --git a/database/L3/schema.sql b/database/L3/schema.sql index 458f5ed..095f133 100644 --- a/database/L3/schema.sql +++ b/database/L3/schema.sql @@ -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, diff --git a/database/Web/Web_App.sqlite b/database/Web/Web_App.sqlite new file mode 100644 index 0000000..0921300 Binary files /dev/null and b/database/Web/Web_App.sqlite differ diff --git a/l3.db b/l3.db new file mode 100644 index 0000000..e69de29 diff --git a/scripts/debug_db.py b/scripts/debug_db.py new file mode 100644 index 0000000..d3c41a5 --- /dev/null +++ b/scripts/debug_db.py @@ -0,0 +1,65 @@ +import sqlite3 +import os + +# Define database paths +BASE_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), '..')) +L2_PATH = os.path.join(BASE_DIR, 'database', 'L2', 'L2_Main.sqlite') + +def check_l2_tables(): + print(f"Checking L2 database at: {L2_PATH}") + if not os.path.exists(L2_PATH): + print("Error: L2 database not found!") + return + + conn = sqlite3.connect(L2_PATH) + cursor = conn.cursor() + + cursor.execute("SELECT name FROM sqlite_master WHERE type='table';") + tables = cursor.fetchall() + print("Tables in L2 Database:") + for table in tables: + print(f" - {table[0]}") + + conn.close() + +def debug_player_query(player_name_query=None): + print(f"\nDebugging Player Query (L2)...") + conn = sqlite3.connect(L2_PATH) + cursor = conn.cursor() + + try: + # Check if 'dim_players' exists + cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='dim_players';") + if not cursor.fetchone(): + print("Error: 'dim_players' table not found!") + return + + # Check schema of dim_players + print("\nChecking dim_players schema:") + cursor.execute("PRAGMA table_info(dim_players)") + for col in cursor.fetchall(): + print(col) + + # Check sample data + print("\nSampling dim_players (first 5):") + cursor.execute("SELECT * FROM dim_players LIMIT 5") + for row in cursor.fetchall(): + print(row) + + # Test Search + search_term = 'zy' + print(f"\nTesting search for '{search_term}':") + cursor.execute("SELECT * FROM dim_players WHERE name LIKE ?", (f'%{search_term}%',)) + results = cursor.fetchall() + print(f"Found {len(results)} matches.") + for r in results: + print(r) + + except Exception as e: + print(f"Error querying L2: {e}") + finally: + conn.close() + +if __name__ == '__main__': + check_l2_tables() + debug_player_query() diff --git a/scripts/debug_integrity.py b/scripts/debug_integrity.py new file mode 100644 index 0000000..a8e0ac6 --- /dev/null +++ b/scripts/debug_integrity.py @@ -0,0 +1,34 @@ +import sqlite3 +import os + +BASE_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), '..')) +L2_PATH = os.path.join(BASE_DIR, 'database', 'L2', 'L2_Main.sqlite') + +def check_db_integrity(): + print(f"Checking DB at: {L2_PATH}") + if not os.path.exists(L2_PATH): + print("CRITICAL: Database file does not exist!") + return + + try: + conn = sqlite3.connect(L2_PATH) + cursor = conn.cursor() + + # Check integrity + print("Running PRAGMA integrity_check...") + cursor.execute("PRAGMA integrity_check") + print(f"Integrity: {cursor.fetchone()}") + + # Check specific user again + cursor.execute("SELECT steam_id_64, username FROM dim_players WHERE username LIKE '%jacky%'") + rows = cursor.fetchall() + print(f"Direct DB check found {len(rows)} rows matching '%jacky%':") + for r in rows: + print(r) + + conn.close() + except Exception as e: + print(f"DB Error: {e}") + +if __name__ == '__main__': + check_db_integrity() diff --git a/scripts/debug_jacky.py b/scripts/debug_jacky.py new file mode 100644 index 0000000..2b20453 --- /dev/null +++ b/scripts/debug_jacky.py @@ -0,0 +1,39 @@ +import sqlite3 +import os + +BASE_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), '..')) +L2_PATH = os.path.join(BASE_DIR, 'database', 'L2', 'L2_Main.sqlite') + +def check_jacky(): + print(f"Checking L2 database at: {L2_PATH}") + conn = sqlite3.connect(L2_PATH) + cursor = conn.cursor() + + search_term = 'jacky' + print(f"\nSearching for '%{search_term}%' (Case Insensitive test):") + + # Standard LIKE + cursor.execute("SELECT steam_id_64, username FROM dim_players WHERE username LIKE ?", (f'%{search_term}%',)) + results = cursor.fetchall() + print(f"LIKE results: {len(results)}") + for r in results: + print(r) + + # Case insensitive explicit + print("\nSearching with LOWER():") + cursor.execute("SELECT steam_id_64, username FROM dim_players WHERE LOWER(username) LIKE LOWER(?)", (f'%{search_term}%',)) + results_lower = cursor.fetchall() + print(f"LOWER() results: {len(results_lower)}") + for r in results_lower: + print(r) + + # Check jacky0987 specifically + print("\nChecking specific username 'jacky0987':") + cursor.execute("SELECT steam_id_64, username FROM dim_players WHERE username = 'jacky0987'") + specific = cursor.fetchone() + print(f"Specific match: {specific}") + + conn.close() + +if __name__ == '__main__': + check_jacky() diff --git a/scripts/init_web_db.py b/scripts/init_web_db.py new file mode 100644 index 0000000..2566d00 --- /dev/null +++ b/scripts/init_web_db.py @@ -0,0 +1,84 @@ +import sqlite3 +import os + +# Define database path +BASE_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), '..')) +DB_PATH = os.path.join(BASE_DIR, 'database', 'Web', 'Web_App.sqlite') + +def init_db(): + print(f"Initializing Web database at: {DB_PATH}") + + # Create directory if not exists + os.makedirs(os.path.dirname(DB_PATH), exist_ok=True) + + conn = sqlite3.connect(DB_PATH) + cursor = conn.cursor() + + # Create Tables + tables = [ + """ + CREATE TABLE IF NOT EXISTS team_lineups ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + description TEXT, + player_ids_json TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + """, + """ + CREATE TABLE IF NOT EXISTS player_metadata ( + steam_id_64 TEXT PRIMARY KEY, + notes TEXT, + tags TEXT, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + """, + """ + CREATE TABLE IF NOT EXISTS strategy_boards ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT, + map_name TEXT, + data_json TEXT, + created_by TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + """, + """ + CREATE TABLE IF NOT EXISTS wiki_pages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + path TEXT UNIQUE, + title TEXT, + content TEXT, + updated_by TEXT, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + """, + """ + CREATE TABLE IF NOT EXISTS comments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT, + username TEXT, + target_type TEXT, + target_id TEXT, + content TEXT, + likes INTEGER DEFAULT 0, + is_hidden INTEGER DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + """ + ] + + for sql in tables: + try: + cursor.execute(sql) + print("Executed SQL successfully.") + except Exception as e: + print(f"Error executing SQL: {e}") + + conn.commit() + conn.close() + print("Web database initialized successfully.") + +if __name__ == '__main__': + init_db() diff --git a/web/app.py b/web/app.py new file mode 100644 index 0000000..c8142b2 --- /dev/null +++ b/web/app.py @@ -0,0 +1,35 @@ +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 + 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.route('/') + def index(): + return render_template('home/index.html') + + return app + +if __name__ == '__main__': + app = create_app() + app.run(debug=True, port=5000) diff --git a/web/auth.py b/web/auth.py new file mode 100644 index 0000000..f30a982 --- /dev/null +++ b/web/auth.py @@ -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 diff --git a/web/config.py b/web/config.py new file mode 100644 index 0000000..bf288a2 --- /dev/null +++ b/web/config.py @@ -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 diff --git a/web/database.py b/web/database.py new file mode 100644 index 0000000..5982ab1 --- /dev/null +++ b/web/database.py @@ -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 diff --git a/web/routes/admin.py b/web/routes/admin.py new file mode 100644 index 0000000..ba9f47a --- /dev/null +++ b/web/routes/admin.py @@ -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) diff --git a/web/routes/main.py b/web/routes/main.py new file mode 100644 index 0000000..5384865 --- /dev/null +++ b/web/routes/main.py @@ -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}'}) diff --git a/web/routes/matches.py b/web/routes/matches.py new file mode 100644 index 0000000..e1f2819 --- /dev/null +++ b/web/routes/matches.py @@ -0,0 +1,48 @@ +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') + + 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) + +@bp.route('/') +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) + rounds = StatsService.get_match_rounds(match_id) + + # Organize players by team + team1_players = [p for p in players if p['team_id'] == 1] + team2_players = [p for p in players if p['team_id'] == 2] + + return render_template('matches/detail.html', match=match, + team1_players=team1_players, team2_players=team2_players, + rounds=rounds) + +@bp.route('//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') diff --git a/web/routes/players.py b/web/routes/players.py new file mode 100644 index 0000000..fa28fa6 --- /dev/null +++ b/web/routes/players.py @@ -0,0 +1,198 @@ +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 +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('/', 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) + # Ensure basic stats fallback if features missing or incomplete + basic = StatsService.get_player_basic_stats(steam_id) + + if not features: + if basic: + features = { + '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), # Pass ADR + } + else: + # If features exist but ADR is missing (not in L3), try to patch it from basic + if 'basic_avg_adr' not in features: + features = dict(features) # Convert to dict if row + 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) + + # 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 [] + + return render_template('players/profile.html', player=player, features=features, comments=comments, metadata=metadata, history=history) + +@bp.route('/comment//like', methods=['POST']) +def like_comment(comment_id): + WebService.like_comment(comment_id) + return jsonify({'success': True}) + +@bp.route('//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 = {} + if features: + # Dimensions: STA, BAT, HPS, PTL, T/CT, UTIL + radar_data = { + 'STA': features['basic_avg_rating'] or 0, + 'BAT': features['bat_avg_duel_win_rate'] or 0, + 'HPS': features['hps_clutch_win_rate_1v1'] or 0, + 'PTL': features['ptl_pistol_win_rate'] or 0, + 'SIDE': features['side_rating_ct'] or 0, + 'UTIL': features['util_usage_rate'] or 0 + } + + trend_labels = [] + trend_values = [] + for t in trends: + dt = datetime.fromtimestamp(t['start_time']) if t['start_time'] else datetime.now() + trend_labels.append(dt.strftime('%Y-%m-%d')) + trend_values.append(t['rating']) + + return jsonify({ + 'trend': {'labels': trend_labels, 'values': trend_values}, + 'radar': radar_data + }) + +# --- 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: + stats.append({ + 'username': p['username'], + 'steam_id': sid, + 'radar': { + 'STA': f['basic_avg_rating'] or 0, + 'BAT': f['bat_avg_duel_win_rate'] or 0, + 'HPS': f['hps_clutch_win_rate_1v1'] or 0, + 'PTL': f['ptl_pistol_win_rate'] or 0, + 'SIDE': f['side_rating_ct'] or 0, + 'UTIL': f['util_usage_rate'] or 0 + } + }) + return jsonify(stats) diff --git a/web/routes/tactics.py b/web/routes/tactics.py new file mode 100644 index 0000000..6cc91e7 --- /dev/null +++ b/web/routes/tactics.py @@ -0,0 +1,73 @@ +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) + + # 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 + } + + return jsonify({ + 'players': player_data, + 'shared_matches': [dict(m) for m in shared_matches], + 'avg_stats': avg_stats + }) + +# 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'}) diff --git a/web/routes/teams.py b/web/routes/teams.py new file mode 100644 index 0000000..58f7309 --- /dev/null +++ b/web/routes/teams.py @@ -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('/') +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) diff --git a/web/routes/wiki.py b/web/routes/wiki.py new file mode 100644 index 0000000..ca06f1e --- /dev/null +++ b/web/routes/wiki.py @@ -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/') +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/', 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) diff --git a/web/services/etl_service.py b/web/services/etl_service.py new file mode 100644 index 0000000..fdec001 --- /dev/null +++ b/web/services/etl_service.py @@ -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) diff --git a/web/services/feature_service.py b/web/services/feature_service.py new file mode 100644 index 0000000..5db6554 --- /dev/null +++ b/web/services/feature_service.py @@ -0,0 +1,256 @@ +from web.database import query_db + +class FeatureService: + @staticmethod + def get_player_features(steam_id): + sql = "SELECT * FROM dm_player_features WHERE steam_id_64 = ?" + return query_db('l3', sql, [steam_id], one=True) + + @staticmethod + def get_players_list(page=1, per_page=20, sort_by='rating', search=None): + offset = (page - 1) * per_page + + # Sort Mapping + sort_map = { + 'rating': 'basic_avg_rating', + 'kd': 'basic_avg_kd', + 'kast': 'basic_avg_kast', + 'matches': 'matches_played' + } + order_col = sort_map.get(sort_by, 'basic_avg_rating') + + from web.services.stats_service import StatsService + + # Helper to attach match counts + def attach_match_counts(player_list): + if not player_list: + return + ids = [p['steam_id_64'] for p in player_list] + # Batch query for counts from L2 + placeholders = ','.join('?' for _ in 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, ids) + cnt_dict = {r['steam_id_64']: r['cnt'] for r in counts} + for p in player_list: + p['matches_played'] = cnt_dict.get(p['steam_id_64'], 0) + + if search: + # ... existing search logic ... + # Get all matching players + l2_players, _ = StatsService.get_players(page=1, per_page=100, search=search) + if not l2_players: + return [], 0 + + # ... (Merge logic) ... + # I need to insert the match count logic inside the merge loop or after + + steam_ids = [p['steam_id_64'] for p in l2_players] + placeholders = ','.join('?' for _ in steam_ids) + sql = f"SELECT * FROM dm_player_features WHERE steam_id_64 IN ({placeholders})" + features = query_db('l3', sql, steam_ids) + f_dict = {f['steam_id_64']: f for f in features} + + # Get counts for sorting + count_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', count_sql, steam_ids) + cnt_dict = {r['steam_id_64']: r['cnt'] for r in counts} + + merged = [] + for p in l2_players: + f = f_dict.get(p['steam_id_64']) + m = dict(p) + if f: + m.update(dict(f)) + else: + # Fallback Calc + stats = StatsService.get_player_basic_stats(p['steam_id_64']) + if stats: + m['basic_avg_rating'] = stats['rating'] + m['basic_avg_kd'] = stats['kd'] + m['basic_avg_kast'] = stats['kast'] + else: + m['basic_avg_rating'] = 0 + m['basic_avg_kd'] = 0 + m['basic_avg_kast'] = 0 # Ensure kast exists + + m['matches_played'] = cnt_dict.get(p['steam_id_64'], 0) + merged.append(m) + + merged.sort(key=lambda x: x.get(order_col, 0) or 0, reverse=True) + + total = len(merged) + start = (page - 1) * per_page + end = start + per_page + return merged[start:end], total + + else: + # Browse mode + # Check L3 + l3_count = query_db('l3', "SELECT COUNT(*) as cnt FROM dm_player_features", one=True)['cnt'] + + if l3_count == 0 or sort_by == 'matches': + # If sorting by matches, we MUST use L2 counts because L3 might not have it or we want dynamic. + # OR if L3 is empty. + # Since L3 schema is unknown regarding 'matches_played', let's assume we fallback to L2 logic + # but paginated in memory if dataset is small, or just fetch all L2 players? + # Fetching all L2 players is bad if many. + # But for 'matches' sort, we need to know counts for ALL to sort correctly. + # Solution: Query L2 for top N players by match count. + + if sort_by == 'matches': + # Query L2 for IDs ordered by count + sql = """ + SELECT steam_id_64, COUNT(*) as cnt + FROM fact_match_players + GROUP BY steam_id_64 + ORDER BY cnt DESC + LIMIT ? OFFSET ? + """ + top_ids = query_db('l2', sql, [per_page, offset]) + if not top_ids: + return [], 0 + + total = query_db('l2', "SELECT COUNT(DISTINCT steam_id_64) as cnt FROM fact_match_players", one=True)['cnt'] + + ids = [r['steam_id_64'] for r in top_ids] + # Fetch details for these IDs + l2_players = StatsService.get_players_by_ids(ids) + + # Merge logic (reuse) + merged = [] + # Fetch L3 features for these IDs to show stats + p_ph = ','.join('?' for _ in ids) + f_sql = f"SELECT * FROM dm_player_features WHERE steam_id_64 IN ({p_ph})" + features = query_db('l3', f_sql, ids) + f_dict = {f['steam_id_64']: f for f in features} + + cnt_dict = {r['steam_id_64']: r['cnt'] for r in top_ids} + + # Map L2 players to dict for easy access (though list order matters for sort?) + # Actually top_ids is sorted. + p_dict = {p['steam_id_64']: p for p in l2_players} + + for r in top_ids: # Preserve order + sid = r['steam_id_64'] + p = p_dict.get(sid) + if not p: continue + + m = dict(p) + f = f_dict.get(sid) + if f: + m.update(dict(f)) + else: + stats = StatsService.get_player_basic_stats(sid) + if stats: + m['basic_avg_rating'] = stats['rating'] + m['basic_avg_kd'] = stats['kd'] + m['basic_avg_kast'] = stats['kast'] + else: + m['basic_avg_rating'] = 0 + m['basic_avg_kd'] = 0 + m['basic_avg_kast'] = 0 + + m['matches_played'] = r['cnt'] + merged.append(m) + + return merged, total + + # L3 empty fallback (existing logic) + l2_players, total = StatsService.get_players(page, per_page, sort_by=None) + merged = [] + attach_match_counts(l2_players) # Helper + + for p in l2_players: + m = dict(p) + stats = StatsService.get_player_basic_stats(p['steam_id_64']) + if stats: + m['basic_avg_rating'] = stats['rating'] + m['basic_avg_kd'] = stats['kd'] + m['basic_avg_kast'] = stats['kast'] + else: + m['basic_avg_rating'] = 0 + m['basic_avg_kd'] = 0 + m['basic_avg_kast'] = 0 + m['matches_played'] = p.get('matches_played', 0) + merged.append(m) + + if sort_by != 'rating': + merged.sort(key=lambda x: x.get(order_col, 0) or 0, reverse=True) + + return merged, total + + # Normal L3 browse (sort by rating/kd/kast) + sql = f"SELECT * FROM dm_player_features ORDER BY {order_col} DESC LIMIT ? OFFSET ?" + features = query_db('l3', sql, [per_page, offset]) + + total = query_db('l3', "SELECT COUNT(*) as cnt FROM dm_player_features", one=True)['cnt'] + + if not features: + return [], total + + steam_ids = [f['steam_id_64'] for f in features] + l2_players = StatsService.get_players_by_ids(steam_ids) + p_dict = {p['steam_id_64']: p for p in l2_players} + + merged = [] + for f in features: + m = dict(f) + p = p_dict.get(f['steam_id_64']) + if p: + m.update(dict(p)) + else: + m['username'] = f['steam_id_64'] # Fallback + m['avatar_url'] = None + merged.append(m) + + return merged, total + + @staticmethod + def get_top_players(limit=20, sort_by='basic_avg_rating'): + # Safety check for sort_by to prevent injection + allowed_sorts = ['basic_avg_rating', 'basic_avg_kd', 'basic_avg_kast', 'basic_avg_rws'] + if sort_by not in allowed_sorts: + sort_by = 'basic_avg_rating' + + sql = f""" + SELECT f.*, p.username, p.avatar_url + FROM dm_player_features f + LEFT JOIN l2.dim_players p ON f.steam_id_64 = p.steam_id_64 + ORDER BY {sort_by} DESC + LIMIT ? + """ + # Note: Cross-database join (l2.dim_players) works in SQLite if attached. + # But `query_db` connects to one DB. + # Strategy: Fetch features, then fetch player infos from L2. Or attach DB. + # Simple strategy: Fetch features, then extract steam_ids and batch fetch from L2 in StatsService. + # Or simpler: Just return features and let the controller/template handle the name/avatar via another call or pre-fetching. + + # Actually, for "Player List" view, we really want L3 data joined with L2 names. + # I will change this to just return features for now, and handle joining in the route handler or via a helper that attaches databases. + # Attaching is better. + + return query_db('l3', f"SELECT * FROM dm_player_features ORDER BY {sort_by} DESC LIMIT ?", [limit]) + + @staticmethod + def get_player_trend(steam_id, limit=30): + # This requires `fact_match_features` or querying L2 matches for historical data. + # WebRDD says: "Trend graph: Recent 10/20 matches Rating trend (Chart.js)." + # We can get this from L2 fact_match_players. + sql = """ + SELECT m.start_time, mp.rating, mp.kd_ratio, mp.adr, m.match_id + FROM fact_match_players mp + JOIN fact_matches m ON mp.match_id = m.match_id + WHERE mp.steam_id_64 = ? + ORDER BY m.start_time DESC + LIMIT ? + """ + # This query needs to run against L2. + # So this method should actually be in StatsService or FeatureService connecting to L2. + # I will put it here but note it uses L2. Actually, better to put in StatsService if it uses L2 tables. + # But FeatureService conceptualizes "Trends". I'll move it to StatsService for implementation correctness (DB context). + pass diff --git a/web/services/stats_service.py b/web/services/stats_service.py new file mode 100644 index 0000000..fed3f2b --- /dev/null +++ b/web/services/stats_service.py @@ -0,0 +1,245 @@ +from web.database import query_db + +class StatsService: + @staticmethod + def get_recent_matches(limit=5): + sql = """ + SELECT m.match_id, m.start_time, m.map_name, m.score_team1, m.score_team2, m.winner_team, + p.username as mvp_name + FROM fact_matches m + LEFT JOIN dim_players p ON m.mvp_uid = p.uid + ORDER BY m.start_time DESC + LIMIT ? + """ + return query_db('l2', sql, [limit]) + + @staticmethod + def get_matches(page=1, per_page=20, map_name=None, date_from=None, date_to=None): + offset = (page - 1) * per_page + args = [] + where_clauses = ["1=1"] + + if map_name: + where_clauses.append("map_name = ?") + args.append(map_name) + + if date_from: + where_clauses.append("start_time >= ?") + args.append(date_from) + + if date_to: + where_clauses.append("start_time <= ?") + args.append(date_to) + + where_str = " AND ".join(where_clauses) + + sql = f""" + SELECT m.match_id, m.start_time, m.map_name, m.score_team1, m.score_team2, m.winner_team, m.duration + FROM fact_matches m + WHERE {where_str} + ORDER BY m.start_time DESC + LIMIT ? OFFSET ? + """ + args.extend([per_page, offset]) + + matches = query_db('l2', sql, args) + + # Count total for pagination + count_sql = f"SELECT COUNT(*) as cnt FROM fact_matches WHERE {where_str}" + total = query_db('l2', count_sql, args[:-2], one=True)['cnt'] + + return matches, total + + @staticmethod + def get_match_detail(match_id): + sql = "SELECT * FROM fact_matches WHERE match_id = ?" + return query_db('l2', sql, [match_id], one=True) + + @staticmethod + def get_match_players(match_id): + sql = """ + SELECT mp.*, p.username, p.avatar_url + FROM fact_match_players mp + LEFT JOIN dim_players p ON mp.steam_id_64 = p.steam_id_64 + WHERE mp.match_id = ? + ORDER BY mp.team_id, mp.rating DESC + """ + return query_db('l2', sql, [match_id]) + + @staticmethod + def get_match_rounds(match_id): + sql = "SELECT * FROM fact_rounds WHERE match_id = ? ORDER BY round_num" + return query_db('l2', sql, [match_id]) + + @staticmethod + def get_players(page=1, per_page=20, search=None, sort_by='rating_desc'): + offset = (page - 1) * per_page + args = [] + where_clauses = ["1=1"] + + if search: + # Force case-insensitive search + where_clauses.append("(LOWER(username) LIKE LOWER(?) OR steam_id_64 LIKE ?)") + args.append(f"%{search}%") + args.append(f"%{search}%") + + where_str = " AND ".join(where_clauses) + + # Sort mapping + order_clause = "rating DESC" # Default logic (this query needs refinement as L2 dim_players doesn't store avg rating) + # Wait, dim_players only has static info. We need aggregated stats. + # Ideally, we should fetch from L3 for player list stats. + # But StatsService is for L2. + # For the Player List, we usually want L3 data (Career stats). + # I will leave the detailed stats logic for FeatureService or do a join here if necessary. + # For now, just listing players from dim_players. + + sql = f""" + SELECT * FROM dim_players + WHERE {where_str} + LIMIT ? OFFSET ? + """ + args.extend([per_page, offset]) + + players = query_db('l2', sql, args) + total = query_db('l2', f"SELECT COUNT(*) as cnt FROM dim_players WHERE {where_str}", args[:-2], one=True)['cnt'] + + return players, total + + @staticmethod + def get_player_info(steam_id): + sql = "SELECT * FROM dim_players WHERE steam_id_64 = ?" + return query_db('l2', sql, [steam_id], one=True) + + @staticmethod + def get_daily_match_counts(days=365): + # Return list of {date: 'YYYY-MM-DD', count: N} + sql = """ + SELECT date(start_time, 'unixepoch') as day, COUNT(*) as count + FROM fact_matches + WHERE start_time > strftime('%s', 'now', ?) + GROUP BY day + ORDER BY day + """ + # sqlite modifier for 'now' needs format like '-365 days' + modifier = f'-{days} days' + rows = query_db('l2', sql, [modifier]) + return rows + + @staticmethod + def get_players_by_ids(steam_ids): + if not steam_ids: + return [] + placeholders = ','.join('?' for _ in steam_ids) + sql = f"SELECT * FROM dim_players WHERE steam_id_64 IN ({placeholders})" + return query_db('l2', sql, steam_ids) + + @staticmethod + def get_player_basic_stats(steam_id): + # Calculate stats from fact_match_players + # Prefer calculating from sums (kills/deaths) for K/D accuracy + # AVG(adr) is used as damage_total might be missing in some sources + sql = """ + SELECT + AVG(rating) as rating, + SUM(kills) as total_kills, + SUM(deaths) as total_deaths, + AVG(kd_ratio) as avg_kd, + AVG(kast) as kast, + AVG(adr) as adr, + COUNT(*) as matches_played + FROM fact_match_players + WHERE steam_id_64 = ? + """ + row = query_db('l2', sql, [steam_id], one=True) + + if row and row['matches_played'] > 0: + res = dict(row) + + # Calculate K/D: Sum Kills / Sum Deaths + kills = res.get('total_kills') or 0 + deaths = res.get('total_deaths') or 0 + + if deaths > 0: + res['kd'] = kills / deaths + else: + res['kd'] = kills # If 0 deaths, K/D is kills (or infinity, but kills is safer for display) + + # Fallback to avg_kd if calculation failed (e.g. both 0) but avg_kd exists + if res['kd'] == 0 and res['avg_kd'] and res['avg_kd'] > 0: + res['kd'] = res['avg_kd'] + + # ADR validation + if res['adr'] is None: + res['adr'] = 0.0 + + return res + return None + + @staticmethod + def get_shared_matches(steam_ids): + # Find matches where ALL steam_ids were present in the SAME team (or just present?) + # "共同经历" usually means played together. + # Query: Intersect match_ids for each player. + # SQLite doesn't have INTERSECT ALL easily for dynamic list, but we can group by match_id. + + if not steam_ids or len(steam_ids) < 2: + return [] + + placeholders = ','.join('?' for _ in steam_ids) + count = len(steam_ids) + + sql = f""" + SELECT m.match_id, m.start_time, m.map_name, m.score_team1, m.score_team2, m.winner_team + FROM fact_matches m + JOIN fact_match_players mp ON m.match_id = mp.match_id + WHERE mp.steam_id_64 IN ({placeholders}) + GROUP BY m.match_id + HAVING COUNT(DISTINCT mp.steam_id_64) = ? + ORDER BY m.start_time DESC + LIMIT 20 + """ + + args = list(steam_ids) + args.append(count) + + return query_db('l2', sql, args) + + @staticmethod + def get_player_trend(steam_id, limit=20): + sql = """ + SELECT m.start_time, mp.rating, mp.kd_ratio, mp.adr, m.match_id, m.map_name + FROM fact_match_players mp + JOIN fact_matches m ON mp.match_id = m.match_id + WHERE mp.steam_id_64 = ? + ORDER BY m.start_time ASC + """ + # We fetch all then slice last 'limit' in python or use subquery. + # DESC LIMIT gets recent, but we want chronological for chart. + # So: SELECT ... ORDER BY time DESC LIMIT ? -> then reverse in code. + + sql = """ + SELECT * FROM ( + SELECT m.start_time, mp.rating, mp.kd_ratio, mp.adr, m.match_id, m.map_name, mp.is_win + FROM fact_match_players mp + JOIN fact_matches m ON mp.match_id = m.match_id + WHERE mp.steam_id_64 = ? + ORDER BY m.start_time DESC + LIMIT ? + ) ORDER BY start_time ASC + """ + return query_db('l2', sql, [steam_id, limit]) + + @staticmethod + def get_live_matches(): + # Query matches started in last 2 hours with no winner + # Assuming we have a way to ingest live matches. + # For now, this query is 'formal' but will likely return empty on static dataset. + sql = """ + SELECT m.match_id, m.map_name, m.score_team1, m.score_team2, m.start_time + FROM fact_matches m + WHERE m.winner_team IS NULL + AND m.start_time > strftime('%s', 'now', '-2 hours') + """ + return query_db('l2', sql) + diff --git a/web/services/web_service.py b/web/services/web_service.py new file mode 100644 index 0000000..590f456 --- /dev/null +++ b/web/services/web_service.py @@ -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]) diff --git a/web/templates/admin/dashboard.html b/web/templates/admin/dashboard.html new file mode 100644 index 0000000..0959fba --- /dev/null +++ b/web/templates/admin/dashboard.html @@ -0,0 +1,54 @@ +{% extends "base.html" %} + +{% block content %} +
+
+

管理后台 (Admin Dashboard)

+ Logout +
+ +
+ +
+

数据管线 (ETL)

+
+ + + +
+
+
+ + +
+

工具箱

+ +
+
+
+ + +{% endblock %} diff --git a/web/templates/admin/login.html b/web/templates/admin/login.html new file mode 100644 index 0000000..0a9a6dc --- /dev/null +++ b/web/templates/admin/login.html @@ -0,0 +1,38 @@ +{% extends "base.html" %} + +{% block content %} +
+
+
+

+ Admin Login +

+
+ + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} + + {% endfor %} + {% endif %} + {% endwith %} + +
+
+
+ + +
+
+ +
+ +
+
+
+
+{% endblock %} diff --git a/web/templates/admin/sql.html b/web/templates/admin/sql.html new file mode 100644 index 0000000..eeb0a9e --- /dev/null +++ b/web/templates/admin/sql.html @@ -0,0 +1,52 @@ +{% extends "base.html" %} + +{% block content %} +
+

SQL Runner

+ +
+
+ + +
+
+ + +
+ +
+ + {% if error %} +
+ {{ error }} +
+ {% endif %} + + {% if result %} +
+ + + + {% for col in result.columns %} + + {% endfor %} + + + + {% for row in result.rows %} + + {% for col in result.columns %} + + {% endfor %} + + {% endfor %} + +
{{ col }}
{{ row[col] }}
+
+ {% endif %} +
+{% endblock %} diff --git a/web/templates/base.html b/web/templates/base.html new file mode 100644 index 0000000..ce1f466 --- /dev/null +++ b/web/templates/base.html @@ -0,0 +1,156 @@ + + + + + + {% block title %}YRTV - CS2 Data Platform{% endblock %} + + + + + + {% block head %}{% endblock %} + + + + + + + +
+ {% block content %}{% endblock %} +
+ + +
+
+

© 2026 YRTV CS2 Data Platform. All rights reserved.

+
+
+ + {% block scripts %}{% endblock %} + + + + diff --git a/web/templates/home/index.html b/web/templates/home/index.html new file mode 100644 index 0000000..fa7a2f2 --- /dev/null +++ b/web/templates/home/index.html @@ -0,0 +1,195 @@ +{% extends "base.html" %} + +{% block content %} +
+ +
+
+

+ JKTV CS2 队伍数据洞察平台 +

+

+ 深度挖掘比赛数据,提供战术研判、阵容模拟与多维能力分析。 +

+ + + +
+
+ + + +
+

+
+
+
+ + +
+ +
+

活跃度 (Activity)

+
+
+ +
+
+
+ Less + + + + + + More +
+
+ + +
+
+

正在进行 (Live)

+ + Online + +
+
+ {% if live_matches %} +
    + {% for m in live_matches %} +
  • + {{ m.map_name }}: {{ m.score_team1 }} - {{ m.score_team2 }} +
  • + {% endfor %} +
+ {% else %} +

暂无正在进行的比赛

+ {% endif %} +
+
+ + +
+

近期战况

+
+
    + {% for match in recent_matches %} +
  • +
    +
    +

    + {{ match.map_name }} +

    +

    + {{ match.start_time | default('Unknown Date') }} +

    +
    +
    + {{ match.score_team1 }} : {{ match.score_team2 }} +
    +
    + 详情 +
    +
    +
  • + {% else %} +
  • 暂无比赛数据
  • + {% endfor %} +
+
+
+
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/web/templates/matches/detail.html b/web/templates/matches/detail.html new file mode 100644 index 0000000..4731782 --- /dev/null +++ b/web/templates/matches/detail.html @@ -0,0 +1,125 @@ +{% extends "base.html" %} + +{% block content %} +
+ +
+
+
+

{{ match.map_name }}

+

Match ID: {{ match.match_id }} | {{ match.start_time }}

+
+
+
+ {{ match.score_team1 }} + : + {{ match.score_team2 }} +
+
+ +
+
+ + +
+
+

Team 1

+
+
+ + + + + + + + + + + + + + + {% for p in team1_players %} + + + + + + + + + + + {% endfor %} + +
PlayerKDA+/-ADRKASTRating
+ + {{ p.kills }}{{ p.deaths }}{{ p.assists }} + {{ p.kills - p.deaths }} + {{ "%.1f"|format(p.adr or 0) }}{{ "%.1f"|format(p.kast or 0) }}%{{ "%.2f"|format(p.rating or 0) }}
+
+
+ + +
+
+

Team 2

+
+
+ + + + + + + + + + + + + + + {% for p in team2_players %} + + + + + + + + + + + {% endfor %} + +
PlayerKDA+/-ADRKASTRating
+ + {{ p.kills }}{{ p.deaths }}{{ p.assists }} + {{ p.kills - p.deaths }} + {{ "%.1f"|format(p.adr or 0) }}{{ "%.1f"|format(p.kast or 0) }}%{{ "%.2f"|format(p.rating or 0) }}
+
+
+
+{% endblock %} diff --git a/web/templates/matches/list.html b/web/templates/matches/list.html new file mode 100644 index 0000000..a7a1f09 --- /dev/null +++ b/web/templates/matches/list.html @@ -0,0 +1,69 @@ +{% extends "base.html" %} + +{% block content %} +
+
+

比赛列表

+ +
+ +
+
+ +
+ + + + + + + + + + + + {% for match in matches %} + + + + + + + + {% endfor %} + +
时间地图比分时长操作
+ + + {{ match.map_name }} + + + {{ match.score_team1 }} + + - + + {{ match.score_team2 }} + + + {{ (match.duration / 60) | int }} min + + 详情 +
+
+ + +
+
+ Total {{ total }} matches +
+
+ {% if page > 1 %} + Prev + {% endif %} + {% if page < total_pages %} + Next + {% endif %} +
+
+
+{% endblock %} diff --git a/web/templates/players/list.html b/web/templates/players/list.html new file mode 100644 index 0000000..beb8c69 --- /dev/null +++ b/web/templates/players/list.html @@ -0,0 +1,74 @@ +{% extends "base.html" %} + +{% block content %} +
+
+

玩家列表

+
+ +
+ +
+ +
+ + + +
+
+
+ +
+ {% for player in players %} +
+ +
+ {{ player.username[:2] | upper if player.username else '??' }} +
+

{{ player.username }}

+

{{ player.steam_id_64 }}

+ + +
+
+ {{ "%.2f"|format(player.basic_avg_rating|default(0)) }} + Rating +
+
+ {{ "%.2f"|format(player.basic_avg_kd|default(0)) }} + K/D +
+
+ {{ "%.1f"|format((player.basic_avg_kast|default(0)) * 100) }}% + KAST +
+
+ + + View Profile + +
+ {% endfor %} +
+ + +
+
+ Total {{ total }} players +
+
+ {% if page > 1 %} + Prev + {% endif %} + {% if page < total_pages %} + Next + {% endif %} +
+
+
+{% endblock %} diff --git a/web/templates/players/profile.html b/web/templates/players/profile.html new file mode 100644 index 0000000..b40306b --- /dev/null +++ b/web/templates/players/profile.html @@ -0,0 +1,309 @@ +{% extends "base.html" %} + +{% block content %} +
+ +
+
+
+
+ + {% if player.avatar_url %} + + {% else %} +
+ {{ player.username[:2] | upper if player.username else '??' }} +
+ {% endif %} + + {% if session.get('is_admin') %} + + {% endif %} +
+
+

{{ player.username }}

+

{{ player.steam_id_64 }}

+
+ + {{ player.uid }} + + + {% for tag in metadata.tags %} + + {{ tag }} + {% if session.get('is_admin') %} +
+ + + +
+ {% endif %} +
+ {% endfor %} + + {% if session.get('is_admin') %} +
+ + + +
+ {% endif %} +
+ + {% if metadata.notes %} +

"{{ metadata.notes }}"

+ {% endif %} +
+
+
+ {% if session.get('is_admin') %} + + {% endif %} +
+
+
+ + + + + + + +
+
+

+ 📈近期 Rating 走势 (Trend) +

+
+
+ +
+
+ + +
+ +
+
+
Rating
+
{{ "%.2f"|format((features.basic_avg_rating if features else 0) or 0) }}
+
+
+
K/D
+
{{ "%.2f"|format((features.basic_avg_kd if features else 0) or 0) }}
+
+
+
ADR
+
{{ "%.1f"|format((features.basic_avg_adr if features else 0) or 0) }}
+
+
+
KAST
+
{{ "%.1f"|format((features.basic_avg_kast if features else 0) * 100) }}%
+
+
+ + +
+

能力六维图

+
+ +
+
+ +
+

比赛记录 (History - {{ history|length }})

+
+ + + + + + + + + + + + + {% for m in history %} + + + + + + + + + {% else %} + + + + {% endfor %} + +
DateMapRatingK/DADRLink
+ + {{ m.map_name }}{{ "%.2f"|format(m.rating or 0) }}{{ "%.2f"|format(m.kd_ratio or 0) }}{{ "%.1f"|format(m.adr or 0) }} + View +
No recent matches found.
+
+
+
+

玩家评价 ({{ comments|length }})

+ + +
+
+ + +
+
+ + +
+ +
+ + +
+ {% for comment in comments %} +
+
+ + + + + +
+
+
+

{{ comment.username }}

+

{{ comment.created_at }}

+
+

{{ comment.content }}

+ +
+ +
+
+
+ {% else %} +

No comments yet. Be the first!

+ {% endfor %} +
+
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/web/templates/tactics/analysis.html b/web/templates/tactics/analysis.html new file mode 100644 index 0000000..97257ef --- /dev/null +++ b/web/templates/tactics/analysis.html @@ -0,0 +1,25 @@ +{% extends "tactics/layout.html" %} + +{% block title %}Deep Analysis - Tactics{% endblock %} + +{% block tactics_content %} +
+

Deep Analysis: Chemistry & Depth

+ +
+ +
+ +

Lineup Builder

+

Drag 5 players here to analyze chemistry.

+
+ + +
+ +

Synergy Matrix

+

Select lineup to view pair-wise win rates.

+
+
+
+{% endblock %} \ No newline at end of file diff --git a/web/templates/tactics/board.html b/web/templates/tactics/board.html new file mode 100644 index 0000000..29e42c3 --- /dev/null +++ b/web/templates/tactics/board.html @@ -0,0 +1,396 @@ +{% extends "base.html" %} + +{% block title %}Strategy Board - Tactics{% endblock %} + +{% block head %} + + + +{% endblock %} + +{% block content %} +
+ + +
+
+ ← Dashboard + Deep Analysis + Data Center + Strategy Board + Economy +
+
+ Real-time Sync: ● Active +
+
+ + +
+ + +
+ + +
+
+ +
+
+ + +
+
+ + +
+ + +
+

Roster

+
+ + + +
+
+ + +
+

+ On Board + +

+
    + +
+
+ + +
+

Synergy

+
+ +
+
+ +
+
+ + +
+
+ +
+ Drag players to map • Scroll to zoom +
+
+
+
+ + + + + + +{% endblock %} \ No newline at end of file diff --git a/web/templates/tactics/compare.html b/web/templates/tactics/compare.html new file mode 100644 index 0000000..f7acde7 --- /dev/null +++ b/web/templates/tactics/compare.html @@ -0,0 +1,161 @@ +{% extends "base.html" %} + +{% block content %} +
+
+

数据对比中心 (Data Center)

+ + +
+ + + +
+ + +
+ +
+ + +
+ +
+
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/web/templates/tactics/data.html b/web/templates/tactics/data.html new file mode 100644 index 0000000..46880b4 --- /dev/null +++ b/web/templates/tactics/data.html @@ -0,0 +1,22 @@ +{% extends "tactics/layout.html" %} + +{% block title %}Data Center - Tactics{% endblock %} + +{% block tactics_content %} +
+

Data Center: Comparison

+ +
+ +
+ + +
+ + +
+

Multi-player Radar Chart / Bar Chart Area

+
+
+
+{% endblock %} \ No newline at end of file diff --git a/web/templates/tactics/economy.html b/web/templates/tactics/economy.html new file mode 100644 index 0000000..d8ee7c0 --- /dev/null +++ b/web/templates/tactics/economy.html @@ -0,0 +1,65 @@ +{% extends "tactics/layout.html" %} + +{% block title %}Economy Calculator - Tactics{% endblock %} + +{% block tactics_content %} +
+

Economy Calculator

+ +
+ +
+

Current Round State

+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+ + +
+

Prediction

+ +
+
+ Team Money (Min) + $12,400 +
+
+ Team Money (Max) + $18,500 +
+ +
+ Recommendation + Full Buy +
+
+
+
+
+{% endblock %} \ No newline at end of file diff --git a/web/templates/tactics/index.html b/web/templates/tactics/index.html new file mode 100644 index 0000000..a059493 --- /dev/null +++ b/web/templates/tactics/index.html @@ -0,0 +1,471 @@ +{% extends "base.html" %} + +{% block title %}Tactics Center{% endblock %} + +{% block head %} + + + +{% endblock %} + +{% block content %} +
+ + +
+
+

队员列表 (Roster)

+

拖拽队员至右侧功能区

+
+ +
+ + + +
+
+ + +
+ + +
+ +
+ + +
+ + +
+

阵容化学反应分析

+ +
+ +
+

+ 阵容构建 (0/5) + +

+ +
+ + + + +
+ +
+ +
+
+ + +
+ + +
+
+
+ + +
+
+
📊
+

数据对比中心 (Construction)

+

此模块正在开发中...

+
+
+ + +
+ +
+
+ + + +
+
+ 在场人数: +
+
+ + +
+
+
+
+ + +
+

经济计算器 (Economy Calculator)

+
+
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
下回合收入预测
+
+
+
+
+
+
+ +
+
+
+ + + + + + +{% endblock %} \ No newline at end of file diff --git a/web/templates/tactics/layout.html b/web/templates/tactics/layout.html new file mode 100644 index 0000000..ff1f7c0 --- /dev/null +++ b/web/templates/tactics/layout.html @@ -0,0 +1,28 @@ +{% extends "base.html" %} + +{% block content %} +
+ + + + {% block tactics_content %}{% endblock %} +
+{% endblock %} \ No newline at end of file diff --git a/web/templates/tactics/maps.html b/web/templates/tactics/maps.html new file mode 100644 index 0000000..568efaa --- /dev/null +++ b/web/templates/tactics/maps.html @@ -0,0 +1,27 @@ +{% extends "base.html" %} + +{% block content %} +
+

地图情报

+ +
+ {% for map in maps %} +
+
+ + {{ map.title }} +
+
+

{{ map.title }}

+
+ + +
+
+
+ {% endfor %} +
+
+{% endblock %} diff --git a/web/templates/teams/clubhouse.html b/web/templates/teams/clubhouse.html new file mode 100644 index 0000000..d154bce --- /dev/null +++ b/web/templates/teams/clubhouse.html @@ -0,0 +1,221 @@ +{% extends "base.html" %} + +{% block title %}My Team - Clubhouse{% endblock %} + +{% block content %} +
+ +
+
+

+ + +

+
+
+ {% if session.get('is_admin') %} + + {% endif %} +
+
+ + +
+

Active Roster

+ +
+ + + + + {% if session.get('is_admin') %} +
+
+ +
+ Add Player +
+ {% endif %} +
+
+ + + + + + +
+ + +{% endblock %} diff --git a/web/templates/teams/create.html b/web/templates/teams/create.html new file mode 100644 index 0000000..54a1699 --- /dev/null +++ b/web/templates/teams/create.html @@ -0,0 +1,71 @@ +{% extends "base.html" %} + +{% block content %} +
+

新建战队阵容

+ +
+
+ + +
+ +
+ + +
+ +
+
+ + +
+ + + + + +
+ {% for i in range(1, 6) %} +
+ + +
+ {% endfor %} +
+
+ + + +
+ +
+
+
+{% endblock %} diff --git a/web/templates/teams/detail.html b/web/templates/teams/detail.html new file mode 100644 index 0000000..f643d84 --- /dev/null +++ b/web/templates/teams/detail.html @@ -0,0 +1,116 @@ +{% extends "base.html" %} + +{% block content %} +
+ +
+

{{ lineup.name }}

+

{{ lineup.description }}

+
+ + +
+ {% for p in players %} +
+ + + {{ p.username }} + + Rating: {{ "%.2f"|format(p.rating if p.rating else 0) }} +
+ {% endfor %} +
+ + +
+

阵容综合能力

+
+
+
+
+
平均 Rating
+
{{ "%.2f"|format(agg_stats.avg_rating or 0) }}
+
+
+
平均 K/D
+
{{ "%.2f"|format(agg_stats.avg_kd or 0) }}
+
+
+
+ + +
+ +
+
+
+ + +
+

共同经历 (Shared Matches)

+
+ + + + + + + + + + + {% for m in shared_matches %} + + + + + + + {% else %} + + + + {% endfor %} + +
DateMapScoreLink
{{ m.start_time }}{{ m.map_name }}{{ m.score_team1 }} : {{ m.score_team2 }} + View +
No shared matches found for this lineup.
+
+
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/web/templates/teams/list.html b/web/templates/teams/list.html new file mode 100644 index 0000000..94e7ec8 --- /dev/null +++ b/web/templates/teams/list.html @@ -0,0 +1,34 @@ +{% extends "base.html" %} + +{% block content %} +
+
+

战队阵容库

+ + 新建阵容 + +
+ +
+ {% for lineup in lineups %} +
+

{{ lineup.name }}

+

{{ lineup.description }}

+ +
+ {% for p in lineup.players %} + {{ p.username }} + {% endfor %} +
+ + + 查看分析 → + +
+ {% endfor %} +
+
+{% endblock %} diff --git a/web/templates/wiki/edit.html b/web/templates/wiki/edit.html new file mode 100644 index 0000000..c5c21bb --- /dev/null +++ b/web/templates/wiki/edit.html @@ -0,0 +1,30 @@ +{% extends "base.html" %} + +{% block content %} +
+

Edit Wiki Page

+ +
+
+ + +

Path cannot be changed after creation (unless new).

+
+ +
+ + +
+ +
+ + +
+ +
+ Cancel + +
+
+
+{% endblock %} diff --git a/web/templates/wiki/index.html b/web/templates/wiki/index.html new file mode 100644 index 0000000..2717dc1 --- /dev/null +++ b/web/templates/wiki/index.html @@ -0,0 +1,25 @@ +{% extends "base.html" %} + +{% block content %} +
+
+

知识库 (Wiki)

+ {% if session.get('is_admin') %} + New Page + {% endif %} +
+ +
+ {% for page in pages %} + +
+ {{ page.title }} + {{ page.path }} +
+
+ {% else %} +

暂无文档。

+ {% endfor %} +
+
+{% endblock %} diff --git a/web/templates/wiki/view.html b/web/templates/wiki/view.html new file mode 100644 index 0000000..c49bcf3 --- /dev/null +++ b/web/templates/wiki/view.html @@ -0,0 +1,33 @@ +{% extends "base.html" %} + +{% block head %} + +{% endblock %} + +{% block content %} +
+
+
+

{{ page.title }}

+

Path: {{ page.path }} | Updated: {{ page.updated_at }}

+
+ {% if session.get('is_admin') %} + Edit + {% endif %} +
+ +
+ +
+ + + +
+ + +{% endblock %}