1.0.0 : Web Implemented.

This commit is contained in:
2026-01-26 02:13:06 +08:00
parent 026a8fe65d
commit 8dabf0b097
55 changed files with 4545 additions and 3 deletions

1
.gitignore vendored
View File

@@ -5,6 +5,7 @@ __pycache__/
*.so *.so
*.dylib *.dylib
*.dll *.dll
.trae/
.Python .Python
build/ build/

View File

@@ -659,6 +659,13 @@ class MatchParser:
stats.team_id = team_id_value stats.team_id = team_id_value
stats.kills = safe_int(get_stat('kill')) stats.kills = safe_int(get_stat('kill'))
stats.deaths = safe_int(get_stat('death')) 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.assists = safe_int(get_stat('assist'))
stats.headshot_count = safe_int(get_stat('headshot')) stats.headshot_count = safe_int(get_stat('headshot'))

View File

@@ -43,6 +43,7 @@ def calculate_basic_features(df):
'total_matches': count, 'total_matches': count,
'basic_avg_rating': df['rating'].mean(), 'basic_avg_rating': df['rating'].mean(),
'basic_avg_kd': df['kd_ratio'].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_kast': df['kast'].mean(),
'basic_avg_rws': df['rws'].mean(), 'basic_avg_rws': df['rws'].mean(),
'basic_avg_headshot_kills': df['headshot_count'].sum() / count, 'basic_avg_headshot_kills': df['headshot_count'].sum() / count,

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

@@ -35,4 +35,10 @@
3. 常用站位区域占比(某区域停留时间/总回合时间) 3. 常用站位区域占比(某区域停留时间/总回合时间)
4. 区域对枪胜率(某区域内击杀数/死亡数) 4. 区域对枪胜率(某区域内击杀数/死亡数)
--- ---
完整了解代码库与web端需求文档 WebRDD.md 开始计划开发web端完成web端的所有需求。
注意不需要实现注册登录系统最好核心是token系统。
严格按照需求部分规划开发方案与开发顺序。不要忽略内容。
utils下还会有哪些需要打包成可快速调用的工具针对这个项目你有什么先见

View File

@@ -82,6 +82,11 @@ yrtv/
3. **View 层**: Jinja2 渲染 HTML。 3. **View 层**: Jinja2 渲染 HTML。
4. **Client 层**: 浏览器交互。 4. **Client 层**: 浏览器交互。
### 2.3 开发与启动 (Development & Startup)
* **启动方式**:
* 在项目根目录下运行: `python web/app.py`
* 访问地址: `http://127.0.0.1:5000`
--- ---
## 3. 功能需求详解 (Functional Requirements) ## 3. 功能需求详解 (Functional Requirements)
@@ -105,7 +110,7 @@ yrtv/
* **筛选/搜索**: 按 ID/昵称搜索,按 K/D、Rating、MVP 等指标排序。 * **筛选/搜索**: 按 ID/昵称搜索,按 K/D、Rating、MVP 等指标排序。
* **展示**: 卡片式布局显示头像、ID、主队、核心数据 (Rating, K/D, ADR)。 * **展示**: 卡片式布局显示头像、ID、主队、核心数据 (Rating, K/D, ADR)。
#### 3.2.2 玩家详情 PlayerProfile #### 3.2.2 玩家详情 PlayerProfile
* **基础信息**: 头像、SteamID、5E ID、注册时间。可以手动分配Tag。玩家列表 Players * **基础信息**: 头像、SteamID、5E ID、注册时间。可以手动分配Tag。
* **核心指标**: 赛季平均 Rating, ADR, KAST, 首杀成功率等。 * **核心指标**: 赛季平均 Rating, ADR, KAST, 首杀成功率等。
* **能力雷达图**: *计算规则需在 Service 层定义* * **能力雷达图**: *计算规则需在 Service 层定义*
* **趋势图**: 近 10/20 场比赛 Rating 走势 (Chart.js)。 * **趋势图**: 近 10/20 场比赛 Rating 走势 (Chart.js)。
@@ -168,7 +173,7 @@ yrtv/
* 上传 demo 文件或修正比赛数据。 * 上传 demo 文件或修正比赛数据。
* **配置**: 管理员账号管理、全局公告设置。查看网站访问数等后台统计。 * **配置**: 管理员账号管理、全局公告设置。查看网站访问数等后台统计。
### 3.7E 管理后台查询工具 (SQL Runner) ### 3.8 管理后台查询工具 (SQL Runner)
* **功能**: 提供一个 Web 版的 SQLite 查询窗口。 * **功能**: 提供一个 Web 版的 SQLite 查询窗口。
* **限制**: 只读权限(防止 `DROP/DELETE`),仅供高级用户进行自定义数据挖掘。 * **限制**: 只读权限(防止 `DROP/DELETE`),仅供高级用户进行自定义数据挖掘。

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -14,6 +14,7 @@ CREATE TABLE IF NOT EXISTS dm_player_features (
-- ========================================== -- ==========================================
basic_avg_rating REAL, basic_avg_rating REAL,
basic_avg_kd REAL, basic_avg_kd REAL,
basic_avg_adr REAL,
basic_avg_kast REAL, basic_avg_kast REAL,
basic_avg_rws REAL, basic_avg_rws REAL,
basic_avg_headshot_kills REAL, basic_avg_headshot_kills REAL,

BIN
database/Web/Web_App.sqlite Normal file

Binary file not shown.

0
l3.db Normal file
View File

65
scripts/debug_db.py Normal file
View File

@@ -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()

View File

@@ -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()

39
scripts/debug_jacky.py Normal file
View File

@@ -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()

84
scripts/init_web_db.py Normal file
View File

@@ -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()

35
web/app.py Normal file
View File

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

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

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}'})

