2.0.0-rc1 : Profile and achievements update.

This commit is contained in:
2026-08-09 00:50:45 +08:00
parent 562775e5db
commit 3874bf57a9
31 changed files with 2618 additions and 108 deletions
+16 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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()
+19
View File
@@ -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,
)
+3 -1
View File
@@ -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):
+9 -1
View File
@@ -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)
+15
View File
@@ -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
View File
@@ -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
+106
View File
@@ -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,
)
+327
View File
@@ -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
+249
View File
@@ -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()
+173
View File
@@ -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(),
}
+66
View File
@@ -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 %}
+4
View File
@@ -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 %}
+2
View File
@@ -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>
+2
View File
@@ -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">
+37
View File
@@ -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 %}
+3
View File
@@ -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
+156
View File
@@ -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 %}