2.0.0-rc1 : Profile and achievements update.
This commit is contained in:
+16
-1
@@ -13,9 +13,22 @@ def create_app(config_object=Config):
|
||||
app.config.from_object(config_object)
|
||||
|
||||
initialize_web_db()
|
||||
from web.services.roster_version_service import RosterVersionService
|
||||
RosterVersionService.ensure_initial_version()
|
||||
app.teardown_appcontext(close_dbs)
|
||||
|
||||
from web.routes import main, matches, players, teams, tactics, admin, wiki, opponents
|
||||
from web.routes import (
|
||||
admin,
|
||||
awards,
|
||||
main,
|
||||
matches,
|
||||
opponents,
|
||||
players,
|
||||
reports,
|
||||
tactics,
|
||||
teams,
|
||||
wiki,
|
||||
)
|
||||
app.register_blueprint(main.bp)
|
||||
app.register_blueprint(matches.bp)
|
||||
app.register_blueprint(players.bp)
|
||||
@@ -24,6 +37,8 @@ def create_app(config_object=Config):
|
||||
app.register_blueprint(admin.bp)
|
||||
app.register_blueprint(wiki.bp)
|
||||
app.register_blueprint(opponents.bp)
|
||||
app.register_blueprint(reports.bp)
|
||||
app.register_blueprint(awards.bp)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@ class Config:
|
||||
DB_L3_PATH = str(L3_DB)
|
||||
DB_WEB_PATH = str(WEB_DB)
|
||||
DB_WEB_SCHEMA_PATH = str(WEB_SCHEMA)
|
||||
WEB_SCHEMA_VERSION = 2
|
||||
WEB_SCHEMA_VERSION = 3
|
||||
|
||||
MAX_CONTENT_LENGTH = 5 * 1024 * 1024
|
||||
SQLITE_TIMEOUT_SECONDS = 15
|
||||
|
||||
+1
-1
@@ -83,7 +83,7 @@ def initialize_web_db():
|
||||
""",
|
||||
[
|
||||
Config.WEB_SCHEMA_VERSION,
|
||||
'ETL jobs, match imports and active lineup governance',
|
||||
'Roster versions, member roles and team performance governance',
|
||||
],
|
||||
)
|
||||
db.commit()
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
from flask import Blueprint, render_template, request
|
||||
|
||||
from web.services.narrative_service import NarrativeService
|
||||
|
||||
|
||||
bp = Blueprint('awards', __name__, url_prefix='/awards')
|
||||
|
||||
|
||||
@bp.route('/')
|
||||
def index():
|
||||
award_type = request.args.get('type')
|
||||
return render_template(
|
||||
'awards/index.html',
|
||||
awards=NarrativeService.list_awards(award_type),
|
||||
leaderboard=NarrativeService.get_award_summary(),
|
||||
award_type=award_type,
|
||||
award_types=NarrativeService.AWARD_TYPE_LABELS,
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from flask import Blueprint, render_template, request, Response
|
||||
from web.services.stats_service import StatsService
|
||||
from web.services.narrative_service import NarrativeService
|
||||
from web.config import Config
|
||||
import json
|
||||
|
||||
@@ -108,7 +109,8 @@ def detail(match_id):
|
||||
rounds=rounds,
|
||||
h2h_matrix=h2h_matrix,
|
||||
round_details=round_details,
|
||||
player_name_map=player_name_map)
|
||||
player_name_map=player_name_map,
|
||||
post_match_report=NarrativeService.get_match_report(match_id))
|
||||
|
||||
@bp.route('/<match_id>/raw')
|
||||
def raw_json(match_id):
|
||||
|
||||
@@ -2,14 +2,17 @@ from flask import Blueprint, render_template, request, jsonify, redirect, url_fo
|
||||
from web.services.stats_service import StatsService
|
||||
from web.services.feature_service import FeatureService
|
||||
from web.services.player_profile_service import PlayerProfileService
|
||||
from web.services.narrative_service import NarrativeService
|
||||
from web.services.web_service import WebService
|
||||
from web.database import execute_db, query_db
|
||||
from web.config import Config
|
||||
from datetime import datetime
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
|
||||
bp = Blueprint('players', __name__, url_prefix='/players')
|
||||
logger = logging.getLogger(__name__)
|
||||
ALLOWED_AVATAR_EXTENSIONS = {'.jpg', '.jpeg', '.png', '.webp'}
|
||||
|
||||
@bp.route('/')
|
||||
@@ -64,7 +67,10 @@ def detail(steam_id):
|
||||
|
||||
flash('Avatar updated successfully.', 'success')
|
||||
except Exception as e:
|
||||
print(f"Avatar upload error: {e}")
|
||||
logger.exception(
|
||||
"Avatar upload failed for player %s",
|
||||
steam_id,
|
||||
)
|
||||
flash('Error uploading avatar.', 'error')
|
||||
|
||||
WebService.update_player_metadata(steam_id, notes=notes)
|
||||
@@ -197,6 +203,8 @@ def detail(steam_id):
|
||||
map_stats=map_stats_list,
|
||||
period_stats=period_stats,
|
||||
records=records,
|
||||
professional_identity=NarrativeService.get_player_identity(steam_id),
|
||||
honors=NarrativeService.get_player_honors(steam_id),
|
||||
l2_stats=l2_stats,
|
||||
side_stats=side_stats)
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
from flask import Blueprint, render_template
|
||||
|
||||
from web.services.narrative_service import NarrativeService
|
||||
|
||||
|
||||
bp = Blueprint('reports', __name__, url_prefix='/reports')
|
||||
|
||||
|
||||
@bp.route('/')
|
||||
def index():
|
||||
return render_template(
|
||||
'reports/index.html',
|
||||
reports=NarrativeService.list_match_reports(50),
|
||||
)
|
||||
|
||||
+58
-17
@@ -2,15 +2,19 @@ from flask import Blueprint, render_template, request, redirect, url_for, flash,
|
||||
from web.services.web_service import WebService
|
||||
from web.services.stats_service import StatsService
|
||||
from web.services.feature_service import FeatureService
|
||||
from web.services.roster_version_service import RosterVersionService
|
||||
from web.services.team_performance_service import TeamPerformanceService
|
||||
from web.services.narrative_service import NarrativeService
|
||||
import json
|
||||
import logging
|
||||
|
||||
bp = Blueprint('teams', __name__, url_prefix='/teams')
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# --- 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([])
|
||||
@@ -20,9 +24,7 @@ def api_search():
|
||||
# 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 = []
|
||||
@@ -62,7 +64,6 @@ def api_search():
|
||||
|
||||
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'])
|
||||
@@ -88,6 +89,7 @@ def api_roster():
|
||||
current_ids = json.loads(target_team['player_ids_json'])
|
||||
except:
|
||||
pass
|
||||
previous_ids = list(current_ids)
|
||||
|
||||
if action == 'add':
|
||||
if steam_id not in current_ids:
|
||||
@@ -95,20 +97,36 @@ def api_roster():
|
||||
elif action == 'remove':
|
||||
if steam_id in current_ids:
|
||||
current_ids.remove(steam_id)
|
||||
else:
|
||||
return jsonify({'error': 'Unknown roster action'}), 400
|
||||
|
||||
# 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'])
|
||||
WebService.save_lineup(
|
||||
target_team['name'],
|
||||
target_team['description'],
|
||||
current_ids,
|
||||
lineup_id=target_team['id'],
|
||||
)
|
||||
try:
|
||||
version_id, created = RosterVersionService.snapshot_roster(
|
||||
current_ids,
|
||||
name=f"{target_team['name']} Roster",
|
||||
)
|
||||
except Exception:
|
||||
WebService.save_lineup(
|
||||
target_team['name'],
|
||||
target_team['description'],
|
||||
previous_ids,
|
||||
lineup_id=target_team['id'],
|
||||
)
|
||||
raise
|
||||
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 = []
|
||||
@@ -130,9 +148,10 @@ def api_roster():
|
||||
|
||||
enriched.append(p_dict)
|
||||
except Exception as inner_e:
|
||||
print(f"ERROR: Processing player failed: {inner_e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
logger.exception(
|
||||
"Failed to enrich roster player %s",
|
||||
p.get('steam_id_64') if hasattr(p, 'get') else 'unknown',
|
||||
)
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
@@ -140,9 +159,7 @@ def api_roster():
|
||||
'roster': enriched
|
||||
})
|
||||
except Exception as e:
|
||||
print(f"CRITICAL ERROR in api_roster: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
logger.exception("Roster API failed")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
# --- Views ---
|
||||
@@ -151,6 +168,30 @@ def index():
|
||||
# Directly render the Clubhouse SPA
|
||||
return render_template('teams/clubhouse.html')
|
||||
|
||||
@bp.route('/performance')
|
||||
def performance():
|
||||
return render_template(
|
||||
'teams/performance.html',
|
||||
seasons=NarrativeService.get_seasons(),
|
||||
**TeamPerformanceService.get_dashboard(),
|
||||
)
|
||||
|
||||
@bp.route('/roster-role', methods=['POST'])
|
||||
def roster_role():
|
||||
if not session.get('is_admin'):
|
||||
return "Unauthorized", 403
|
||||
steam_id = request.form.get('steam_id')
|
||||
member_role = request.form.get('member_role')
|
||||
try:
|
||||
RosterVersionService.update_current_member_role(
|
||||
steam_id,
|
||||
member_role,
|
||||
)
|
||||
flash('Roster role updated.', 'success')
|
||||
except ValueError as exc:
|
||||
flash(str(exc), 'error')
|
||||
return redirect(url_for('teams.performance'))
|
||||
|
||||
# Deprecated routes (kept for compatibility if needed, but hidden)
|
||||
@bp.route('/list')
|
||||
def list_view():
|
||||
@@ -228,5 +269,5 @@ def detail(lineup_id):
|
||||
|
||||
return render_template('teams/detail.html', lineup=lineup, players=players, agg_stats=agg_stats, shared_matches=shared_matches, radar_data=radar_data)
|
||||
except Exception as e:
|
||||
import traceback
|
||||
return f"<pre>{traceback.format_exc()}</pre>", 500
|
||||
logger.exception("Lineup detail failed for lineup_id=%s", lineup_id)
|
||||
return "Unable to load lineup", 500
|
||||
|
||||
@@ -246,12 +246,19 @@ class IntegrityService:
|
||||
@staticmethod
|
||||
def _check_l3(db, roster_ids, checks, counts):
|
||||
required_tables = {
|
||||
'dm_duo_stats',
|
||||
'dm_match_player_reports',
|
||||
'dm_match_reports',
|
||||
'dm_player_features',
|
||||
'dm_player_awards',
|
||||
'dm_player_match_history',
|
||||
'dm_player_map_stats',
|
||||
'dm_player_period_stats',
|
||||
'dm_player_record_events',
|
||||
'dm_player_records',
|
||||
'dm_player_weapon_stats',
|
||||
'dm_lineup_stats',
|
||||
'dm_team_season_stats',
|
||||
}
|
||||
missing = sorted(required_tables - IntegrityService._table_names(db))
|
||||
IntegrityService._check(
|
||||
@@ -281,6 +288,42 @@ class IntegrityService:
|
||||
counts['l3_records'] = db.execute(
|
||||
'SELECT COUNT(*) FROM dm_player_records'
|
||||
).fetchone()[0]
|
||||
counts['l3_duos'] = db.execute(
|
||||
'SELECT COUNT(*) FROM dm_duo_stats'
|
||||
).fetchone()[0]
|
||||
counts['l3_lineups'] = db.execute(
|
||||
'SELECT COUNT(*) FROM dm_lineup_stats'
|
||||
).fetchone()[0]
|
||||
counts['l3_match_reports'] = db.execute(
|
||||
'SELECT COUNT(*) FROM dm_match_reports'
|
||||
).fetchone()[0]
|
||||
counts['l3_player_reports'] = db.execute(
|
||||
'SELECT COUNT(*) FROM dm_match_player_reports'
|
||||
).fetchone()[0]
|
||||
counts['l3_record_events'] = db.execute(
|
||||
'SELECT COUNT(*) FROM dm_player_record_events'
|
||||
).fetchone()[0]
|
||||
counts['l3_seasons'] = db.execute(
|
||||
'SELECT COUNT(*) FROM dm_team_season_stats'
|
||||
).fetchone()[0]
|
||||
counts['l3_awards'] = db.execute(
|
||||
'SELECT COUNT(*) FROM dm_player_awards'
|
||||
).fetchone()[0]
|
||||
expected_matches = counts.get('matches', 0)
|
||||
IntegrityService._check(
|
||||
checks,
|
||||
'Post-match report coverage',
|
||||
(
|
||||
'pass'
|
||||
if counts['l3_match_reports'] == expected_matches
|
||||
else 'fail'
|
||||
),
|
||||
(
|
||||
f"{counts['l3_match_reports']}/"
|
||||
f"{expected_matches} matches reported"
|
||||
),
|
||||
counts['l3_match_reports'],
|
||||
)
|
||||
|
||||
if roster_ids:
|
||||
placeholders = ','.join('?' for _ in roster_ids)
|
||||
@@ -323,6 +366,21 @@ class IntegrityService:
|
||||
f'{actual_history}/{expected_history} player-match rows materialized',
|
||||
actual_history,
|
||||
)
|
||||
player_report_count = db.execute(
|
||||
f"""
|
||||
SELECT COUNT(*)
|
||||
FROM dm_match_player_reports
|
||||
WHERE steam_id_64 IN ({placeholders})
|
||||
""",
|
||||
roster_ids,
|
||||
).fetchone()[0]
|
||||
IntegrityService._check(
|
||||
checks,
|
||||
'Roster player-report completeness',
|
||||
'pass' if player_report_count == expected_history else 'fail',
|
||||
f'{player_report_count}/{expected_history} player reports',
|
||||
player_report_count,
|
||||
)
|
||||
|
||||
score_rows = db.execute(
|
||||
f"""
|
||||
@@ -378,6 +436,13 @@ class IntegrityService:
|
||||
('l3_weapons', 'Player weapon stats mart'),
|
||||
('l3_periods', 'Player period stats mart'),
|
||||
('l3_records', 'Player records mart'),
|
||||
('l3_duos', 'Team duo stats mart'),
|
||||
('l3_lineups', 'Team lineup stats mart'),
|
||||
('l3_match_reports', 'Post-match report mart'),
|
||||
('l3_player_reports', 'Player post-match report mart'),
|
||||
('l3_record_events', 'Record event mart'),
|
||||
('l3_seasons', 'Team season mart'),
|
||||
('l3_awards', 'Player award mart'),
|
||||
):
|
||||
value = counts[key]
|
||||
IntegrityService._check(
|
||||
@@ -424,6 +489,8 @@ class IntegrityService:
|
||||
'schema_migrations',
|
||||
'strategy_boards',
|
||||
'team_lineups',
|
||||
'team_roster_members',
|
||||
'team_roster_versions',
|
||||
'wiki_pages',
|
||||
}
|
||||
missing = sorted(required_tables - IntegrityService._table_names(db))
|
||||
@@ -509,3 +576,42 @@ class IntegrityService:
|
||||
f'{active_count} active lineups configured',
|
||||
active_count,
|
||||
)
|
||||
|
||||
current_versions = db.execute(
|
||||
"""
|
||||
SELECT id
|
||||
FROM team_roster_versions
|
||||
WHERE is_current = 1
|
||||
"""
|
||||
).fetchall()
|
||||
current_member_count = 0
|
||||
if len(current_versions) == 1:
|
||||
current_member_count = db.execute(
|
||||
"""
|
||||
SELECT COUNT(*)
|
||||
FROM team_roster_members
|
||||
WHERE roster_version_id = ?
|
||||
""",
|
||||
[current_versions[0]['id']],
|
||||
).fetchone()[0]
|
||||
counts['roster_versions'] = db.execute(
|
||||
'SELECT COUNT(*) FROM team_roster_versions'
|
||||
).fetchone()[0]
|
||||
counts['current_roster_members'] = current_member_count
|
||||
IntegrityService._check(
|
||||
checks,
|
||||
'Current roster version',
|
||||
'pass' if len(current_versions) == 1 else 'fail',
|
||||
f'{len(current_versions)} current roster versions',
|
||||
len(current_versions),
|
||||
)
|
||||
IntegrityService._check(
|
||||
checks,
|
||||
'Roster version membership',
|
||||
'pass' if current_member_count == counts.get('active_roster', 0) else 'fail',
|
||||
(
|
||||
f"{current_member_count}/"
|
||||
f"{counts.get('active_roster', 0)} active members versioned"
|
||||
),
|
||||
current_member_count,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
import json
|
||||
|
||||
from web.database import query_db
|
||||
|
||||
|
||||
class NarrativeService:
|
||||
AWARD_TYPE_LABELS = {
|
||||
'daily': '单日最佳',
|
||||
'weekly': '星期最佳',
|
||||
'monthly': '月度最佳',
|
||||
'quarterly': '季度最佳',
|
||||
'yearly': '年度最佳',
|
||||
}
|
||||
|
||||
PERFORMANCE_LABELS = {
|
||||
'surge': '状态爆发',
|
||||
'above_form': '高于近期',
|
||||
'stable': '稳定发挥',
|
||||
'below_form': '低于近期',
|
||||
'slump': '状态低迷',
|
||||
'insufficient_sample': '样本不足',
|
||||
}
|
||||
|
||||
RECORD_LABELS = {
|
||||
'highest_rating': '最高 Rating',
|
||||
'most_kills': '最多击杀',
|
||||
'highest_adr': '最高 ADR',
|
||||
'highest_kd': '最高 K/D',
|
||||
'most_headshots': '最多爆头',
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _identity_map(steam_ids):
|
||||
steam_ids = sorted({str(value) for value in steam_ids if value})
|
||||
if not steam_ids:
|
||||
return {}
|
||||
placeholders = ','.join('?' for _ in steam_ids)
|
||||
rows = query_db(
|
||||
'l2',
|
||||
f"""
|
||||
SELECT steam_id_64, username, avatar_url
|
||||
FROM dim_players
|
||||
WHERE steam_id_64 IN ({placeholders})
|
||||
""",
|
||||
steam_ids,
|
||||
)
|
||||
return {str(row['steam_id_64']): dict(row) for row in rows}
|
||||
|
||||
@staticmethod
|
||||
def get_match_report(match_id):
|
||||
report = query_db(
|
||||
'l3',
|
||||
'SELECT * FROM dm_match_reports WHERE match_id = ?',
|
||||
[match_id],
|
||||
one=True,
|
||||
)
|
||||
if not report:
|
||||
return None
|
||||
result = dict(report)
|
||||
player_rows = query_db(
|
||||
'l3',
|
||||
"""
|
||||
SELECT *
|
||||
FROM dm_match_player_reports
|
||||
WHERE match_id = ?
|
||||
ORDER BY rating DESC
|
||||
""",
|
||||
[match_id],
|
||||
)
|
||||
player_reports = [dict(row) for row in player_rows]
|
||||
steam_ids = [row['steam_id_64'] for row in player_reports]
|
||||
steam_ids.extend([
|
||||
result.get('mvp_steam_id'),
|
||||
result.get('improver_steam_id'),
|
||||
])
|
||||
duo = json.loads(result.get('strongest_duo_json') or 'null')
|
||||
if duo:
|
||||
steam_ids.extend(duo.get('steam_ids') or [])
|
||||
identities = NarrativeService._identity_map(steam_ids)
|
||||
|
||||
for player in player_reports:
|
||||
player['identity'] = identities.get(
|
||||
str(player['steam_id_64']),
|
||||
{'username': player['steam_id_64']},
|
||||
)
|
||||
player['performance_text'] = NarrativeService.PERFORMANCE_LABELS.get(
|
||||
player['performance_label'],
|
||||
player['performance_label'],
|
||||
)
|
||||
record_keys = json.loads(player['record_keys_json'] or '[]')
|
||||
player['record_labels'] = [
|
||||
NarrativeService.RECORD_LABELS.get(key, key)
|
||||
for key in record_keys
|
||||
]
|
||||
result['players'] = player_reports
|
||||
result['mvp'] = identities.get(str(result.get('mvp_steam_id')), {})
|
||||
result['improver'] = identities.get(
|
||||
str(result.get('improver_steam_id')),
|
||||
{},
|
||||
)
|
||||
if duo:
|
||||
duo['players'] = [
|
||||
identities.get(str(steam_id), {'username': steam_id})
|
||||
for steam_id in duo.get('steam_ids', [])
|
||||
]
|
||||
result['strongest_duo'] = duo
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def list_match_reports(limit=30):
|
||||
rows = query_db(
|
||||
'l3',
|
||||
"""
|
||||
SELECT *
|
||||
FROM dm_match_reports
|
||||
ORDER BY match_date DESC, match_id DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
[limit],
|
||||
)
|
||||
identities = NarrativeService._identity_map(
|
||||
[row['mvp_steam_id'] for row in rows]
|
||||
)
|
||||
result = []
|
||||
for row in rows:
|
||||
item = dict(row)
|
||||
item['mvp'] = identities.get(str(item['mvp_steam_id']), {})
|
||||
result.append(item)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def list_awards(award_type=None, limit=100):
|
||||
args = []
|
||||
where = ''
|
||||
if award_type in NarrativeService.AWARD_TYPE_LABELS:
|
||||
where = 'WHERE award_type = ?'
|
||||
args.append(award_type)
|
||||
args.append(limit)
|
||||
rows = query_db(
|
||||
'l3',
|
||||
f"""
|
||||
SELECT *
|
||||
FROM dm_player_awards
|
||||
{where}
|
||||
ORDER BY period_start DESC,
|
||||
CASE award_type
|
||||
WHEN 'yearly' THEN 1
|
||||
WHEN 'quarterly' THEN 2
|
||||
WHEN 'monthly' THEN 3
|
||||
WHEN 'weekly' THEN 4
|
||||
ELSE 5
|
||||
END
|
||||
LIMIT ?
|
||||
""",
|
||||
args,
|
||||
)
|
||||
identities = NarrativeService._identity_map(
|
||||
[row['steam_id_64'] for row in rows]
|
||||
)
|
||||
result = []
|
||||
for row in rows:
|
||||
item = dict(row)
|
||||
item['identity'] = identities.get(str(item['steam_id_64']), {})
|
||||
item['award_label'] = NarrativeService.AWARD_TYPE_LABELS.get(
|
||||
item['award_type'],
|
||||
item['award_type'],
|
||||
)
|
||||
result.append(item)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def get_award_summary():
|
||||
rows = query_db(
|
||||
'l3',
|
||||
"""
|
||||
SELECT
|
||||
steam_id_64,
|
||||
COUNT(*) AS awards,
|
||||
SUM(CASE WHEN award_type = 'daily' THEN 1 ELSE 0 END) AS daily,
|
||||
SUM(CASE WHEN award_type = 'weekly' THEN 1 ELSE 0 END) AS weekly,
|
||||
SUM(CASE WHEN award_type = 'monthly' THEN 1 ELSE 0 END) AS monthly,
|
||||
SUM(CASE WHEN award_type = 'quarterly' THEN 1 ELSE 0 END) AS quarterly,
|
||||
SUM(CASE WHEN award_type = 'yearly' THEN 1 ELSE 0 END) AS yearly
|
||||
FROM dm_player_awards
|
||||
GROUP BY steam_id_64
|
||||
ORDER BY yearly DESC, quarterly DESC, monthly DESC,
|
||||
weekly DESC, daily DESC
|
||||
""",
|
||||
)
|
||||
identities = NarrativeService._identity_map(
|
||||
[row['steam_id_64'] for row in rows]
|
||||
)
|
||||
result = []
|
||||
for row in rows:
|
||||
item = dict(row)
|
||||
item['identity'] = identities.get(str(item['steam_id_64']), {})
|
||||
result.append(item)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def get_seasons():
|
||||
rows = query_db(
|
||||
'l3',
|
||||
"""
|
||||
SELECT *
|
||||
FROM dm_team_season_stats
|
||||
ORDER BY season_key DESC
|
||||
""",
|
||||
)
|
||||
identities = NarrativeService._identity_map(
|
||||
[row['top_player_steam_id'] for row in rows]
|
||||
)
|
||||
result = []
|
||||
for row in rows:
|
||||
item = dict(row)
|
||||
item['top_player'] = identities.get(
|
||||
str(item['top_player_steam_id']),
|
||||
{},
|
||||
)
|
||||
result.append(item)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def get_player_honors(steam_id, limit=20):
|
||||
rows = query_db(
|
||||
'l3',
|
||||
"""
|
||||
SELECT *
|
||||
FROM dm_player_awards
|
||||
WHERE steam_id_64 = ?
|
||||
ORDER BY period_start DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
[steam_id, limit],
|
||||
)
|
||||
awards = []
|
||||
for row in rows:
|
||||
item = dict(row)
|
||||
item['award_label'] = NarrativeService.AWARD_TYPE_LABELS.get(
|
||||
item['award_type'],
|
||||
item['award_type'],
|
||||
)
|
||||
awards.append(item)
|
||||
record_rows = query_db(
|
||||
'l3',
|
||||
"""
|
||||
SELECT *
|
||||
FROM dm_player_record_events
|
||||
WHERE steam_id_64 = ?
|
||||
ORDER BY match_date DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
[steam_id, limit],
|
||||
)
|
||||
record_events = []
|
||||
for row in record_rows:
|
||||
item = dict(row)
|
||||
item['record_label'] = NarrativeService.RECORD_LABELS.get(
|
||||
item['record_key'],
|
||||
item['record_key'],
|
||||
)
|
||||
record_events.append(item)
|
||||
return {
|
||||
'awards': awards,
|
||||
'award_count': len(awards),
|
||||
'record_events': record_events,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def get_player_identity(steam_id):
|
||||
feature = query_db(
|
||||
'l3',
|
||||
"""
|
||||
SELECT
|
||||
total_matches,
|
||||
first_match_date,
|
||||
last_match_date,
|
||||
core_top_weapon,
|
||||
meta_map_best_map,
|
||||
tier_percentile
|
||||
FROM dm_player_features
|
||||
WHERE steam_id_64 = ?
|
||||
""",
|
||||
[steam_id],
|
||||
one=True,
|
||||
)
|
||||
roster = query_db(
|
||||
'web',
|
||||
"""
|
||||
SELECT
|
||||
member.member_role,
|
||||
version.name AS roster_version,
|
||||
(
|
||||
SELECT MIN(history_version.effective_from)
|
||||
FROM team_roster_members history_member
|
||||
JOIN team_roster_versions history_version
|
||||
ON history_version.id = history_member.roster_version_id
|
||||
WHERE history_member.steam_id_64 = member.steam_id_64
|
||||
) AS effective_from
|
||||
FROM team_roster_members member
|
||||
JOIN team_roster_versions version
|
||||
ON version.id = member.roster_version_id
|
||||
WHERE member.steam_id_64 = ?
|
||||
ORDER BY version.is_current DESC, version.effective_from DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
[steam_id],
|
||||
one=True,
|
||||
)
|
||||
awards = query_db(
|
||||
'l3',
|
||||
"""
|
||||
SELECT award_type, COUNT(*) AS count
|
||||
FROM dm_player_awards
|
||||
WHERE steam_id_64 = ?
|
||||
GROUP BY award_type
|
||||
""",
|
||||
[steam_id],
|
||||
)
|
||||
result = dict(feature) if feature else {}
|
||||
if roster:
|
||||
result.update(dict(roster))
|
||||
result['awards_by_type'] = {
|
||||
row['award_type']: row['count'] for row in awards
|
||||
}
|
||||
result['award_count'] = sum(result['awards_by_type'].values())
|
||||
return result
|
||||
@@ -0,0 +1,249 @@
|
||||
import json
|
||||
import sqlite3
|
||||
import time
|
||||
|
||||
from web.config import Config
|
||||
|
||||
|
||||
class RosterVersionService:
|
||||
@staticmethod
|
||||
def _connect_web(web_db_path=None):
|
||||
db = sqlite3.connect(web_db_path or Config.DB_WEB_PATH, timeout=30)
|
||||
db.row_factory = sqlite3.Row
|
||||
db.execute('PRAGMA foreign_keys = ON')
|
||||
return db
|
||||
|
||||
@staticmethod
|
||||
def _normalize_ids(player_ids):
|
||||
seen = set()
|
||||
result = []
|
||||
for value in player_ids or []:
|
||||
steam_id = str(value).strip()
|
||||
if steam_id and steam_id not in seen:
|
||||
seen.add(steam_id)
|
||||
result.append(steam_id)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def ensure_initial_version(web_db_path=None, l2_db_path=None):
|
||||
web = RosterVersionService._connect_web(web_db_path)
|
||||
try:
|
||||
current = web.execute(
|
||||
"""
|
||||
SELECT id
|
||||
FROM team_roster_versions
|
||||
WHERE is_current = 1
|
||||
LIMIT 1
|
||||
"""
|
||||
).fetchone()
|
||||
if current:
|
||||
return int(current['id'])
|
||||
|
||||
lineup = web.execute(
|
||||
"""
|
||||
SELECT player_ids_json
|
||||
FROM team_lineups
|
||||
WHERE is_active = 1
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT 1
|
||||
"""
|
||||
).fetchone()
|
||||
if not lineup:
|
||||
return None
|
||||
try:
|
||||
player_ids = RosterVersionService._normalize_ids(
|
||||
json.loads(lineup['player_ids_json'] or '[]')
|
||||
)
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
return None
|
||||
if not player_ids:
|
||||
return None
|
||||
|
||||
effective_from = int(time.time())
|
||||
l2_path = l2_db_path or Config.DB_L2_PATH
|
||||
l2 = sqlite3.connect(l2_path)
|
||||
try:
|
||||
placeholders = ','.join('?' for _ in player_ids)
|
||||
row = l2.execute(
|
||||
f"""
|
||||
SELECT MIN(m.start_time)
|
||||
FROM fact_match_players p
|
||||
JOIN fact_matches m ON m.match_id = p.match_id
|
||||
WHERE p.steam_id_64 IN ({placeholders})
|
||||
""",
|
||||
player_ids,
|
||||
).fetchone()
|
||||
if row and row[0]:
|
||||
effective_from = int(row[0])
|
||||
finally:
|
||||
l2.close()
|
||||
|
||||
cursor = web.execute(
|
||||
"""
|
||||
INSERT INTO team_roster_versions (
|
||||
name, effective_from, is_current, notes
|
||||
) VALUES (?, ?, 1, ?)
|
||||
""",
|
||||
['2.0.0 Beta Initial Roster', effective_from, 'Bootstrapped from active lineup'],
|
||||
)
|
||||
version_id = int(cursor.lastrowid)
|
||||
web.executemany(
|
||||
"""
|
||||
INSERT INTO team_roster_members (
|
||||
roster_version_id, steam_id_64, member_role, position_order
|
||||
) VALUES (?, ?, 'member', ?)
|
||||
""",
|
||||
[
|
||||
(version_id, steam_id, index)
|
||||
for index, steam_id in enumerate(player_ids)
|
||||
],
|
||||
)
|
||||
web.commit()
|
||||
return version_id
|
||||
finally:
|
||||
web.close()
|
||||
|
||||
@staticmethod
|
||||
def snapshot_roster(
|
||||
player_ids,
|
||||
name=None,
|
||||
effective_from=None,
|
||||
roles=None,
|
||||
web_db_path=None,
|
||||
):
|
||||
normalized_ids = RosterVersionService._normalize_ids(player_ids)
|
||||
if not normalized_ids:
|
||||
raise ValueError('Roster version requires at least one player')
|
||||
|
||||
roles = roles or {}
|
||||
effective_from = int(effective_from or time.time())
|
||||
web = RosterVersionService._connect_web(web_db_path)
|
||||
try:
|
||||
current = web.execute(
|
||||
"""
|
||||
SELECT *
|
||||
FROM team_roster_versions
|
||||
WHERE is_current = 1
|
||||
LIMIT 1
|
||||
"""
|
||||
).fetchone()
|
||||
if current:
|
||||
current_ids = [
|
||||
str(row[0]) for row in web.execute(
|
||||
"""
|
||||
SELECT steam_id_64
|
||||
FROM team_roster_members
|
||||
WHERE roster_version_id = ?
|
||||
ORDER BY position_order, steam_id_64
|
||||
""",
|
||||
[current['id']],
|
||||
)
|
||||
]
|
||||
if current_ids == normalized_ids:
|
||||
return int(current['id']), False
|
||||
web.execute(
|
||||
"""
|
||||
UPDATE team_roster_versions
|
||||
SET is_current = 0, effective_to = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
[effective_from - 1, current['id']],
|
||||
)
|
||||
|
||||
cursor = web.execute(
|
||||
"""
|
||||
INSERT INTO team_roster_versions (
|
||||
name, effective_from, is_current
|
||||
) VALUES (?, ?, 1)
|
||||
""",
|
||||
[
|
||||
name or f'Roster {time.strftime("%Y-%m-%d")}',
|
||||
effective_from,
|
||||
],
|
||||
)
|
||||
version_id = int(cursor.lastrowid)
|
||||
values = []
|
||||
for index, steam_id in enumerate(normalized_ids):
|
||||
role = roles.get(steam_id, 'member')
|
||||
if role not in {'starter', 'substitute', 'member'}:
|
||||
role = 'member'
|
||||
values.append((version_id, steam_id, role, index))
|
||||
web.executemany(
|
||||
"""
|
||||
INSERT INTO team_roster_members (
|
||||
roster_version_id, steam_id_64, member_role, position_order
|
||||
) VALUES (?, ?, ?, ?)
|
||||
""",
|
||||
values,
|
||||
)
|
||||
web.commit()
|
||||
return version_id, True
|
||||
except Exception:
|
||||
web.rollback()
|
||||
raise
|
||||
finally:
|
||||
web.close()
|
||||
|
||||
@staticmethod
|
||||
def list_versions(web_db_path=None):
|
||||
web = RosterVersionService._connect_web(web_db_path)
|
||||
try:
|
||||
rows = web.execute(
|
||||
"""
|
||||
SELECT
|
||||
version.*,
|
||||
COUNT(member.steam_id_64) AS member_count
|
||||
FROM team_roster_versions version
|
||||
LEFT JOIN team_roster_members member
|
||||
ON member.roster_version_id = version.id
|
||||
GROUP BY version.id
|
||||
ORDER BY version.effective_from DESC, version.id DESC
|
||||
"""
|
||||
).fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
finally:
|
||||
web.close()
|
||||
|
||||
@staticmethod
|
||||
def get_current_members(web_db_path=None):
|
||||
web = RosterVersionService._connect_web(web_db_path)
|
||||
try:
|
||||
rows = web.execute(
|
||||
"""
|
||||
SELECT member.*, version.name AS version_name
|
||||
FROM team_roster_members member
|
||||
JOIN team_roster_versions version
|
||||
ON version.id = member.roster_version_id
|
||||
WHERE version.is_current = 1
|
||||
ORDER BY member.position_order, member.steam_id_64
|
||||
"""
|
||||
).fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
finally:
|
||||
web.close()
|
||||
|
||||
@staticmethod
|
||||
def update_current_member_role(steam_id, member_role, web_db_path=None):
|
||||
if member_role not in {'starter', 'substitute', 'member'}:
|
||||
raise ValueError('Invalid roster member role')
|
||||
web = RosterVersionService._connect_web(web_db_path)
|
||||
try:
|
||||
cursor = web.execute(
|
||||
"""
|
||||
UPDATE team_roster_members
|
||||
SET member_role = ?
|
||||
WHERE steam_id_64 = ?
|
||||
AND roster_version_id = (
|
||||
SELECT id
|
||||
FROM team_roster_versions
|
||||
WHERE is_current = 1
|
||||
LIMIT 1
|
||||
)
|
||||
""",
|
||||
[member_role, str(steam_id)],
|
||||
)
|
||||
if cursor.rowcount != 1:
|
||||
raise ValueError('Player is not in the current roster version')
|
||||
web.commit()
|
||||
finally:
|
||||
web.close()
|
||||
@@ -0,0 +1,173 @@
|
||||
import json
|
||||
|
||||
from web.database import query_db
|
||||
from web.services.roster_version_service import RosterVersionService
|
||||
from web.services.team_context_service import TeamContextService
|
||||
|
||||
|
||||
class TeamPerformanceService:
|
||||
@staticmethod
|
||||
def _player_identity_map(steam_ids):
|
||||
if not steam_ids:
|
||||
return {}
|
||||
placeholders = ','.join('?' for _ in steam_ids)
|
||||
rows = query_db(
|
||||
'l2',
|
||||
f"""
|
||||
SELECT steam_id_64, username, avatar_url
|
||||
FROM dim_players
|
||||
WHERE steam_id_64 IN ({placeholders})
|
||||
""",
|
||||
steam_ids,
|
||||
)
|
||||
return {str(row['steam_id_64']): dict(row) for row in rows}
|
||||
|
||||
@staticmethod
|
||||
def get_team_summary():
|
||||
roster_ids = TeamContextService.get_active_roster_ids()
|
||||
if not roster_ids:
|
||||
return {
|
||||
'matches': 0,
|
||||
'wins': 0,
|
||||
'win_rate': 0,
|
||||
'avg_rating': 0,
|
||||
'maps': [],
|
||||
}
|
||||
placeholders = ','.join('?' for _ in roster_ids)
|
||||
rows = query_db(
|
||||
'l2',
|
||||
f"""
|
||||
SELECT
|
||||
p.match_id,
|
||||
CASE
|
||||
WHEN p.group_id IN (1, 2) THEN p.group_id
|
||||
WHEN p.team_id IN (1, 2) THEN p.team_id
|
||||
END AS team_key,
|
||||
COUNT(DISTINCT p.steam_id_64) AS roster_count,
|
||||
AVG(p.rating) AS avg_rating,
|
||||
MAX(p.is_win) AS is_win,
|
||||
m.map_name,
|
||||
m.start_time
|
||||
FROM fact_match_players p
|
||||
JOIN fact_matches m ON m.match_id = p.match_id
|
||||
WHERE p.steam_id_64 IN ({placeholders})
|
||||
GROUP BY p.match_id, team_key
|
||||
HAVING roster_count >= 2 AND team_key IS NOT NULL
|
||||
ORDER BY m.start_time DESC
|
||||
""",
|
||||
roster_ids,
|
||||
)
|
||||
matches = len(rows)
|
||||
wins = sum(int(row['is_win'] or 0) for row in rows)
|
||||
map_stats = {}
|
||||
for row in rows:
|
||||
item = map_stats.setdefault(
|
||||
row['map_name'] or 'Unknown',
|
||||
{'map_name': row['map_name'] or 'Unknown', 'matches': 0, 'wins': 0},
|
||||
)
|
||||
item['matches'] += 1
|
||||
item['wins'] += int(row['is_win'] or 0)
|
||||
maps = []
|
||||
for item in map_stats.values():
|
||||
item['win_rate'] = item['wins'] / item['matches']
|
||||
maps.append(item)
|
||||
maps.sort(key=lambda item: item['matches'], reverse=True)
|
||||
return {
|
||||
'matches': matches,
|
||||
'wins': wins,
|
||||
'losses': matches - wins,
|
||||
'win_rate': wins / matches if matches else 0,
|
||||
'avg_rating': (
|
||||
sum(float(row['avg_rating'] or 0) for row in rows) / matches
|
||||
if matches else 0
|
||||
),
|
||||
'maps': maps,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def get_current_roster():
|
||||
members = RosterVersionService.get_current_members()
|
||||
identities = TeamPerformanceService._player_identity_map(
|
||||
[member['steam_id_64'] for member in members]
|
||||
)
|
||||
result = []
|
||||
for member in members:
|
||||
item = dict(member)
|
||||
item.update(identities.get(str(member['steam_id_64']), {}))
|
||||
result.append(item)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def get_top_duos(limit=12):
|
||||
rows = query_db(
|
||||
'l3',
|
||||
"""
|
||||
SELECT *
|
||||
FROM dm_duo_stats
|
||||
ORDER BY sample_reliable DESC, matches DESC,
|
||||
win_rate DESC, avg_combined_rating DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
[limit],
|
||||
)
|
||||
steam_ids = {
|
||||
str(value)
|
||||
for row in rows
|
||||
for value in (row['steam_id_a'], row['steam_id_b'])
|
||||
}
|
||||
identities = TeamPerformanceService._player_identity_map(sorted(steam_ids))
|
||||
result = []
|
||||
for row in rows:
|
||||
item = dict(row)
|
||||
item['player_a'] = identities.get(str(row['steam_id_a']), {})
|
||||
item['player_b'] = identities.get(str(row['steam_id_b']), {})
|
||||
result.append(item)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def get_top_lineups(player_count=None, limit=12):
|
||||
args = []
|
||||
where = ''
|
||||
if player_count:
|
||||
where = 'WHERE player_count = ?'
|
||||
args.append(int(player_count))
|
||||
args.append(limit)
|
||||
rows = query_db(
|
||||
'l3',
|
||||
f"""
|
||||
SELECT *
|
||||
FROM dm_lineup_stats
|
||||
{where}
|
||||
ORDER BY sample_reliable DESC, matches DESC,
|
||||
win_rate DESC, avg_team_rating DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
args,
|
||||
)
|
||||
all_ids = set()
|
||||
parsed = []
|
||||
for row in rows:
|
||||
item = dict(row)
|
||||
item['player_ids'] = [
|
||||
str(value) for value in json.loads(item['player_ids_json'])
|
||||
]
|
||||
all_ids.update(item['player_ids'])
|
||||
parsed.append(item)
|
||||
identities = TeamPerformanceService._player_identity_map(sorted(all_ids))
|
||||
for item in parsed:
|
||||
item['players'] = [
|
||||
identities.get(steam_id, {'username': steam_id})
|
||||
for steam_id in item['player_ids']
|
||||
]
|
||||
return parsed
|
||||
|
||||
@staticmethod
|
||||
def get_dashboard():
|
||||
return {
|
||||
'summary': TeamPerformanceService.get_team_summary(),
|
||||
'roster': TeamPerformanceService.get_current_roster(),
|
||||
'versions': RosterVersionService.list_versions(),
|
||||
'duos': TeamPerformanceService.get_top_duos(),
|
||||
'lineups': TeamPerformanceService.get_top_lineups(),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}战队荣誉 - YRTV{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="space-y-8 px-4 sm:px-0">
|
||||
<div>
|
||||
<p class="text-xs font-bold uppercase tracking-widest text-amber-600">YRTV Awards</p>
|
||||
<h1 class="mt-1 text-3xl font-black text-gray-900 dark:text-white">周期最佳与荣誉榜</h1>
|
||||
<p class="mt-2 text-sm text-gray-500">完全基于已导入比赛。月、季度和年度奖项设有最低场次门槛。</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<a href="{{ url_for('awards.index') }}" class="rounded-full px-4 py-2 text-sm font-bold {% if not award_type %}bg-amber-500 text-white{% else %}bg-white text-gray-600 dark:bg-slate-800 dark:text-gray-300{% endif %}">全部</a>
|
||||
{% for key, label in award_types.items() %}
|
||||
<a href="{{ url_for('awards.index', type=key) }}" class="rounded-full px-4 py-2 text-sm font-bold {% if award_type == key %}bg-amber-500 text-white{% else %}bg-white text-gray-600 dark:bg-slate-800 dark:text-gray-300{% endif %}">{{ label }}</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-8 xl:grid-cols-3">
|
||||
<section class="rounded-2xl bg-gradient-to-br from-amber-500 to-orange-600 p-6 text-white shadow-xl">
|
||||
<h2 class="text-xl font-black">荣誉榜</h2>
|
||||
<div class="mt-5 space-y-3">
|
||||
{% for player in leaderboard[:10] %}
|
||||
<a href="{{ url_for('players.detail', steam_id=player.steam_id_64) }}" class="flex items-center justify-between rounded-xl bg-white/10 p-3 hover:bg-white/20">
|
||||
<div>
|
||||
<div class="font-bold">{{ loop.index }}. {{ player.identity.username or player.steam_id_64 }}</div>
|
||||
<div class="text-xs text-amber-100">
|
||||
年 {{ player.yearly }} · 季 {{ player.quarterly }} · 月 {{ player.monthly }} · 周 {{ player.weekly }} · 日 {{ player.daily }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-2xl font-black">{{ player.awards }}</div>
|
||||
</a>
|
||||
{% else %}
|
||||
<div class="py-8 text-center text-amber-100">暂无奖项</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="space-y-4 xl:col-span-2">
|
||||
{% for award in awards %}
|
||||
<article class="rounded-2xl border border-gray-100 bg-white p-5 shadow dark:border-slate-700 dark:bg-slate-800">
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex h-14 w-14 items-center justify-center rounded-2xl bg-amber-100 text-2xl text-amber-700">🏆</div>
|
||||
<div>
|
||||
<div class="text-xs font-bold uppercase tracking-wider text-amber-600">{{ award.award_label }}</div>
|
||||
<a href="{{ url_for('players.detail', steam_id=award.steam_id_64) }}" class="text-xl font-black text-gray-900 hover:text-yrtv-600 dark:text-white">{{ award.identity.username or award.steam_id_64 }}</a>
|
||||
<div class="text-xs text-gray-400">{{ award.period_key }} · {{ award.matches }} 场</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 gap-4 text-center">
|
||||
<div><div class="text-[10px] uppercase text-gray-400">Rating</div><div class="font-black">{{ '%.2f'|format(award.avg_rating) }}</div></div>
|
||||
<div><div class="text-[10px] uppercase text-gray-400">K/D</div><div class="font-black">{{ '%.2f'|format(award.avg_kd) }}</div></div>
|
||||
<div><div class="text-[10px] uppercase text-gray-400">ADR</div><div class="font-black">{{ '%.1f'|format(award.avg_adr) }}</div></div>
|
||||
<div><div class="text-[10px] uppercase text-gray-400">Win</div><div class="font-black">{{ '%.0f%%'|format(award.win_rate * 100) }}</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
{% else %}
|
||||
<div class="rounded-2xl bg-white py-16 text-center text-gray-400 dark:bg-slate-800">当前筛选没有满足最低场次的奖项。</div>
|
||||
{% endfor %}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -46,6 +46,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('reports.index') }}" class="{% if request.endpoint and 'reports' 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('awards.index') }}" class="{% if request.endpoint and 'awards' in request.endpoint %}border-amber-500 text-gray-900 dark:text-white{% else %}border-transparent text-gray-500 dark:text-gray-300 hover:border-gray-300 hover:text-gray-700 dark:hover:text-white{% endif %} inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">荣誉</a>
|
||||
<a href="{{ url_for('teams.index') }}" class="{% if request.endpoint and 'teams' in request.endpoint %}border-yrtv-500 text-gray-900 dark:text-white{% else %}border-transparent text-gray-500 dark:text-gray-300 hover:border-gray-300 hover:text-gray-700 dark:hover:text-white{% endif %} inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">战队</a>
|
||||
<a href="{{ url_for('opponents.index') }}" class="{% if request.endpoint and 'opponents' in request.endpoint %}border-yrtv-500 text-gray-900 dark:text-white{% else %}border-transparent text-gray-500 dark:text-gray-300 hover:border-gray-300 hover:text-gray-700 dark:hover:text-white{% endif %} inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">对手</a>
|
||||
<a href="{{ url_for('tactics.index') }}" class="{% if request.endpoint and 'tactics' in request.endpoint %}border-yrtv-500 text-gray-900 dark:text-white{% else %}border-transparent text-gray-500 dark:text-gray-300 hover:border-gray-300 hover:text-gray-700 dark:hover:text-white{% endif %} inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">战术</a>
|
||||
@@ -84,6 +86,8 @@
|
||||
<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('reports.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('awards.index') }}" class="border-transparent text-gray-500 hover:bg-gray-50 hover:border-gray-300 hover:text-gray-700 block pl-3 pr-4 py-2 border-l-4 text-base font-medium dark:text-white dark:hover:bg-slate-700">荣誉</a>
|
||||
<a href="{{ url_for('teams.index') }}" class="border-transparent text-gray-500 hover:bg-gray-50 hover:border-gray-300 hover:text-gray-700 block pl-3 pr-4 py-2 border-l-4 text-base font-medium dark:text-white dark:hover:bg-slate-700">战队</a>
|
||||
<a href="{{ url_for('opponents.index') }}" class="border-transparent text-gray-500 hover:bg-gray-50 hover:border-gray-300 hover:text-gray-700 block pl-3 pr-4 py-2 border-l-4 text-base font-medium dark:text-white dark:hover:bg-slate-700">对手</a>
|
||||
<a href="{{ url_for('tactics.index') }}" class="border-transparent text-gray-500 hover:bg-gray-50 hover:border-gray-300 hover:text-gray-700 block pl-3 pr-4 py-2 border-l-4 text-base font-medium dark:text-white dark:hover:bg-slate-700">战术</a>
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
{% if post_match_report %}
|
||||
<section class="rounded-2xl border border-gray-100 bg-white p-6 shadow dark:border-slate-700 dark:bg-slate-800">
|
||||
<div class="flex flex-col gap-5 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="rounded-lg px-3 py-1 text-xs font-black {% if post_match_report.is_win %}bg-green-100 text-green-700{% else %}bg-red-100 text-red-600{% endif %}">
|
||||
{{ 'VICTORY' if post_match_report.is_win else 'DEFEAT' }}
|
||||
</span>
|
||||
<h2 class="text-xl font-black text-gray-900 dark:text-white">赛后报告</h2>
|
||||
</div>
|
||||
<p class="mt-3 max-w-3xl text-sm text-gray-600 dark:text-gray-300">{{ post_match_report.summary_text }}</p>
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-5 text-center">
|
||||
<div><div class="text-2xl font-black text-yrtv-600">{{ '%.2f'|format(post_match_report.team_avg_rating) }}</div><div class="text-[10px] uppercase text-gray-400">Team Rating</div></div>
|
||||
<div><div class="text-2xl font-black text-gray-900 dark:text-white">{{ '%.1f'|format(post_match_report.team_avg_adr) }}</div><div class="text-[10px] uppercase text-gray-400">Team ADR</div></div>
|
||||
<div><div class="text-2xl font-black text-amber-600">{{ post_match_report.record_break_count }}</div><div class="text-[10px] uppercase text-gray-400">Records</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<div class="rounded-xl bg-amber-50 p-4 dark:bg-amber-900/20">
|
||||
<div class="text-xs font-bold uppercase text-amber-600">Match MVP</div>
|
||||
<a href="{{ url_for('players.detail', steam_id=post_match_report.mvp_steam_id) }}" class="mt-1 block text-lg font-black text-gray-900 dark:text-white">{{ post_match_report.mvp.username or post_match_report.mvp_steam_id }}</a>
|
||||
<div class="text-sm text-gray-500">Rating {{ '%.2f'|format(post_match_report.mvp_rating) }}</div>
|
||||
</div>
|
||||
<div class="rounded-xl bg-green-50 p-4 dark:bg-green-900/20">
|
||||
<div class="text-xs font-bold uppercase text-green-600">Form Improver</div>
|
||||
{% if post_match_report.improver_steam_id %}
|
||||
<a href="{{ url_for('players.detail', steam_id=post_match_report.improver_steam_id) }}" class="mt-1 block text-lg font-black text-gray-900 dark:text-white">{{ post_match_report.improver.username or post_match_report.improver_steam_id }}</a>
|
||||
<div class="text-sm text-green-600">Rating {{ '%+.2f'|format(post_match_report.improver_delta) }}</div>
|
||||
{% else %}
|
||||
<div class="mt-1 text-sm text-gray-400">近期样本不足</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="rounded-xl bg-yrtv-50 p-4 dark:bg-yrtv-900/20">
|
||||
<div class="text-xs font-bold uppercase text-yrtv-600">Strongest Duo</div>
|
||||
{% if post_match_report.strongest_duo %}
|
||||
<div class="mt-1 text-lg font-black text-gray-900 dark:text-white">
|
||||
{% for player in post_match_report.strongest_duo.players %}
|
||||
{{ player.username }}{% if not loop.last %} + {% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="text-sm text-gray-500">Rating {{ '%.2f'|format(post_match_report.strongest_duo.avg_rating) }}</div>
|
||||
{% else %}
|
||||
<div class="mt-1 text-sm text-gray-400">本场无二人组</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200 text-sm dark:divide-slate-700">
|
||||
<thead><tr class="text-left text-xs uppercase text-gray-400"><th class="py-2">Player</th><th>Rating</th><th>vs Prior 20</th><th>ADR Δ</th><th>Status</th><th>Record</th></tr></thead>
|
||||
<tbody class="divide-y divide-gray-100 dark:divide-slate-700">
|
||||
{% for player in post_match_report.players %}
|
||||
<tr>
|
||||
<td class="py-3 font-bold"><a href="{{ url_for('players.detail', steam_id=player.steam_id_64) }}" class="hover:text-yrtv-600">{{ player.identity.username or player.steam_id_64 }}</a></td>
|
||||
<td class="font-mono">{{ '%.2f'|format(player.rating or 0) }}</td>
|
||||
<td class="font-mono {% if (player.rating_delta or 0) > 0 %}text-green-600{% elif (player.rating_delta or 0) < 0 %}text-red-500{% endif %}">{{ '%+.2f'|format(player.rating_delta) if player.rating_delta is not none else 'N/A' }}</td>
|
||||
<td class="font-mono">{{ '%+.1f'|format(player.adr_delta) if player.adr_delta is not none else 'N/A' }}</td>
|
||||
<td>{{ player.performance_text }}</td>
|
||||
<td>{% for label in player.record_labels %}<span class="mr-1 rounded bg-amber-100 px-2 py-1 text-[10px] font-bold text-amber-700">{{ label }}</span>{% else %}<span class="text-gray-300">-</span>{% endfor %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
@@ -43,6 +43,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% include "matches/_post_match_report.html" %}
|
||||
|
||||
<!-- Tab: Overview -->
|
||||
<div x-show="tab === 'overview'" class="space-y-6">
|
||||
<!-- Team 1 Stats -->
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
<section class="overflow-hidden rounded-2xl border border-gray-100 bg-gradient-to-r from-slate-900 via-slate-800 to-yrtv-900 p-6 text-white shadow-xl dark:border-slate-700">
|
||||
<div class="flex flex-col gap-6 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<div class="text-xs font-bold uppercase tracking-[0.25em] text-yrtv-300">Professional Identity</div>
|
||||
<div class="mt-2 flex flex-wrap items-center gap-3">
|
||||
<span class="rounded-full bg-white/10 px-3 py-1 text-sm font-bold uppercase">{{ professional_identity.member_role or 'member' }}</span>
|
||||
<span class="text-sm text-slate-300">{{ professional_identity.roster_version or 'Active Roster' }}</span>
|
||||
</div>
|
||||
<div class="mt-4 text-sm text-slate-300">
|
||||
{{ professional_identity.total_matches or 0 }} 场效力 · 队内 Percentile {{ '%.0f%%'|format(professional_identity.tier_percentile or 0) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||
<div class="rounded-xl bg-white/10 p-3">
|
||||
<div class="text-[10px] uppercase text-slate-400">代表武器</div>
|
||||
<div class="mt-1 font-black">{{ professional_identity.core_top_weapon or 'N/A' }}</div>
|
||||
</div>
|
||||
<div class="rounded-xl bg-white/10 p-3">
|
||||
<div class="text-[10px] uppercase text-slate-400">最佳地图</div>
|
||||
<div class="mt-1 font-black">{{ professional_identity.meta_map_best_map or 'N/A' }}</div>
|
||||
</div>
|
||||
<div class="rounded-xl bg-white/10 p-3">
|
||||
<div class="text-[10px] uppercase text-slate-400">荣誉</div>
|
||||
<div class="mt-1 text-2xl font-black text-amber-400">{{ professional_identity.award_count or 0 }}</div>
|
||||
</div>
|
||||
<div class="rounded-xl bg-white/10 p-3">
|
||||
<div class="text-[10px] uppercase text-slate-400">月/季/年最佳</div>
|
||||
<div class="mt-1 font-black text-amber-300">
|
||||
{{ professional_identity.awards_by_type.get('monthly', 0) }}/{{ professional_identity.awards_by_type.get('quarterly', 0) }}/{{ professional_identity.awards_by_type.get('yearly', 0) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if honors.awards %}
|
||||
<div class="mt-5 flex gap-2 overflow-x-auto border-t border-white/10 pt-4">
|
||||
{% for award in honors.awards[:8] %}
|
||||
<a href="{{ url_for('awards.index', type=award.award_type) }}" class="whitespace-nowrap rounded-full bg-amber-400/20 px-3 py-1 text-xs font-bold text-amber-200">
|
||||
🏆 {{ award.period_key }} {{ award.award_label }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
@@ -148,6 +148,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% include "players/_professional_identity.html" %}
|
||||
|
||||
<!-- 1.5 Lifetime Stats (Quantity) -->
|
||||
<div class="bg-white dark:bg-slate-800 shadow-xl rounded-2xl overflow-hidden border border-gray-100 dark:border-slate-700 p-6">
|
||||
<div class="flex flex-col lg:flex-row gap-8 items-center">
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}赛后报告 - YRTV{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="space-y-6 px-4 sm:px-0">
|
||||
<div>
|
||||
<p class="text-xs font-bold uppercase tracking-widest text-yrtv-600">Post Match</p>
|
||||
<h1 class="mt-1 text-3xl font-black text-gray-900 dark:text-white">赛后报告中心</h1>
|
||||
<p class="mt-2 text-sm text-gray-500">表现变化只与该队员比赛发生前的最近 20 场比较。</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
{% for report in reports %}
|
||||
<a href="{{ url_for('matches.detail', match_id=report.match_id) }}" class="rounded-2xl border border-gray-100 bg-white p-5 shadow transition hover:border-yrtv-300 hover:shadow-lg dark:border-slate-700 dark:bg-slate-800">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="rounded px-2 py-1 text-xs font-black {% if report.is_win %}bg-green-100 text-green-700{% else %}bg-red-100 text-red-600{% endif %}">{{ 'WIN' if report.is_win else 'LOSS' }}</span>
|
||||
<span class="font-black text-gray-900 dark:text-white">{{ report.map_name }}</span>
|
||||
</div>
|
||||
<p class="mt-3 text-sm text-gray-600 dark:text-gray-300">{{ report.summary_text }}</p>
|
||||
<div class="mt-3 text-xs text-gray-400">{{ report.roster_count }} 名 roster 队员 · {{ report.record_break_count }} 项纪录刷新</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="text-2xl font-black text-yrtv-600">{{ '%.2f'|format(report.team_avg_rating) }}</div>
|
||||
<div class="text-[10px] uppercase text-gray-400">Team Rating</div>
|
||||
<div class="mt-2 text-xs text-gray-500">MVP {{ report.mvp.username or report.mvp_steam_id }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{% else %}
|
||||
<div class="col-span-full rounded-2xl bg-white py-16 text-center text-gray-400 dark:bg-slate-800">暂无赛后报告</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -13,6 +13,9 @@
|
||||
</h2>
|
||||
</div>
|
||||
<div class="mt-4 flex md:mt-0 md:ml-4">
|
||||
<a href="{{ url_for('teams.performance') }}" class="inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 dark:bg-slate-700 dark:border-slate-600 dark:text-white">
|
||||
Team Career
|
||||
</a>
|
||||
{% 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
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}战队履历 - YRTV{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="space-y-8 px-4 sm:px-0">
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-bold uppercase tracking-widest text-yrtv-600">Team Career</p>
|
||||
<h1 class="mt-1 text-3xl font-black text-gray-900 dark:text-white">战队履历与阵容表现</h1>
|
||||
<p class="mt-2 text-sm text-gray-500">只统计 roster 成员在同一队伍实际共同出场的比赛。</p>
|
||||
</div>
|
||||
<a href="{{ url_for('teams.index') }}" class="rounded-lg bg-yrtv-600 px-4 py-2 text-sm font-bold text-white hover:bg-yrtv-500">
|
||||
返回 Clubhouse
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4 lg:grid-cols-5">
|
||||
{% for label, value, style in [
|
||||
('Matches', summary.matches, ''),
|
||||
('Wins', summary.wins, 'text-green-600'),
|
||||
('Losses', summary.losses, 'text-red-500'),
|
||||
('Win Rate', '%.1f%%'|format(summary.win_rate * 100), 'text-yrtv-600'),
|
||||
('Team Rating', '%.2f'|format(summary.avg_rating), '')
|
||||
] %}
|
||||
<div class="rounded-2xl border border-gray-100 bg-white p-5 shadow dark:border-slate-700 dark:bg-slate-800">
|
||||
<div class="text-xs font-bold uppercase text-gray-400">{{ label }}</div>
|
||||
<div class="mt-2 text-3xl font-black text-gray-900 dark:text-white {{ style }}">{{ value }}</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<section class="rounded-2xl border border-gray-100 bg-white p-6 shadow dark:border-slate-700 dark:bg-slate-800">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-xl font-bold text-gray-900 dark:text-white">战队赛季</h2>
|
||||
<a href="{{ url_for('awards.index') }}" class="text-sm font-bold text-amber-600">查看周期荣誉</a>
|
||||
</div>
|
||||
<div class="mt-5 grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{% for season in seasons %}
|
||||
<div class="rounded-xl bg-gradient-to-br from-slate-900 to-slate-700 p-5 text-white">
|
||||
<div class="text-xs font-bold uppercase tracking-widest text-slate-300">{{ season.season_label }}</div>
|
||||
<div class="mt-3 grid grid-cols-3 gap-3 text-center">
|
||||
<div><div class="text-2xl font-black">{{ season.matches }}</div><div class="text-[10px] uppercase text-slate-400">Matches</div></div>
|
||||
<div><div class="text-2xl font-black text-green-400">{{ '%.0f%%'|format(season.win_rate * 100) }}</div><div class="text-[10px] uppercase text-slate-400">Win</div></div>
|
||||
<div><div class="text-2xl font-black">{{ '%.2f'|format(season.avg_team_rating) }}</div><div class="text-[10px] uppercase text-slate-400">Rating</div></div>
|
||||
</div>
|
||||
<div class="mt-4 border-t border-white/10 pt-3 text-xs text-slate-300">
|
||||
最佳地图 {{ season.best_map }} ({{ '%.0f%%'|format(season.best_map_win_rate * 100) }})<br>
|
||||
赛季选手 {{ season.top_player.username or season.top_player_steam_id }} ({{ '%.2f'|format(season.top_player_rating) }})
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-gray-400">暂无赛季数据</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="rounded-2xl border border-gray-100 bg-white p-6 shadow dark:border-slate-700 dark:bg-slate-800">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-xl font-bold text-gray-900 dark:text-white">当前 Roster Version</h2>
|
||||
{% if versions %}
|
||||
<span class="rounded-full bg-green-100 px-3 py-1 text-xs font-bold text-green-700">{{ versions[0].name }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="mt-5 grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
|
||||
{% for member in roster %}
|
||||
<div class="rounded-xl bg-gray-50 p-3 transition hover:bg-yrtv-50 dark:bg-slate-700/40">
|
||||
<a href="{{ url_for('players.detail', steam_id=member.steam_id_64) }}"
|
||||
class="block truncate font-bold text-gray-900 dark:text-white">{{ member.username or member.steam_id_64 }}</a>
|
||||
{% if session.get('is_admin') %}
|
||||
<form action="{{ url_for('teams.roster_role') }}" method="POST" class="mt-2">
|
||||
<input type="hidden" name="steam_id" value="{{ member.steam_id_64 }}">
|
||||
<select name="member_role" onchange="this.form.submit()"
|
||||
class="w-full rounded border-gray-200 bg-white py-1 text-xs uppercase dark:border-slate-600 dark:bg-slate-700">
|
||||
{% for role in ['starter', 'substitute', 'member'] %}
|
||||
<option value="{{ role }}" {% if member.member_role == role %}selected{% endif %}>{{ role }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</form>
|
||||
{% else %}
|
||||
<div class="mt-1 text-xs uppercase text-gray-400">{{ member.member_role }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="grid grid-cols-1 gap-8 xl:grid-cols-2">
|
||||
<section class="rounded-2xl border border-gray-100 bg-white p-6 shadow dark:border-slate-700 dark:bg-slate-800">
|
||||
<h2 class="text-xl font-bold text-gray-900 dark:text-white">二人组表现</h2>
|
||||
<div class="mt-5 space-y-3">
|
||||
{% for duo in duos %}
|
||||
<div class="flex items-center justify-between rounded-xl bg-gray-50 p-4 dark:bg-slate-700/40">
|
||||
<div>
|
||||
<div class="font-bold text-gray-900 dark:text-white">
|
||||
{{ duo.player_a.username or duo.steam_id_a }} + {{ duo.player_b.username or duo.steam_id_b }}
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-gray-400">{{ duo.matches }} 场 · 平均 Rating {{ '%.2f'|format(duo.avg_combined_rating) }}</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="text-xl font-black {% if duo.win_rate >= 0.5 %}text-green-600{% else %}text-red-500{% endif %}">
|
||||
{{ '%.0f%%'|format(duo.win_rate * 100) }}
|
||||
</div>
|
||||
<div class="text-[10px] text-gray-400">{{ '可靠样本' if duo.sample_reliable else '小样本' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="py-8 text-center text-gray-400">暂无二人组数据</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="rounded-2xl border border-gray-100 bg-white p-6 shadow dark:border-slate-700 dark:bg-slate-800">
|
||||
<h2 class="text-xl font-bold text-gray-900 dark:text-white">实际阵容组合</h2>
|
||||
<div class="mt-5 space-y-3">
|
||||
{% for lineup in lineups %}
|
||||
<div class="rounded-xl bg-gray-50 p-4 dark:bg-slate-700/40">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{% for player in lineup.players %}
|
||||
<span class="rounded bg-white px-2 py-1 text-xs font-bold text-gray-700 dark:bg-slate-600 dark:text-white">{{ player.username }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="mt-2 text-xs text-gray-400">{{ lineup.player_count }} 人 · {{ lineup.matches }} 场 · Rating {{ '%.2f'|format(lineup.avg_team_rating) }}</div>
|
||||
</div>
|
||||
<div class="text-xl font-black {% if lineup.win_rate >= 0.5 %}text-green-600{% else %}text-red-500{% endif %}">
|
||||
{{ '%.0f%%'|format(lineup.win_rate * 100) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="py-8 text-center text-gray-400">暂无阵容组合数据</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="rounded-2xl border border-gray-100 bg-white p-6 shadow dark:border-slate-700 dark:bg-slate-800">
|
||||
<h2 class="text-xl font-bold text-gray-900 dark:text-white">Roster Version 历史</h2>
|
||||
<div class="mt-5 divide-y divide-gray-100 dark:divide-slate-700">
|
||||
{% for version in versions %}
|
||||
<div class="flex items-center justify-between py-3">
|
||||
<div>
|
||||
<div class="font-bold text-gray-900 dark:text-white">{{ version.name }}</div>
|
||||
<div class="text-xs text-gray-400">{{ version.member_count }} members · {{ version.effective_from }}</div>
|
||||
</div>
|
||||
<span class="rounded-full px-3 py-1 text-xs font-bold {% if version.is_current %}bg-green-100 text-green-700{% else %}bg-gray-100 text-gray-500{% endif %}">
|
||||
{{ 'Current' if version.is_current else 'Archived' }}
|
||||
</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user