48
web/routes/matches.py Normal file
View File

@@ -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('/<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)
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('/<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')

198
web/routes/players.py Normal file
View File

@@ -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('/<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)
# 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/<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 = {}
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)

73
web/routes/tactics.py Normal file
View File

@@ -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'})

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)

View File

@@ -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

View File

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

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

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

156
web/templates/base.html Normal file
View File

@@ -0,0 +1,156 @@
<!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://cdn.jsdelivr.net/npm/chart.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('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('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 CS2 Data Platform. All rights reserved.</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,125 @@
{% extends "base.html" %}
{% block content %}
<div class="space-y-6">
<!-- 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>
</div>
<!-- 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">
<img class="h-8 w-8 rounded-full" src="{{ p.avatar_url or 'https://via.placeholder.com/32' }}" alt="">
</div>
<div class="ml-4">
<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>
</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">
<img class="h-8 w-8 rounded-full" src="{{ p.avatar_url or 'https://via.placeholder.com/32' }}" alt="">
</div>
<div class="ml-4">
<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>
</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>
{% endblock %}

View File

@@ -0,0 +1,69 @@
{% 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>
<!-- 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">时长</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">
<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{% else %}bg-red-100 text-red-800{% endif %}">
{{ match.score_team1 }}
</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{% else %}bg-red-100 text-red-800{% endif %}">
{{ match.score_team2 }}
</span>
</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,74 @@
{% 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 Hidden/Placeholder -->
<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">
{{ player.username[:2] | upper if player.username else '??' }}
</div>
<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 %}

View File

@@ -0,0 +1,309 @@
{% extends "base.html" %}
{% block content %}
<div class="space-y-6">
<!-- Profile Header -->
<div class="bg-white dark:bg-slate-800 shadow rounded-lg p-6">
<div class="sm:flex sm:items-center sm:justify-between">
<div class="sm:flex sm:space-x-5">
<div class="flex-shrink-0 relative group">
<!-- Avatar -->
{% if player.avatar_url %}
<img src="{{ player.avatar_url }}" class="mx-auto h-24 w-24 rounded-full object-cover border-4 border-white shadow-lg">
{% else %}
<div class="mx-auto h-24 w-24 rounded-full bg-yrtv-100 flex items-center justify-center text-yrtv-600 font-bold text-3xl border-4 border-white shadow-lg">
{{ player.username[:2] | upper if player.username else '??' }}
</div>
{% endif %}
{% if session.get('is_admin') %}
<button onclick="document.getElementById('editProfileModal').classList.remove('hidden')" class="absolute inset-0 flex items-center justify-center bg-black bg-opacity-50 rounded-full opacity-0 group-hover:opacity-100 text-white text-xs transition cursor-pointer">
Edit
</button>
{% endif %}
</div>
<div class="mt-4 text-center sm:mt-0 sm:pt-1 sm:text-left">
<p class="text-xl font-bold text-gray-900 dark:text-white sm:text-2xl">{{ player.username }}</p>
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">{{ player.steam_id_64 }}</p>
<div class="mt-2 flex justify-center sm:justify-start space-x-2 items-center flex-wrap">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
{{ player.uid }}
</span>
<!-- Tags -->
{% for tag in metadata.tags %}
<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-gray-700 dark:text-gray-300">
{{ tag }}
{% if session.get('is_admin') %}
<form action="{{ url_for('players.detail', steam_id=player.steam_id_64) }}" method="POST" class="inline ml-1">
<input type="hidden" name="admin_action" value="remove_tag">
<input type="hidden" name="tag" value="{{ tag }}">
<button type="submit" class="text-gray-400 hover:text-red-500 focus:outline-none">&times;</button>
</form>
{% endif %}
</span>
{% endfor %}
{% if session.get('is_admin') %}
<form action="{{ url_for('players.detail', steam_id=player.steam_id_64) }}" method="POST" class="inline-flex items-center">
<input type="hidden" name="admin_action" value="add_tag">
<input type="text" name="tag" placeholder="New Tag" class="w-20 text-xs border border-gray-300 rounded px-1 py-0.5 focus:outline-none dark:bg-slate-700 dark:border-slate-600 dark:text-white">
<button type="submit" class="ml-1 text-xs text-yrtv-600 hover:text-yrtv-800">+</button>
</form>
{% endif %}
</div>
{% if metadata.notes %}
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400 italic">"{{ metadata.notes }}"</p>
{% endif %}
</div>
</div>
<div class="mt-5 flex justify-center sm:mt-0 space-x-2">
{% if session.get('is_admin') %}
<button onclick="document.getElementById('editProfileModal').classList.remove('hidden')" class="flex justify-center items-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 dark:bg-slate-700 dark:text-white dark:border-slate-600 dark:hover:bg-slate-600">
Edit Profile
</button>
{% endif %}
</div>
</div>
</div>
<!-- Edit Modal -->
<div id="editProfileModal" class="hidden fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
<div class="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white dark:bg-slate-800">
<div class="mt-3 text-center">
<h3 class="text-lg leading-6 font-medium text-gray-900 dark:text-white">Edit Profile</h3>
<form action="{{ url_for('players.detail', steam_id=player.steam_id_64) }}" method="POST" class="mt-2 px-7 py-3" enctype="multipart/form-data">
<input type="hidden" name="admin_action" value="update_profile">
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 text-left">Avatar</label>
<input type="file" name="avatar" accept="image/*" class="mt-1 block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-yrtv-50 file:text-yrtv-700 hover:file:bg-yrtv-100 dark:text-gray-300 dark:file:bg-slate-700 dark:file:text-white">
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400 text-left">Supported: JPG, PNG. Will replace existing.</p>
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 text-left">Notes</label>
<textarea name="notes" rows="3" 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 dark:border-slate-600">{{ metadata.notes }}</textarea>
</div>
<div class="items-center px-4 py-3">
<button type="submit" class="px-4 py-2 bg-yrtv-600 text-white text-base font-medium rounded-md w-full shadow-sm hover:bg-yrtv-700 focus:outline-none focus:ring-2 focus:ring-yrtv-500">
Save
</button>
<button type="button" onclick="document.getElementById('editProfileModal').classList.add('hidden')" class="mt-3 px-4 py-2 bg-gray-100 text-gray-700 text-base font-medium rounded-md w-full shadow-sm hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-gray-300 dark:bg-slate-700 dark:text-white dark:hover:bg-slate-600">
Cancel
</button>
</div>
</form>
</div>
</div>
</div>
<!-- Layout Reorder: Trend First -->
<!-- Trend Chart (Full Width) -->
<div class="bg-white dark:bg-slate-800 shadow rounded-lg p-6">
<div class="flex justify-between items-center mb-4">
<h3 class="text-lg font-medium leading-6 text-gray-900 dark:text-white">
<span class="mr-2">📈</span>近期 Rating 走势 (Trend)
</h3>
</div>
<div class="relative h-72">
<canvas id="trendChart"></canvas>
</div>
</div>
<!-- Grid: Stats + Radar -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- Left: Stats Cards -->
<div class="lg:col-span-2 grid grid-cols-2 gap-4 h-fit">
<div class="bg-white dark:bg-slate-800 shadow rounded-lg p-4 text-center">
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Rating</dt>
<dd class="mt-1 text-3xl font-semibold text-gray-900 dark:text-white">{{ "%.2f"|format((features.basic_avg_rating if features else 0) or 0) }}</dd>
</div>
<div class="bg-white dark:bg-slate-800 shadow rounded-lg p-4 text-center">
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">K/D</dt>
<dd class="mt-1 text-3xl font-semibold text-gray-900 dark:text-white">{{ "%.2f"|format((features.basic_avg_kd if features else 0) or 0) }}</dd>
</div>
<div class="bg-white dark:bg-slate-800 shadow rounded-lg p-4 text-center">
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">ADR</dt>
<dd class="mt-1 text-3xl font-semibold text-gray-900 dark:text-white">{{ "%.1f"|format((features.basic_avg_adr if features else 0) or 0) }}</dd>
</div>
<div class="bg-white dark:bg-slate-800 shadow rounded-lg p-4 text-center">
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">KAST</dt>
<dd class="mt-1 text-3xl font-semibold text-gray-900 dark:text-white">{{ "%.1f"|format((features.basic_avg_kast if features else 0) * 100) }}%</dd>
</div>
</div>
<!-- Right: Radar -->
<div class="bg-white dark:bg-slate-800 shadow rounded-lg p-6 flex flex-col items-center justify-center">
<h3 class="text-lg font-medium leading-6 text-gray-900 dark:text-white mb-4 w-full text-left">能力六维图</h3>
<div class="relative h-64 w-full">
<canvas id="radarChart"></canvas>
</div>
</div>
<!-- Match History (L2) -->
<div class="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">比赛记录 (History - {{ history|length }})</h3>
<div class="overflow-x-auto max-h-96 overflow-y-auto">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700 relative">
<thead class="bg-gray-50 dark:bg-slate-700 sticky top-0">
<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-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Rating</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">K/D</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">ADR</th>
<th class="px-6 py-3 text-right 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 history %}
<tr>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
<script>document.write(new Date({{ m.start_time }} * 1000).toLocaleDateString())</script>
</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-right font-bold {% if (m.rating or 0) >= 1.1 %}text-green-600{% elif (m.rating or 0) < 0.9 %}text-red-600{% else %}text-gray-900 dark:text-white{% endif %}">{{ "%.2f"|format(m.rating or 0) }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-right text-gray-500 dark:text-gray-400">{{ "%.2f"|format(m.kd_ratio or 0) }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-right text-gray-500 dark:text-gray-400">{{ "%.1f"|format(m.adr or 0) }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-right 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="6" class="px-6 py-4 text-center text-gray-500">No recent matches found.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<div class="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-6">玩家评价 ({{ comments|length }})</h3>
<!-- Comment Form -->
<form action="{{ url_for('players.detail', steam_id=player.steam_id_64) }}" method="POST" class="mb-8">
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Your Name (Optional)</label>
<input type="text" name="username" 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" placeholder="Anonymous">
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Comment</label>
<textarea name="content" rows="3" 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" placeholder="Share your thoughts..."></textarea>
</div>
<button type="submit" class="px-4 py-2 bg-yrtv-600 text-white rounded hover:bg-yrtv-700">Submit Review</button>
</form>
<!-- Comment List -->
<div class="space-y-6">
{% for comment in comments %}
<div class="flex space-x-4 p-4 bg-gray-50 dark:bg-slate-700 rounded-lg">
<div class="flex-shrink-0">
<span class="inline-block h-10 w-10 rounded-full overflow-hidden bg-gray-100">
<svg class="h-full w-full text-gray-300" fill="currentColor" viewBox="0 0 24 24">
<path d="M24 20.993V24H0v-2.996A14.977 14.977 0 0112.004 15c4.904 0 9.26 2.354 11.996 5.993zM16.002 8.999a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
</span>
</div>
<div class="flex-1 space-y-1">
<div class="flex items-center justify-between">
<h3 class="text-sm font-medium text-gray-900 dark:text-white">{{ comment.username }}</h3>
<p class="text-sm text-gray-500">{{ comment.created_at }}</p>
</div>
<p class="text-sm text-gray-500 dark:text-gray-300">{{ comment.content }}</p>
<div class="mt-2 flex items-center space-x-2">
<button onclick="likeComment({{ comment.id }}, this)" class="flex items-center text-gray-400 hover:text-red-500">
<svg class="h-5 w-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
</svg>
<span class="like-count">{{ comment.likes }}</span>
</button>
</div>
</div>
</div>
{% else %}
<p class="text-gray-500 text-center">No comments yet. Be the first!</p>
{% endfor %}
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function likeComment(commentId, btn) {
fetch(`/players/comment/${commentId}/like`, { method: 'POST' })
.then(response => response.json())
.then(data => {
if (data.success) {
const countSpan = btn.querySelector('.like-count');
countSpan.innerText = parseInt(countSpan.innerText) + 1;
btn.classList.add('text-red-500');
}
});
}
document.addEventListener('DOMContentLoaded', function() {
const steamId = "{{ player.steam_id_64 }}";
fetch(`/players/${steamId}/charts_data`)
.then(response => response.json())
.then(data => {
// Radar Chart
const ctxRadar = document.getElementById('radarChart').getContext('2d');
new Chart(ctxRadar, {
type: 'radar',
data: {
labels: ['STA', 'BAT', 'HPS', 'PTL', 'SIDE', 'UTIL'],
datasets: [{
label: 'Ability',
data: [
data.radar.STA, data.radar.BAT, data.radar.HPS,
data.radar.PTL, data.radar.SIDE, data.radar.UTIL
],
backgroundColor: 'rgba(124, 58, 237, 0.2)',
borderColor: 'rgba(124, 58, 237, 1)',
pointBackgroundColor: 'rgba(124, 58, 237, 1)',
}]
},
options: {
scales: {
r: {
beginAtZero: true,
suggestedMax: 2.0 // Adjust based on data range
}
}
}
});
// Trend Chart
const ctxTrend = document.getElementById('trendChart').getContext('2d');
new Chart(ctxTrend, {
type: 'line',
data: {
labels: data.trend.labels,
datasets: [{
label: 'Rating',
data: data.trend.values,
borderColor: 'rgba(16, 185, 129, 1)',
backgroundColor: 'rgba(16, 185, 129, 0.1)',
tension: 0.1,
fill: true
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
y: {
beginAtZero: false
}
}
}
});
});
});
</script>
{% endblock %}

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,22 @@
{% extends "tactics/layout.html" %}
{% block title %}Data Center - 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">Data Center: Comparison</h2>
<div class="space-y-6">
<!-- Controls -->
<div class="flex space-x-4">
<input type="text" placeholder="Search players to compare..." class="flex-1 rounded-md border-gray-300 dark:bg-slate-700 dark:border-slate-600 dark:text-white">
<button class="px-4 py-2 bg-yrtv-600 text-white rounded-md">Add to Compare</button>
</div>
<!-- Chart Placeholder -->
<div class="h-96 bg-gray-50 dark:bg-slate-700 rounded flex items-center justify-center">
<p class="text-gray-500 dark:text-gray-400">Multi-player Radar Chart / Bar Chart Area</p>
</div>
</div>
</div>
{% endblock %}

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,471 @@
{% 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)">
<img :src="player.avatar_url || 'https://avatars.steamstatic.com/fef49e7fa7e1997310d705b2a6158ff8dc1cdfeb_full.jpg'"
class="w-10 h-10 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-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="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Drop Zone -->
<div class="bg-white dark:bg-slate-800 p-6 rounded-lg shadow min-h-[300px]"
@dragover.prevent @drop="dropAnalysis($event)">
<h4 class="font-medium text-gray-700 dark:text-gray-300 mb-4 flex justify-between">
<span x-text="'阵容构建 (' + analysisLineup.length + '/5)'">阵容构建 (0/5)</span>
<button @click="clearAnalysis()" class="text-xs text-red-500 hover:underline">清空</button>
</h4>
<div class="grid grid-cols-1 sm:grid-cols-5 gap-4">
<template x-for="(p, idx) in analysisLineup" :key="p.steam_id_64">
<div class="relative bg-gray-50 dark:bg-slate-700 p-2 rounded border border-gray-200 dark:border-slate-600 flex flex-col items-center">
<button @click="removeFromAnalysis(idx)" class="absolute top-1 right-1 text-red-400 hover:text-red-600">×</button>
<img :src="p.avatar_url" class="w-12 h-12 rounded-full mb-2">
<span class="text-xs font-bold truncate w-full text-center dark:text-white" x-text="p.username || p.name"></span>
<span class="text-[10px] text-gray-500" x-text="'R: ' + (p.stats?.basic_avg_rating || 0).toFixed(2)"></span>
</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 flex items-center justify-center h-24 text-gray-400 text-xs">
拖拽至此
</div>
</template>
</div>
<div class="mt-6">
<button @click="analyzeLineup()" :disabled="analysisLineup.length < 1" class="w-full py-2 bg-yrtv-600 text-white rounded hover:bg-yrtv-700 disabled:opacity-50 disabled:cursor-not-allowed">
开始分析 (Analyze)
</button>
</div>
</div>
<!-- Results Area -->
<div class="bg-white dark:bg-slate-800 p-6 rounded-lg shadow">
<template x-if="!analysisResult">
<div class="h-full flex items-center justify-center text-gray-400 text-sm">
请先选择阵容并点击分析
</div>
</template>
<template x-if="analysisResult">
<div class="space-y-4">
<div class="flex justify-between items-center">
<h4 class="font-bold text-gray-900 dark:text-white">综合评分</h4>
<span class="text-2xl font-bold text-yrtv-600" x-text="analysisResult.avg_stats.rating.toFixed(2)"></span>
</div>
<div class="grid grid-cols-3 gap-2 text-center text-sm">
<div class="bg-gray-50 dark:bg-slate-700 p-2 rounded">
<div class="text-gray-500">Avg K/D</div>
<div class="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-2 rounded">
<div class="text-gray-500">Avg ADR</div>
<div class="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-2 rounded">
<div class="text-gray-500">共同场次</div>
<div class="font-bold dark:text-white" x-text="analysisResult.shared_matches.length"></div>
</div>
</div>
<div>
<h5 class="text-sm font-bold text-gray-700 dark:text-gray-300 mb-2">近期共同比赛 (Recent Shared Matches)</h5>
<div class="max-h-40 overflow-y-auto text-xs">
<table class="min-w-full divide-y divide-gray-200 dark:divide-slate-600">
<thead class="bg-gray-50 dark:bg-slate-700">
<tr>
<th class="px-2 py-1 text-left">Map</th>
<th class="px-2 py-1 text-right">Score</th>
<th class="px-2 py-1 text-right">Result</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-slate-700">
<template x-for="m in analysisResult.shared_matches" :key="m.match_id">
<tr>
<td class="px-2 py-1 dark:text-gray-300" x-text="m.map_name"></td>
<td class="px-2 py-1 text-right dark:text-gray-300" x-text="m.score_team1 + ':' + m.score_team2"></td>
<td class="px-2 py-1 text-right font-bold" :class="m.winner_team ? 'text-green-600' : 'text-gray-500'" x-text="m.winner_team ? 'Win' : 'Draw/Loss'"></td>
</tr>
</template>
</tbody>
</table>
<template x-if="analysisResult.shared_matches.length === 0">
<p class="text-center text-gray-400 py-2">无共同比赛记录</p>
</template>
</div>
</div>
</div>
</template>
</div>
</div>
</div>
<!-- 2. Data Center (Placeholder) -->
<div x-show="activeTab === 'data'" class="flex items-center justify-center h-full">
<div class="text-center">
<div class="text-4xl mb-4">📊</div>
<h3 class="text-xl font-bold text-gray-900 dark:text-white">数据对比中心 (Construction)</h3>
<p class="text-gray-500">此模块正在开发中...</p>
</div>
</div>
<!-- 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,
// Board State
currentMap: 'de_mirage',
map: null,
markers: {},
boardPlayers: [],
// Economy State
econ: {
result: 'loss',
lossBonus: '0',
surviving: 0
},
init() {
this.fetchRoster();
// 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) {
event.dataTransfer.setData('text/plain', JSON.stringify(player));
event.dataTransfer.effectAllowed = 'copy';
},
// --- 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">
<img src="${player.avatar_url || 'https://avatars.steamstatic.com/fef49e7fa7e1997310d705b2a6158ff8dc1cdfeb_full.jpg'}"
class="w-8 h-8 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.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,221 @@
{% 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>
<!-- 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">
<img :src="player.avatar_url || 'https://avatars.steamstatic.com/fef49e7fa7e1997310d705b2a6158ff8dc1cdfeb_full.jpg'" class="w-32 h-32 rounded-full object-cover border-4 border-yrtv-500 shadow-lg">
</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: [],
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;
});
},
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 %}