2.0.0-rc2: Achievements Refactored & Admin improved.

This commit is contained in:
2026-08-09 01:34:20 +08:00
parent 3874bf57a9
commit 63a0751aba
29 changed files with 2143 additions and 355 deletions
+20 -2
View File
@@ -25,8 +25,10 @@ YRTV 是面向固定 CS2 战队的私人数据站。它不是公共玩家排行
- 203 次职业纪录刷新事件
- 2 个战队赛季
- 111 个日/周/月/季/年度奖项
- 31 项自动化测试通过
- 39 项数据完整性检查通过
- 19 条正面/负面/趣味数据发现
- 36 枚地图与 ELO 分段勋章
- 36 项自动化测试通过
- 43 项数据完整性检查通过
数据规模会随导入变化,Admin 数据完整性中心显示的结果是运行时事实。
@@ -77,6 +79,16 @@ YRTV 是面向固定 CS2 战队的私人数据站。它不是公共玩家排行
- 个人主页展示荣誉数量和近期奖章
- 每次职业纪录刷新保留时间和对应比赛
### 发现与勋章
- 独立 `/discover/` 发现模块
- 高光、低谷和趣味数据同时展示
- 爆种、尽力局、躺赢、低谷、稳定性、夜间比赛和连胜等发现
- 每张达到门槛的地图评选金银铜
- 每个达到门槛的 ELO 分段评选金银铜
- 勋章最低 5 场,展示 Rating、K/D、ADR、胜率和样本数
- 个人职业主页同步展示已获得勋章
### 数据运营
- Admin 上传 `iframe_network.json`
@@ -85,6 +97,10 @@ YRTV 是面向固定 CS2 战队的私人数据站。它不是公共玩家排行
- 后台执行 L1 → L2 → L3
- 实时查看作业阶段、进度、日志和耗时
- 数据完整性中心与 JSON 报告
- 独立运营总览、比赛导入、作业中心、数据质量、查询控制台和系统信息
- 数据库对象目录与只读 SQL 执行耗时
- L1/L2/L3/Web 文件状态和回滚快照可视化
- 后台写操作使用 CSRF 防护
## 快速开始
@@ -213,6 +229,8 @@ Flask services and player profiles
- `dm_player_record_events`
- `dm_team_season_stats`
- `dm_player_awards`
- `dm_discovery_insights`
- `dm_performance_medals`
### Web:应用状态
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+7
View File
@@ -335,6 +335,13 @@ def main(force_all: bool = False, workers: int = 1, create_backup: bool = True):
processed_ids,
)
logger.info("Narrative marts rebuilt: %s", narrative_counts)
from database.L3.processors.discovery_processor import DiscoveryProcessor
discovery_counts = DiscoveryProcessor.rebuild(
conn_l2,
conn_l3,
processed_ids,
)
logger.info("Discovery marts rebuilt: %s", discovery_counts)
quick_check = conn_l3.execute("PRAGMA quick_check").fetchone()[0]
if quick_check != 'ok':
@@ -0,0 +1,399 @@
from collections import defaultdict
from datetime import datetime, timezone
import json
from statistics import pstdev
class DiscoveryProcessor:
ELO_SEGMENTS = (
(0, 1200, '<1200', '<1200 ELO'),
(1200, 1400, '1200-1399', '1200-1399 ELO'),
(1400, 1600, '1400-1599', '1400-1599 ELO'),
(1600, 1800, '1600-1799', '1600-1799 ELO'),
(1800, 2000, '1800-1999', '1800-1999 ELO'),
(2000, float('inf'), '2000+', '2000+ ELO'),
)
MEDAL_TIERS = {1: 'gold', 2: 'silver', 3: 'bronze'}
@staticmethod
def rebuild(conn_l2, conn_l3, roster_ids):
conn_l3.execute('DELETE FROM dm_discovery_insights')
conn_l3.execute('DELETE FROM dm_performance_medals')
rows = DiscoveryProcessor._load_rows(conn_l2, roster_ids)
insights = DiscoveryProcessor._build_insights(rows)
medals = DiscoveryProcessor._build_medals(rows)
DiscoveryProcessor._insert_insights(conn_l3, insights)
DiscoveryProcessor._insert_medals(conn_l3, medals)
return {
'insights': len(insights),
'medals': len(medals),
}
@staticmethod
def _load_rows(conn_l2, roster_ids):
if not roster_ids:
return []
placeholders = ','.join('?' for _ in roster_ids)
rows = conn_l2.execute(
f"""
SELECT
p.match_id,
p.steam_id_64,
p.rating,
p.kd_ratio,
p.adr,
p.kills,
p.deaths,
p.headshot_count,
p.first_kill,
p.first_death,
p.throw_harm,
p.flash_enemy,
p.is_win,
p.origin_elo,
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})
ORDER BY m.start_time, p.match_id
""",
roster_ids,
).fetchall()
return [dict(row) for row in rows]
@staticmethod
def _elo_segment(value):
value = float(value or 0)
for minimum, maximum, key, label in DiscoveryProcessor.ELO_SEGMENTS:
if minimum <= value < maximum:
return key, label
return None, None
@staticmethod
def _aggregate(rows):
matches = len(rows)
wins = sum(int(row['is_win'] or 0) for row in rows)
kills = sum(int(row['kills'] or 0) for row in rows)
deaths = sum(int(row['deaths'] or 0) for row in rows)
return {
'matches': matches,
'wins': wins,
'win_rate': wins / matches if matches else 0,
'avg_rating': (
sum(float(row['rating'] or 0) for row in rows) / matches
if matches else 0
),
'avg_kd': kills / deaths if deaths else float(kills),
'avg_adr': (
sum(float(row['adr'] or 0) for row in rows) / matches
if matches else 0
),
}
@staticmethod
def _build_medals(rows):
dimensions = defaultdict(lambda: defaultdict(list))
labels = {}
for row in rows:
steam_id = str(row['steam_id_64'])
map_name = row['map_name'] or 'Unknown'
dimensions[('map', map_name)][steam_id].append(row)
labels[('map', map_name)] = map_name
segment_key, segment_label = DiscoveryProcessor._elo_segment(
row['origin_elo']
)
if segment_key:
dimensions[('elo', segment_key)][steam_id].append(row)
labels[('elo', segment_key)] = segment_label
medals = []
for (dimension_type, dimension_key), player_rows in dimensions.items():
candidates = []
for steam_id, matches in player_rows.items():
if len(matches) < 5:
continue
stats = DiscoveryProcessor._aggregate(matches)
candidates.append((steam_id, stats))
candidates.sort(
key=lambda item: (
item[1]['avg_rating'],
item[1]['avg_adr'],
item[1]['matches'],
),
reverse=True,
)
for rank, (steam_id, stats) in enumerate(candidates[:3], 1):
medals.append({
'dimension_type': dimension_type,
'dimension_key': dimension_key,
'dimension_label': labels[(dimension_type, dimension_key)],
'medal_rank': rank,
'medal_tier': DiscoveryProcessor.MEDAL_TIERS[rank],
'steam_id_64': steam_id,
**stats,
'sample_reliable': int(stats['matches'] >= 10),
})
return medals
@staticmethod
def _streaks(rows):
win_best = loss_best = win_current = loss_current = 0
for row in rows:
if row['is_win']:
win_current += 1
loss_current = 0
else:
loss_current += 1
win_current = 0
win_best = max(win_best, win_current)
loss_best = max(loss_best, loss_current)
return win_best, loss_best
@staticmethod
def _player_stats(rows):
grouped = defaultdict(list)
for row in rows:
grouped[str(row['steam_id_64'])].append(row)
result = {}
for steam_id, matches in grouped.items():
ratings = [float(row['rating'] or 0) for row in matches]
night_matches = [
row for row in matches
if datetime.fromtimestamp(
int(row['start_time']),
timezone.utc,
).hour in {23, 0, 1, 2, 3, 4, 5}
]
win_streak, loss_streak = DiscoveryProcessor._streaks(matches)
map_rows = defaultdict(list)
for row in matches:
map_rows[row['map_name'] or 'Unknown'].append(row)
eligible_maps = [
(map_name, DiscoveryProcessor._aggregate(map_matches))
for map_name, map_matches in map_rows.items()
if len(map_matches) >= 5
]
best_map = (
max(eligible_maps, key=lambda item: item[1]['avg_rating'])
if eligible_maps else None
)
result[steam_id] = {
'matches': len(matches),
'carry_losses': sum(
1 for row in matches
if not row['is_win'] and float(row['rating'] or 0) >= 1.2
),
'monster_games': sum(
1 for row in matches
if float(row['rating'] or 0) >= 1.5
),
'rough_games': sum(
1 for row in matches
if float(row['rating'] or 0) < 0.7
),
'lucky_wins': sum(
1 for row in matches
if row['is_win'] and float(row['rating'] or 0) < 0.8
),
'opening_balance': sum(
int(row['first_kill'] or 0)
- int(row['first_death'] or 0)
for row in matches
),
'rating_volatility': pstdev(ratings) if len(ratings) > 1 else 0,
'night_matches': len(night_matches),
'night_share': len(night_matches) / len(matches),
'win_streak': win_streak,
'loss_streak': loss_streak,
'best_map': best_map,
'best_game': max(
matches,
key=lambda row: float(row['rating'] or 0),
),
}
return result
@staticmethod
def _insight(
key,
steam_id,
tone,
category,
title,
description,
metric_label,
metric_value,
metric_unit='',
match_id=None,
evidence=None,
order=0,
):
return {
'insight_key': key,
'steam_id_64': steam_id,
'tone': tone,
'category': category,
'title': title,
'description': description,
'metric_label': metric_label,
'metric_value': float(metric_value),
'metric_unit': metric_unit,
'match_id': match_id,
'evidence_json': json.dumps(evidence or {}, ensure_ascii=False),
'display_order': order,
}
@staticmethod
def _build_insights(rows):
stats = DiscoveryProcessor._player_stats(rows)
if not stats:
return []
insights = []
definitions = (
(
'monster_games', max, 'positive', '爆发',
'爆种制造机', 'Rating ≥ 1.50 的比赛次数全队最多。',
'爆种局', '', 10,
),
(
'carry_losses', max, 'positive', '抗压',
'逆风尽力王', '失利时 Rating ≥ 1.20 的比赛次数全队最多。',
'尽力局', '', 20,
),
(
'opening_balance', max, 'positive', '突破',
'开门红专家', '生涯首杀减首死净值全队最高。',
'FK-FD', '', 30,
),
(
'rough_games', max, 'negative', '低谷',
'低谷收藏家', 'Rating < 0.70 的比赛次数全队最多。',
'低迷局', '', 40,
),
(
'lucky_wins', max, 'fun', '趣味',
'躺赢许可证', '赢球但个人 Rating < 0.80 的次数全队最多。',
'幸运胜场', '', 50,
),
(
'rating_volatility', max, 'fun', '稳定性',
'过山车选手', 'Rating 标准差全队最高,状态最有悬念。',
'波动', '', 60,
),
(
'rating_volatility', min, 'positive', '稳定性',
'定海神针', 'Rating 标准差全队最低,发挥最稳定。',
'波动', '', 70,
),
(
'night_share', max, 'fun', '时段',
'夜猫子', '23:00-05:59 UTC 比赛占比全队最高。',
'夜间占比', '%', 80,
),
(
'win_streak', max, 'positive', '连胜',
'连胜发动机', '个人最长连续胜场全队最高。',
'最长连胜', '', 90,
),
(
'loss_streak', max, 'negative', '连败',
'逆境耐受测试', '个人最长连续败场全队最高。',
'最长连败', '', 100,
),
)
for metric, selector, tone, category, title, description, label, unit, order in definitions:
steam_id, player = selector(
stats.items(),
key=lambda item: item[1][metric],
)
value = player[metric]
display_value = value * 100 if unit == '%' else value
if display_value <= 0:
continue
insights.append(DiscoveryProcessor._insight(
f'global:{metric}:{selector.__name__}',
steam_id,
tone,
category,
title,
description,
label,
display_value,
unit,
evidence={'matches': player['matches']},
order=order,
))
for index, (steam_id, player) in enumerate(stats.items(), 1):
if not player['best_map']:
continue
map_name, map_stats = player['best_map']
insights.append(DiscoveryProcessor._insight(
f'player:{steam_id}:map_specialist',
steam_id,
'positive',
'地图',
f'{map_name} 地头蛇',
'个人至少 5 场地图中,平均 Rating 最高的一张。',
'地图 Rating',
map_stats['avg_rating'],
'',
evidence={
'map': map_name,
'matches': map_stats['matches'],
'win_rate': map_stats['win_rate'],
},
order=120 + index,
))
return insights
@staticmethod
def _insert_insights(conn_l3, rows):
conn_l3.executemany(
"""
INSERT INTO dm_discovery_insights (
insight_key, steam_id_64, tone, category, title,
description, metric_label, metric_value, metric_unit,
match_id, evidence_json, display_order
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
[
(
row['insight_key'], row['steam_id_64'], row['tone'],
row['category'], row['title'], row['description'],
row['metric_label'], row['metric_value'],
row['metric_unit'], row['match_id'],
row['evidence_json'], row['display_order'],
)
for row in rows
],
)
@staticmethod
def _insert_medals(conn_l3, rows):
conn_l3.executemany(
"""
INSERT INTO dm_performance_medals (
dimension_type, dimension_key, dimension_label,
medal_rank, medal_tier, steam_id_64, matches, wins,
win_rate, avg_rating, avg_kd, avg_adr, sample_reliable
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
[
(
row['dimension_type'], row['dimension_key'],
row['dimension_label'], row['medal_rank'],
row['medal_tier'], row['steam_id_64'], row['matches'],
row['wins'], row['win_rate'], row['avg_rating'],
row['avg_kd'], row['avg_adr'], row['sample_reliable'],
)
for row in rows
],
)
+57
View File
@@ -587,6 +587,61 @@ ON dm_player_awards(steam_id_64, period_start DESC);
CREATE INDEX IF NOT EXISTS idx_player_awards_period
ON dm_player_awards(award_type, period_start DESC);
-- ============================================================================
-- Discovery Mart: Data-driven fun facts, both positive and negative
-- ============================================================================
CREATE TABLE IF NOT EXISTS dm_discovery_insights (
insight_key TEXT PRIMARY KEY,
steam_id_64 TEXT,
tone TEXT NOT NULL
CHECK (tone IN ('positive', 'negative', 'fun')),
category TEXT NOT NULL,
title TEXT NOT NULL,
description TEXT NOT NULL,
metric_label TEXT,
metric_value REAL,
metric_unit TEXT,
match_id TEXT,
evidence_json TEXT NOT NULL DEFAULT '{}',
display_order INTEGER NOT NULL DEFAULT 0,
last_updated TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_discovery_tone_order
ON dm_discovery_insights(tone, display_order, insight_key);
CREATE INDEX IF NOT EXISTS idx_discovery_player
ON dm_discovery_insights(steam_id_64, tone);
-- ============================================================================
-- Medal Mart: Gold/silver/bronze by map and ELO segment
-- ============================================================================
CREATE TABLE IF NOT EXISTS dm_performance_medals (
dimension_type TEXT NOT NULL
CHECK (dimension_type IN ('map', 'elo')),
dimension_key TEXT NOT NULL,
dimension_label TEXT NOT NULL,
medal_rank INTEGER NOT NULL CHECK (medal_rank BETWEEN 1 AND 3),
medal_tier TEXT NOT NULL
CHECK (medal_tier IN ('gold', 'silver', 'bronze')),
steam_id_64 TEXT NOT NULL,
matches INTEGER NOT NULL,
wins INTEGER NOT NULL,
win_rate REAL,
avg_rating REAL,
avg_kd REAL,
avg_adr REAL,
sample_reliable BOOLEAN NOT NULL DEFAULT 0,
last_updated TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (dimension_type, dimension_key, medal_rank)
);
CREATE INDEX IF NOT EXISTS idx_performance_medals_player
ON dm_performance_medals(steam_id_64, dimension_type, medal_rank);
CREATE INDEX IF NOT EXISTS idx_performance_medals_dimension
ON dm_performance_medals(dimension_type, dimension_key, medal_rank);
-- ============================================================================
-- Schema Summary
-- ============================================================================
@@ -609,4 +664,6 @@ ON dm_player_awards(award_type, period_start DESC);
-- dm_player_record_events: Historical record-breaking moments
-- dm_team_season_stats: Calendar season team summaries
-- dm_player_awards: Daily/weekly/monthly/quarterly/yearly awards
-- dm_discovery_insights: Positive, negative and quirky data discoveries
-- dm_performance_medals: Map and ELO-segment gold/silver/bronze medals
-- ============================================================================
Binary file not shown.
+51 -8
View File
@@ -48,19 +48,63 @@ class JobStore:
finally:
db.close()
def list_jobs(self, limit: int = 20) -> List[Dict[str, Any]]:
def list_jobs(
self,
limit: int = 20,
status: Optional[str] = None,
job_type: Optional[str] = None,
offset: int = 0,
) -> List[Dict[str, Any]]:
db = self._connect()
try:
where = []
args = []
if status:
where.append('status = ?')
args.append(status)
if job_type:
where.append('job_type = ?')
args.append(job_type)
where_sql = f"WHERE {' AND '.join(where)}" if where else ''
args.extend([
max(1, min(int(limit), 100)),
max(0, int(offset)),
])
rows = db.execute(
f"""
SELECT *
FROM etl_jobs
{where_sql}
ORDER BY created_at DESC, id DESC
LIMIT ? OFFSET ?
""",
args,
).fetchall()
return [dict(row) for row in rows]
finally:
db.close()
def get_summary(self) -> Dict[str, int]:
db = self._connect()
try:
rows = db.execute(
"""
SELECT *
SELECT status, COUNT(*) AS count
FROM etl_jobs
ORDER BY created_at DESC, id DESC
LIMIT ?
""",
[max(1, min(int(limit), 100))],
GROUP BY status
"""
).fetchall()
return [dict(row) for row in rows]
summary = {
'total': 0,
'queued': 0,
'running': 0,
'succeeded': 0,
'failed': 0,
}
for row in rows:
summary[row['status']] = int(row['count'])
summary['total'] += int(row['count'])
return summary
finally:
db.close()
@@ -195,4 +239,3 @@ class JobStore:
db.commit()
finally:
db.close()
+163 -2
View File
@@ -46,6 +46,7 @@ class ApplicationIntegrationTests(unittest.TestCase):
'/matches/',
'/reports/',
'/awards/',
'/discover/',
'/players/',
f'/players/{self.roster_ids[0]}',
'/teams/',
@@ -59,6 +60,35 @@ class ApplicationIntegrationTests(unittest.TestCase):
self.assertEqual(response.status_code, 200)
self.assertGreater(len(response.data), 100)
def test_discovery_filters_and_player_medals_render(self):
response = self.client.get('/discover/')
self.assertEqual(response.status_code, 200)
for label in ('趣味发现', '表现勋章墙', '勋章总榜'):
self.assertIn(label.encode('utf-8'), response.data)
response = self.client.get('/discover/?tone=negative')
self.assertEqual(response.status_code, 200)
self.assertIn('低谷收藏家'.encode('utf-8'), response.data)
response = self.client.get('/discover/?dimension=elo')
self.assertEqual(response.status_code, 200)
self.assertIn(b'ELO Medal', response.data)
l3 = sqlite3.connect(Config.DB_L3_PATH)
try:
medal_player = l3.execute(
"""
SELECT steam_id_64
FROM dm_performance_medals
ORDER BY medal_rank
LIMIT 1
"""
).fetchone()[0]
finally:
l3.close()
response = self.client.get(f'/players/{medal_player}')
self.assertIn(b'Map & ELO Medals', response.data)
def test_match_detail_contains_post_match_report(self):
l3 = sqlite3.connect(Config.DB_L3_PATH)
try:
@@ -103,10 +133,11 @@ class ApplicationIntegrationTests(unittest.TestCase):
def test_admin_integrity_page_and_json_render(self):
with self.client.session_transaction() as session:
session['is_admin'] = True
session['_csrf_token'] = 'test-csrf'
response = self.client.get('/admin/data-integrity')
self.assertEqual(response.status_code, 200)
self.assertIn('数据完整性中心'.encode('utf-8'), response.data)
self.assertIn('数据质量中心'.encode('utf-8'), response.data)
response = self.client.get('/admin/data-integrity?format=json')
self.assertEqual(response.status_code, 200)
@@ -120,13 +151,66 @@ class ApplicationIntegrationTests(unittest.TestCase):
response = self.client.get('/admin/import-match')
self.assertEqual(response.status_code, 200)
self.assertIn('比赛数据导入'.encode('utf-8'), response.data)
self.assertIn('比赛导入'.encode('utf-8'), response.data)
def test_admin_console_pages_share_navigation_and_real_data(self):
with self.client.session_transaction() as session:
session['is_admin'] = True
session['_csrf_token'] = 'test-csrf'
pages = {
'/admin/': ('运营总览', '完整数据流水线'),
'/admin/jobs': ('作业中心', '执行记录'),
'/admin/sql': ('只读查询控制台', 'Database Objects'),
'/admin/system': ('系统与存储', '回滚快照'),
}
for path, labels in pages.items():
with self.subTest(path=path):
response = self.client.get(path)
self.assertEqual(response.status_code, 200)
self.assertIn('YRTV'.encode('utf-8'), response.data)
for label in labels:
self.assertIn(label.encode('utf-8'), response.data)
def test_admin_sql_is_read_only_and_csrf_protected(self):
with self.client.session_transaction() as session:
session['is_admin'] = True
session['_csrf_token'] = 'test-csrf'
response = self.client.post(
'/admin/sql',
data={
'_csrf_token': 'test-csrf',
'db_name': 'l2',
'query': 'SELECT COUNT(*) AS matches FROM fact_matches',
},
)
self.assertEqual(response.status_code, 200)
self.assertIn(b'matches', response.data)
self.assertIn(b'208', response.data)
response = self.client.post(
'/admin/sql',
data={
'_csrf_token': 'test-csrf',
'db_name': 'l2',
'query': 'DELETE FROM fact_matches',
},
)
self.assertEqual(response.status_code, 200)
self.assertIn(b'Only SELECT queries are allowed', response.data)
response = self.client.post(
'/admin/trigger_etl',
headers={'Accept': 'application/json'},
)
self.assertEqual(response.status_code, 400)
def test_duplicate_match_upload_is_rejected_without_starting_job(self):
from database.paths import L1_DB
with self.client.session_transaction() as session:
session['is_admin'] = True
session['_csrf_token'] = 'test-csrf'
with sqlite3.connect(str(L1_DB)) as db:
raw = db.execute(
"""
@@ -142,6 +226,7 @@ class ApplicationIntegrationTests(unittest.TestCase):
response = self.client.post(
'/admin/import-match',
data={
'_csrf_token': 'test-csrf',
'capture': (
upload,
'iframe_network.json',
@@ -178,6 +263,8 @@ class ApplicationIntegrationTests(unittest.TestCase):
):
with self.subTest(label=label):
self.assertIn(label.encode('utf-8'), response.data)
self.assertIn(b'x-data="{ selected: \'last_20\' }"', response.data)
self.assertNotIn(b'periods: [{', response.data)
def test_profile_period_api_and_trend_window(self):
steam_id = self.roster_ids[0]
@@ -699,6 +786,80 @@ class DatabaseGovernanceTests(unittest.TestCase):
self.assertNotIn((award_type, period_key), seen)
seen.add((award_type, period_key))
def test_discovery_tones_and_medal_rules(self):
l3 = sqlite3.connect(Config.DB_L3_PATH)
try:
tones = {
row[0] for row in l3.execute(
'SELECT DISTINCT tone FROM dm_discovery_insights'
)
}
medals = l3.execute(
"""
SELECT dimension_type, dimension_key, medal_rank,
medal_tier, matches
FROM dm_performance_medals
"""
).fetchall()
finally:
l3.close()
self.assertEqual(tones, {'positive', 'negative', 'fun'})
self.assertGreater(len(medals), 0)
seen = set()
expected_tiers = {1: 'gold', 2: 'silver', 3: 'bronze'}
for dimension_type, dimension_key, rank, tier, matches in medals:
key = (dimension_type, dimension_key, rank)
self.assertNotIn(key, seen)
seen.add(key)
self.assertIn(dimension_type, {'map', 'elo'})
self.assertEqual(tier, expected_tiers[rank])
self.assertGreaterEqual(matches, 5)
def test_map_gold_medal_matches_highest_eligible_rating(self):
l3 = sqlite3.connect(Config.DB_L3_PATH)
l2 = sqlite3.connect(Config.DB_L2_PATH)
web = sqlite3.connect(Config.DB_WEB_PATH)
try:
medal = l3.execute(
"""
SELECT dimension_key, steam_id_64, avg_rating
FROM dm_performance_medals
WHERE dimension_type = 'map' AND medal_rank = 1
ORDER BY dimension_key
LIMIT 1
"""
).fetchone()
roster_ids = json.loads(
web.execute(
"""
SELECT player_ids_json FROM team_lineups
WHERE is_active = 1
"""
).fetchone()[0]
)
placeholders = ','.join('?' for _ in roster_ids)
expected = l2.execute(
f"""
SELECT p.steam_id_64, AVG(p.rating) AS avg_rating
FROM fact_match_players p
JOIN fact_matches m ON m.match_id = p.match_id
WHERE p.steam_id_64 IN ({placeholders})
AND m.map_name = ?
GROUP BY p.steam_id_64
HAVING COUNT(*) >= 5
ORDER BY avg_rating DESC, AVG(p.adr) DESC, COUNT(*) DESC
LIMIT 1
""",
roster_ids + [medal[0]],
).fetchone()
finally:
l3.close()
l2.close()
web.close()
self.assertEqual(medal[1], expected[0])
self.assertAlmostEqual(medal[2], expected[1], places=8)
def test_seasons_only_include_team_matches(self):
l2 = sqlite3.connect(Config.DB_L2_PATH)
l3 = sqlite3.connect(Config.DB_L3_PATH)
+4
View File
@@ -6,6 +6,7 @@ sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
from flask import Flask
from web.config import Config
from web.database import close_dbs, initialize_web_db
from web.auth import get_csrf_token
def create_app(config_object=Config):
@@ -16,10 +17,12 @@ def create_app(config_object=Config):
from web.services.roster_version_service import RosterVersionService
RosterVersionService.ensure_initial_version()
app.teardown_appcontext(close_dbs)
app.jinja_env.globals['csrf_token'] = get_csrf_token
from web.routes import (
admin,
awards,
discover,
main,
matches,
opponents,
@@ -39,6 +42,7 @@ def create_app(config_object=Config):
app.register_blueprint(opponents.bp)
app.register_blueprint(reports.bp)
app.register_blueprint(awards.bp)
app.register_blueprint(discover.bp)
return app
+33 -1
View File
@@ -1,5 +1,8 @@
from functools import wraps
from flask import session, redirect, url_for, flash
import hmac
import secrets
from flask import abort, flash, request, session, redirect, url_for
def admin_required(f):
@wraps(f)
@@ -9,3 +12,32 @@ def admin_required(f):
flash('Admin access required', 'warning')
return redirect(url_for('admin.login'))
return decorated_function
def get_csrf_token():
token = session.get('_csrf_token')
if not token:
token = secrets.token_urlsafe(32)
session['_csrf_token'] = token
return token
def validate_csrf():
expected = session.get('_csrf_token')
received = (
request.headers.get('X-CSRF-Token')
or request.form.get('_csrf_token')
)
if not expected or not received or not hmac.compare_digest(
str(expected),
str(received),
):
abort(400, description='Invalid CSRF token')
def csrf_protected(f):
@wraps(f)
def decorated_function(*args, **kwargs):
validate_csrf()
return f(*args, **kwargs)
return decorated_function
+71 -14
View File
@@ -1,15 +1,27 @@
from flask import Blueprint, render_template, request, redirect, url_for, session, flash, jsonify
from flask import (
Blueprint,
flash,
jsonify,
redirect,
render_template,
request,
session,
url_for,
)
from web.config import Config
from web.auth import admin_required
from web.auth import admin_required, csrf_protected, validate_csrf
from web.database import query_db
from web.services.admin_service import AdminService
from web.services.etl_service import EtlService
import hmac
import time
bp = Blueprint('admin', __name__, url_prefix='/admin')
@bp.route('/login', methods=['GET', 'POST'])
def login():
if request.method == 'POST':
validate_csrf()
token = request.form.get('token') or ''
if hmac.compare_digest(token, Config.ADMIN_TOKEN):
session['is_admin'] = True
@@ -26,7 +38,10 @@ def logout():
@bp.route('/')
@admin_required
def dashboard():
return render_template('admin/dashboard.html')
return render_template(
'admin/dashboard.html',
**AdminService.get_overview(),
)
@bp.route('/data-integrity')
@admin_required
@@ -40,6 +55,7 @@ def data_integrity():
@bp.route('/trigger_etl', methods=['POST'])
@admin_required
@csrf_protected
def trigger_etl():
from database.job_store import JobStore
@@ -68,6 +84,7 @@ def import_match():
store = JobStore(Config.DB_WEB_PATH)
if request.method == 'POST':
validate_csrf()
upload = request.files.get('capture')
if not upload or not upload.filename:
flash('请选择 iframe_network.json 文件。', 'error')
@@ -91,7 +108,7 @@ def import_match():
'success',
)
return redirect(url_for(
'admin.import_match',
'admin.jobs',
job_id=prepared['job_id'],
))
except (DuplicateMatchError, ImportValidationError) as exc:
@@ -106,14 +123,34 @@ def import_match():
)
flash(f'启动导入失败:{exc}', 'error')
selected_job = None
selected_job_id = request.args.get('job_id', type=int)
if selected_job_id:
selected_job = store.get_job(selected_job_id)
return render_template(
'admin/import_match.html',
jobs=store.list_jobs(30),
selected_job=selected_job,
jobs=store.list_jobs(5),
)
@bp.route('/jobs')
@admin_required
def jobs():
from database.job_store import JobStore
status = request.args.get('status') or None
job_type = request.args.get('type') or None
selected_job_id = request.args.get('job_id', type=int)
data = AdminService.get_jobs(status, job_type)
data['selected_job'] = (
JobStore(Config.DB_WEB_PATH).get_job(selected_job_id)
if selected_job_id else None
)
return render_template('admin/jobs.html', **data)
@bp.route('/system')
@admin_required
def system():
return render_template(
'admin/system.html',
**AdminService.get_system_status(),
)
@@ -133,9 +170,14 @@ def sql_runner():
result = None
error = None
query = ""
db_name = "l2"
db_name = request.args.get('db_name', 'l2')
if db_name not in {'l2', 'l3', 'web'}:
db_name = 'l2'
duration_ms = None
row_count = None
if request.method == 'POST':
validate_csrf()
query = (request.form.get('query') or '').strip()
db_name = request.form.get('db_name', 'l2')
@@ -151,8 +193,10 @@ def sql_runner():
query = statement
if 'LIMIT' not in statement.upper():
query = f"{statement} LIMIT 50"
started = time.perf_counter()
rows = query_db(db_name, query)
duration_ms = (time.perf_counter() - started) * 1000
row_count = len(rows)
if rows:
columns = rows[0].keys()
result = {'columns': columns, 'rows': rows}
@@ -160,5 +204,18 @@ def sql_runner():
result = {'columns': [], 'rows': []}
except Exception as e:
error = str(e)
return render_template('admin/sql.html', result=result, error=error, query=query, db_name=db_name)
try:
catalog = AdminService.get_database_catalog(db_name)
except ValueError:
catalog = []
return render_template(
'admin/sql.html',
result=result,
error=error,
query=query,
db_name=db_name,
catalog=catalog,
duration_ms=duration_ms,
row_count=row_count,
)
+24
View File
@@ -0,0 +1,24 @@
from flask import Blueprint, render_template, request
from web.services.discovery_service import DiscoveryService
bp = Blueprint('discover', __name__, url_prefix='/discover')
@bp.route('/')
def index():
tone = request.args.get('tone')
dimension = request.args.get('dimension')
if dimension not in {'map', 'elo'}:
dimension = None
return render_template(
'discover/index.html',
insights=DiscoveryService.get_insights(tone),
medals=DiscoveryService.get_medals(dimension),
leaderboard=DiscoveryService.get_medal_leaderboard(),
tone=tone,
dimension=dimension,
tone_labels=DiscoveryService.TONE_LABELS,
)
+2
View File
@@ -3,6 +3,7 @@ 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.discovery_service import DiscoveryService
from web.services.web_service import WebService
from web.database import execute_db, query_db
from web.config import Config
@@ -205,6 +206,7 @@ def detail(steam_id):
records=records,
professional_identity=NarrativeService.get_player_identity(steam_id),
honors=NarrativeService.get_player_honors(steam_id),
performance_medals=DiscoveryService.get_player_medals(steam_id),
l2_stats=l2_stats,
side_stats=side_stats)
+175
View File
@@ -0,0 +1,175 @@
from datetime import datetime
import json
from pathlib import Path
import sqlite3
from database.job_store import JobStore
from database.maintenance import (
backup_storage_status,
check_managed_databases,
)
from database.paths import BACKUP_ROOT
from web.config import Config
from web.database import query_db
from web.services.integrity_service import IntegrityService
from web.services.roster_version_service import RosterVersionService
from web.services.team_context_service import TeamContextService
class AdminService:
DATABASE_LABELS = {
'l2': 'L2 Facts',
'l3': 'L3 Marts',
'web': 'Web App',
}
@staticmethod
def _format_bytes(value):
value = float(value or 0)
for unit in ('B', 'KB', 'MB', 'GB'):
if value < 1024 or unit == 'GB':
return f'{value:.1f} {unit}'
value /= 1024
@staticmethod
def get_overview():
integrity = IntegrityService.build_report()
store = JobStore(Config.DB_WEB_PATH)
latest_report = query_db(
'l3',
"""
SELECT match_id, match_date, map_name, is_win,
team_avg_rating, summary_text
FROM dm_match_reports
ORDER BY match_date DESC
LIMIT 1
""",
one=True,
)
latest_match = dict(latest_report) if latest_report else None
if latest_match:
latest_match['date_text'] = datetime.fromtimestamp(
latest_match['match_date']
).strftime('%Y-%m-%d %H:%M')
counts = integrity['counts']
return {
'integrity': integrity,
'jobs': store.list_jobs(8),
'job_summary': store.get_summary(),
'latest_match': latest_match,
'roster_size': len(TeamContextService.get_active_roster_ids()),
'roster_versions': len(RosterVersionService.list_versions()),
'metrics': {
'matches': counts.get('matches', 0),
'roster_players': counts.get('active_roster', 0),
'reports': counts.get('l3_match_reports', 0),
'awards': counts.get('l3_awards', 0),
'backup_size': AdminService._format_bytes(
counts.get('backup_bytes', 0)
),
},
}
@staticmethod
def get_jobs(status=None, job_type=None, limit=50):
store = JobStore(Config.DB_WEB_PATH)
return {
'jobs': store.list_jobs(
limit=limit,
status=status,
job_type=job_type,
),
'summary': store.get_summary(),
'status': status,
'job_type': job_type,
}
@staticmethod
def get_database_catalog(db_name):
if db_name not in {'l2', 'l3', 'web'}:
raise ValueError('Unknown database')
rows = query_db(
db_name,
"""
SELECT name, type
FROM sqlite_master
WHERE type IN ('table', 'view')
AND name NOT LIKE 'sqlite_%'
ORDER BY type, name
""",
)
return [dict(row) for row in rows]
@staticmethod
def get_system_status():
managed = check_managed_databases()
web_status = {
'path': Config.DB_WEB_PATH,
'exists': Path(Config.DB_WEB_PATH).exists(),
'size_bytes': (
Path(Config.DB_WEB_PATH).stat().st_size
if Path(Config.DB_WEB_PATH).exists() else 0
),
}
databases = []
for name, item in managed.items():
entry = dict(item)
entry['name'] = name.upper()
entry['size_text'] = AdminService._format_bytes(
entry['size_bytes']
)
databases.append(entry)
web_status.update({
'name': 'WEB',
'quick_check': 'managed by app',
'size_text': AdminService._format_bytes(web_status['size_bytes']),
})
databases.append(web_status)
backup_dirs = sorted(
[path for path in BACKUP_ROOT.glob('*') if path.is_dir()],
key=lambda path: path.stat().st_mtime,
reverse=True,
)
backups = []
for directory in backup_dirs:
manifest_path = directory / 'manifest.json'
manifest = {}
if manifest_path.exists():
try:
manifest = json.loads(
manifest_path.read_text(encoding='utf-8')
)
except (OSError, json.JSONDecodeError):
manifest = {}
size = sum(
file.stat().st_size
for file in directory.rglob('*')
if file.is_file()
)
backups.append({
'name': directory.name,
'created_at': manifest.get('created_at'),
'size_text': AdminService._format_bytes(size),
'databases': manifest.get('databases', {}),
})
return {
'databases': databases,
'backups': backups,
'backup_summary': backup_storage_status(),
'config': {
'web_schema_version': Config.WEB_SCHEMA_VERSION,
'sqlite_timeout': Config.SQLITE_TIMEOUT_SECONDS,
'slow_query_threshold': Config.SLOW_QUERY_THRESHOLD_SECONDS,
'max_upload_mb': Config.MAX_CONTENT_LENGTH / 1024 / 1024,
'secret_key_configured': (
Config.SECRET_KEY != 'yrtv-dev-only-change-me'
),
'admin_token_configured': (
Config.ADMIN_TOKEN != 'yrtv-admin-dev'
),
},
}
+163
View File
@@ -0,0 +1,163 @@
import json
from web.database import query_db
class DiscoveryService:
TONE_LABELS = {
'positive': '高光',
'negative': '低谷',
'fun': '趣味',
}
MEDAL_LABELS = {
'gold': '金牌',
'silver': '银牌',
'bronze': '铜牌',
}
@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_insights(tone=None):
args = []
where = ''
if tone in DiscoveryService.TONE_LABELS:
where = 'WHERE tone = ?'
args.append(tone)
rows = query_db(
'l3',
f"""
SELECT *
FROM dm_discovery_insights
{where}
ORDER BY display_order, insight_key
""",
args,
)
identities = DiscoveryService._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['tone_label'] = DiscoveryService.TONE_LABELS.get(
item['tone'],
item['tone'],
)
try:
item['evidence'] = json.loads(item['evidence_json'] or '{}')
except json.JSONDecodeError:
item['evidence'] = {}
result.append(item)
return result
@staticmethod
def get_medals(dimension_type=None):
args = []
where = ''
if dimension_type in {'map', 'elo'}:
where = 'WHERE dimension_type = ?'
args.append(dimension_type)
rows = query_db(
'l3',
f"""
SELECT *
FROM dm_performance_medals
{where}
ORDER BY
CASE dimension_type WHEN 'map' THEN 1 ELSE 2 END,
dimension_key,
medal_rank
""",
args,
)
identities = DiscoveryService._identity_map(
[row['steam_id_64'] for row in rows]
)
grouped = []
current_key = None
current = None
for row in rows:
item = dict(row)
item['identity'] = identities.get(str(item['steam_id_64']), {})
item['medal_label'] = DiscoveryService.MEDAL_LABELS.get(
item['medal_tier'],
item['medal_tier'],
)
key = (item['dimension_type'], item['dimension_key'])
if key != current_key:
current = {
'dimension_type': item['dimension_type'],
'dimension_key': item['dimension_key'],
'dimension_label': item['dimension_label'],
'medals': [],
}
grouped.append(current)
current_key = key
current['medals'].append(item)
return grouped
@staticmethod
def get_medal_leaderboard():
rows = query_db(
'l3',
"""
SELECT
steam_id_64,
COUNT(*) AS medals,
SUM(CASE WHEN medal_tier = 'gold' THEN 1 ELSE 0 END) AS gold,
SUM(CASE WHEN medal_tier = 'silver' THEN 1 ELSE 0 END) AS silver,
SUM(CASE WHEN medal_tier = 'bronze' THEN 1 ELSE 0 END) AS bronze
FROM dm_performance_medals
GROUP BY steam_id_64
ORDER BY gold DESC, silver DESC, bronze DESC
""",
)
identities = DiscoveryService._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_player_medals(steam_id):
rows = query_db(
'l3',
"""
SELECT *
FROM dm_performance_medals
WHERE steam_id_64 = ?
ORDER BY medal_rank, dimension_type, dimension_key
""",
[steam_id],
)
result = []
for row in rows:
item = dict(row)
item['medal_label'] = DiscoveryService.MEDAL_LABELS.get(
item['medal_tier'],
item['medal_tier'],
)
result.append(item)
return result
+57
View File
@@ -246,6 +246,7 @@ class IntegrityService:
@staticmethod
def _check_l3(db, roster_ids, checks, counts):
required_tables = {
'dm_discovery_insights',
'dm_duo_stats',
'dm_match_player_reports',
'dm_match_reports',
@@ -257,6 +258,7 @@ class IntegrityService:
'dm_player_record_events',
'dm_player_records',
'dm_player_weapon_stats',
'dm_performance_medals',
'dm_lineup_stats',
'dm_team_season_stats',
}
@@ -309,6 +311,12 @@ class IntegrityService:
counts['l3_awards'] = db.execute(
'SELECT COUNT(*) FROM dm_player_awards'
).fetchone()[0]
counts['l3_discoveries'] = db.execute(
'SELECT COUNT(*) FROM dm_discovery_insights'
).fetchone()[0]
counts['l3_medals'] = db.execute(
'SELECT COUNT(*) FROM dm_performance_medals'
).fetchone()[0]
expected_matches = counts.get('matches', 0)
IntegrityService._check(
checks,
@@ -443,6 +451,8 @@ class IntegrityService:
('l3_record_events', 'Record event mart'),
('l3_seasons', 'Team season mart'),
('l3_awards', 'Player award mart'),
('l3_discoveries', 'Discovery insight mart'),
('l3_medals', 'Map and ELO medal mart'),
):
value = counts[key]
IntegrityService._check(
@@ -453,6 +463,53 @@ class IntegrityService:
value,
)
discovery_tones = {
row[0] for row in db.execute(
'SELECT DISTINCT tone FROM dm_discovery_insights'
)
}
IntegrityService._check(
checks,
'Discovery tone coverage',
(
'pass'
if discovery_tones == {'positive', 'negative', 'fun'}
else 'fail'
),
f"tones: {', '.join(sorted(discovery_tones))}",
len(discovery_tones),
)
invalid_medals = db.execute(
"""
SELECT COUNT(*)
FROM dm_performance_medals
WHERE matches < 5
OR medal_rank NOT BETWEEN 1 AND 3
"""
).fetchone()[0]
duplicate_medals = db.execute(
"""
SELECT COUNT(*)
FROM (
SELECT dimension_type, dimension_key, medal_rank, COUNT(*) AS n
FROM dm_performance_medals
GROUP BY dimension_type, dimension_key, medal_rank
HAVING n > 1
)
"""
).fetchone()[0]
IntegrityService._check(
checks,
'Performance medal validity',
'pass' if invalid_medals == 0 and duplicate_medals == 0 else 'fail',
(
f'{invalid_medals} invalid samples, '
f'{duplicate_medals} duplicate ranks'
),
invalid_medals + duplicate_medals,
)
if roster_ids:
placeholders = ','.join('?' for _ in roster_ids)
scope_sql = f"AND steam_id_64 IN ({placeholders})"
+86
View File
@@ -0,0 +1,86 @@
{% extends "base.html" %}
{% block title %}{% block admin_title %}Admin{% endblock %} - YRTV{% endblock %}
{% block content %}
{% set admin_nav = [
('admin.dashboard', '总览', 'Overview'),
('admin.import_match', '比赛导入', 'Import'),
('admin.jobs', '作业中心', 'Jobs'),
('admin.data_integrity', '数据质量', 'Integrity'),
('admin.sql_runner', '查询控制台', 'Query'),
('admin.system', '系统信息', 'System')
] %}
<div class="min-h-[calc(100vh-8rem)] px-3 sm:px-0">
<div class="overflow-hidden rounded-2xl border border-slate-200 bg-slate-100 shadow-xl dark:border-slate-700 dark:bg-slate-950">
<div class="grid min-h-[760px] grid-cols-1 lg:grid-cols-[240px_minmax(0,1fr)]">
<aside class="border-b border-slate-200 bg-slate-950 text-slate-300 lg:border-b-0 lg:border-r lg:border-slate-800">
<div class="flex items-center justify-between px-5 py-5">
<div>
<div class="text-xs font-black uppercase tracking-[0.25em] text-yrtv-400">YRTV</div>
<div class="mt-1 text-lg font-black text-white">Operations</div>
</div>
<span class="rounded-full bg-amber-400/10 px-2 py-1 text-[10px] font-bold uppercase text-amber-300">Beta</span>
</div>
<nav class="flex gap-1 overflow-x-auto px-3 pb-4 lg:block lg:space-y-1 lg:overflow-visible">
{% for endpoint, label, english in admin_nav %}
{% set active = request.endpoint == endpoint %}
<a href="{{ url_for(endpoint) }}"
class="group flex min-w-max items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-bold transition
{% if active %}bg-yrtv-600 text-white shadow-lg shadow-yrtv-950/30
{% else %}text-slate-400 hover:bg-slate-800 hover:text-white{% endif %}">
<span class="flex h-7 w-7 items-center justify-center rounded-lg {% if active %}bg-white/15{% else %}bg-slate-800 group-hover:bg-slate-700{% endif %}">
{{ loop.index|string }}
</span>
<span>
<span class="block">{{ label }}</span>
<span class="block text-[10px] font-medium uppercase tracking-wider opacity-60">{{ english }}</span>
</span>
</a>
{% endfor %}
</nav>
<div class="hidden border-t border-slate-800 p-4 lg:block">
<a href="{{ url_for('main.index') }}" class="block rounded-lg px-3 py-2 text-sm font-bold text-slate-400 hover:bg-slate-800 hover:text-white">返回站点</a>
<a href="{{ url_for('admin.logout') }}" class="mt-1 block rounded-lg px-3 py-2 text-sm font-bold text-red-400 hover:bg-red-950/40">退出后台</a>
</div>
</aside>
<div class="min-w-0">
<header class="border-b border-slate-200 bg-white/90 px-5 py-4 backdrop-blur dark:border-slate-800 dark:bg-slate-900/90">
<div class="flex items-center justify-between gap-4">
<div>
<h1 class="text-xl font-black text-slate-900 dark:text-white">{% block page_title %}管理后台{% endblock %}</h1>
<p class="mt-0.5 text-xs text-slate-500">{% block page_subtitle %}YRTV 数据运营控制台{% endblock %}</p>
</div>
<div class="flex items-center gap-2">
<a href="{{ url_for('admin.data_integrity') }}" class="rounded-full border border-slate-200 bg-slate-50 px-3 py-1.5 text-xs font-bold text-slate-600 hover:border-emerald-300 hover:text-emerald-600 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300">系统状态</a>
<a href="{{ url_for('main.index') }}" class="rounded-lg bg-slate-900 px-3 py-2 text-xs font-bold text-white hover:bg-slate-700 dark:bg-white dark:text-slate-900">打开前台</a>
</div>
</div>
</header>
<main class="p-4 sm:p-6 lg:p-8">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="mb-6 space-y-2">
{% for category, message in messages %}
<div class="rounded-xl border px-4 py-3 text-sm font-medium
{% if category == 'success' %}border-emerald-200 bg-emerald-50 text-emerald-800
{% elif category == 'warning' %}border-amber-200 bg-amber-50 text-amber-800
{% else %}border-red-200 bg-red-50 text-red-800{% endif %}">
{{ message }}
</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
{% block admin_content %}{% endblock %}
</main>
</div>
</div>
</div>
</div>
{% endblock %}
+137 -47
View File
@@ -1,57 +1,147 @@
{% extends "base.html" %}
{% extends "admin/base.html" %}
{% block content %}
<div class="bg-white dark:bg-slate-800 shadow rounded-lg p-6">
<div class="flex justify-between items-center mb-6">
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">管理后台 (Admin Dashboard)</h2>
<a href="{{ url_for('admin.logout') }}" class="text-red-600 hover:text-red-800">Logout</a>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- ETL Controls -->
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<h3 class="text-lg font-bold text-gray-900 dark:text-white mb-4">数据管线 (ETL)</h3>
<div class="space-y-2">
<a href="{{ url_for('admin.import_match') }}" class="block w-full text-center bg-yrtv-600 text-white py-2 px-4 rounded hover:bg-yrtv-500">上传并导入比赛</a>
<button onclick="triggerEtl()" class="w-full bg-blue-600 text-white py-2 px-4 rounded hover:bg-blue-700">运行完整 L1 → L2 → L3</button>
</div>
<div id="etlResult" class="mt-4 text-sm text-gray-600 dark:text-gray-400"></div>
{% block admin_title %}运营总览{% endblock %}
{% block page_title %}运营总览{% endblock %}
{% block page_subtitle %}数据规模、流水线状态与最近比赛一屏掌握{% endblock %}
{% block admin_content %}
{% set health_color = 'emerald' if integrity.overall_status == 'pass' else ('amber' if integrity.overall_status == 'warn' else 'red') %}
<div class="space-y-7">
<section class="grid grid-cols-2 gap-3 xl:grid-cols-5">
{% for label, value, note in [
('比赛总量', metrics.matches, 'L2 matches'),
('Active Roster', metrics.roster_players, roster_versions|string + ' roster version'),
('赛后报告', metrics.reports, 'L3 reports'),
('周期奖项', metrics.awards, 'Awards generated'),
('备份占用', metrics.backup_size, integrity.counts.backup_sets|string + ' snapshots')
] %}
<div class="rounded-2xl border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-800 dark:bg-slate-900">
<div class="text-xs font-bold uppercase tracking-wider text-slate-400">{{ label }}</div>
<div class="mt-2 text-3xl font-black text-slate-900 dark:text-white">{{ value }}</div>
<div class="mt-1 truncate text-[11px] text-slate-400">{{ note }}</div>
</div>
<!-- Tools -->
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<h3 class="text-lg font-bold text-gray-900 dark:text-white mb-4">工具箱</h3>
<div class="space-y-2">
<a href="{{ url_for('admin.data_integrity') }}" class="block w-full text-center bg-emerald-600 text-white py-2 px-4 rounded hover:bg-emerald-700">数据完整性中心</a>
<a href="{{ url_for('admin.sql_runner') }}" class="block w-full text-center bg-gray-600 text-white py-2 px-4 rounded hover:bg-gray-700">SQL Runner</a>
<a href="{{ url_for('wiki.index') }}" class="block w-full text-center bg-gray-600 text-white py-2 px-4 rounded hover:bg-gray-700">Manage Wiki</a>
{% endfor %}
</section>
<div class="grid grid-cols-1 gap-6 xl:grid-cols-3">
<section class="overflow-hidden rounded-2xl bg-gradient-to-br from-slate-950 via-slate-900 to-yrtv-950 p-6 text-white shadow-xl xl:col-span-2">
<div class="flex flex-col gap-6 sm:flex-row sm:items-start sm:justify-between">
<div>
<div class="text-xs font-black uppercase tracking-[0.22em] text-yrtv-300">Data Pipeline</div>
<h2 class="mt-2 text-2xl font-black">完整数据流水线</h2>
<p class="mt-2 max-w-xl text-sm leading-6 text-slate-300">运行前自动备份 L1/L2/L3,串行构建并验证 {{ integrity.checks|length }} 项数据质量规则。失败时自动恢复。</p>
</div>
<div class="rounded-2xl bg-white/10 px-4 py-3 text-right">
<div class="text-xs uppercase text-slate-400">Running</div>
<div class="text-2xl font-black {% if job_summary.running %}text-amber-300{% else %}text-emerald-300{% endif %}">{{ job_summary.running }}</div>
</div>
</div>
</div>
<div class="mt-6 flex flex-wrap gap-3">
<a href="{{ url_for('admin.import_match') }}" class="rounded-xl bg-yrtv-500 px-5 py-3 text-sm font-black text-white hover:bg-yrtv-400">导入新比赛</a>
<button id="pipeline-button" onclick="triggerPipeline()" class="rounded-xl bg-white px-5 py-3 text-sm font-black text-slate-900 hover:bg-slate-100 disabled:cursor-not-allowed disabled:opacity-50" {% if job_summary.running %}disabled{% endif %}>运行完整流水线</button>
<a href="{{ url_for('admin.jobs') }}" class="rounded-xl border border-white/20 px-5 py-3 text-sm font-bold text-white hover:bg-white/10">查看所有作业</a>
</div>
<p id="pipeline-message" class="mt-3 text-xs text-slate-400"></p>
</section>
<a href="{{ url_for('admin.data_integrity') }}" class="group rounded-2xl border border-{{ health_color }}-200 bg-{{ health_color }}-50 p-6 shadow-sm transition hover:-translate-y-0.5 hover:shadow-md dark:border-{{ health_color }}-900/50 dark:bg-{{ health_color }}-950/20">
<div class="flex items-center justify-between">
<div class="text-xs font-black uppercase tracking-widest text-{{ health_color }}-600">Data Health</div>
<span class="rounded-full bg-{{ health_color }}-100 px-3 py-1 text-xs font-black uppercase text-{{ health_color }}-700">{{ integrity.overall_status }}</span>
</div>
<div class="mt-5 flex items-end gap-3">
<div class="text-5xl font-black text-{{ health_color }}-700">{{ integrity.totals.pass }}</div>
<div class="pb-1 text-sm text-{{ health_color }}-700">项检查通过</div>
</div>
<div class="mt-4 grid grid-cols-2 gap-2 text-xs">
<div class="rounded-lg bg-white/70 p-2 text-slate-600">警告 <b>{{ integrity.totals.warn }}</b></div>
<div class="rounded-lg bg-white/70 p-2 text-slate-600">失败 <b>{{ integrity.totals.fail }}</b></div>
</div>
<div class="mt-4 text-xs font-bold text-{{ health_color }}-700 group-hover:underline">查看完整报告 →</div>
</a>
</div>
<div class="grid grid-cols-1 gap-6 xl:grid-cols-3">
<section class="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900 xl:col-span-2">
<div class="flex items-center justify-between border-b border-slate-100 px-5 py-4 dark:border-slate-800">
<div>
<h2 class="font-black text-slate-900 dark:text-white">最近作业</h2>
<p class="text-xs text-slate-400">成功 {{ job_summary.succeeded }} · 失败 {{ job_summary.failed }}</p>
</div>
<a href="{{ url_for('admin.jobs') }}" class="text-xs font-bold text-yrtv-600 hover:underline">作业中心</a>
</div>
<div class="divide-y divide-slate-100 dark:divide-slate-800">
{% for job in jobs %}
<a href="{{ url_for('admin.jobs', job_id=job.id) }}" class="flex items-center gap-4 px-5 py-3.5 hover:bg-slate-50 dark:hover:bg-slate-800/50">
<span class="h-2.5 w-2.5 rounded-full {% if job.status == 'succeeded' %}bg-emerald-500{% elif job.status == 'failed' %}bg-red-500{% elif job.status == 'running' %}bg-amber-400 animate-pulse{% else %}bg-slate-300{% endif %}"></span>
<div class="min-w-0 flex-1">
<div class="truncate text-sm font-bold text-slate-800 dark:text-slate-200">#{{ job.id }} {{ job.job_type }}</div>
<div class="truncate text-xs text-slate-400">{{ job.message or job.current_stage or '等待执行' }}</div>
</div>
<div class="text-right text-xs text-slate-400">
<div class="font-bold uppercase">{{ job.status }}</div>
<div>{{ '%.2fs'|format(job.duration_seconds) if job.duration_seconds is not none else job.progress|string + '%' }}</div>
</div>
</a>
{% else %}
<div class="px-5 py-10 text-center text-sm text-slate-400">暂无流水线作业</div>
{% endfor %}
</div>
</section>
<section class="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900">
<div class="text-xs font-black uppercase tracking-widest text-slate-400">Latest Match</div>
{% if latest_match %}
<div class="mt-4 flex items-center justify-between">
<span class="rounded-lg px-2.5 py-1 text-xs font-black {% if latest_match.is_win %}bg-emerald-100 text-emerald-700{% else %}bg-red-100 text-red-600{% endif %}">{{ 'WIN' if latest_match.is_win else 'LOSS' }}</span>
<span class="text-xs text-slate-400">{{ latest_match.date_text }}</span>
</div>
<div class="mt-4 text-2xl font-black text-slate-900 dark:text-white">{{ latest_match.map_name }}</div>
<div class="mt-1 text-sm text-slate-500">Team Rating {{ '%.2f'|format(latest_match.team_avg_rating) }}</div>
<p class="mt-4 line-clamp-3 text-xs leading-5 text-slate-500">{{ latest_match.summary_text }}</p>
<a href="{{ url_for('matches.detail', match_id=latest_match.match_id) }}" class="mt-4 block text-xs font-black text-yrtv-600 hover:underline">打开赛后报告 →</a>
{% else %}
<div class="mt-8 text-sm text-slate-400">暂无比赛数据</div>
{% endif %}
</section>
</div>
<section class="grid grid-cols-2 gap-3 sm:grid-cols-4">
{% for endpoint, title, note in [
('admin.sql_runner', '查询数据', '只读 SQL Console'),
('admin.system', '系统信息', '数据库与备份'),
('teams.performance', '战队履历', 'Roster & Lineup'),
('awards.index', '荣誉中心', '周期最佳')
] %}
<a href="{{ url_for(endpoint) }}" class="rounded-xl border border-slate-200 bg-white p-4 transition hover:border-yrtv-300 hover:shadow-sm dark:border-slate-800 dark:bg-slate-900">
<div class="font-black text-slate-900 dark:text-white">{{ title }}</div>
<div class="mt-1 text-xs text-slate-400">{{ note }}</div>
</a>
{% endfor %}
</section>
</div>
{% endblock %}
{% block scripts %}
{{ super() }}
<script>
function triggerEtl() {
const resultDiv = document.getElementById('etlResult');
resultDiv.innerText = "正在创建后台流水线...";
fetch("{{ url_for('admin.trigger_etl') }}", {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
})
.then(response => response.json())
.then(data => {
if (data.success) {
window.location.href = "{{ url_for('admin.import_match') }}?job_id=" + data.job_id;
} else {
resultDiv.innerText = data.error || "启动失败";
}
})
.catch(err => {
resultDiv.innerText = "Error: " + err;
});
async function triggerPipeline() {
const button = document.getElementById('pipeline-button');
const message = document.getElementById('pipeline-message');
button.disabled = true;
message.textContent = '正在创建后台流水线...';
try {
const response = await fetch("{{ url_for('admin.trigger_etl') }}", {
method: 'POST',
headers: {'X-CSRF-Token': "{{ csrf_token() }}"}
});
const data = await response.json();
if (!response.ok || !data.success) throw new Error(data.error || '启动失败');
window.location.href = "{{ url_for('admin.jobs') }}?job_id=" + data.job_id;
} catch (error) {
message.textContent = error.message;
button.disabled = false;
}
}
</script>
{% endblock %}
+88 -67
View File
@@ -1,84 +1,105 @@
{% extends "base.html" %}
{% extends "admin/base.html" %}
{% block title %}数据完整性 - YRTV{% endblock %}
{% block admin_title %}数据质量{% endblock %}
{% block page_title %}数据质量中心{% endblock %}
{% block page_subtitle %}数据库健康、引用完整性、Mart 覆盖与运行治理{% endblock %}
{% block content %}
{% block admin_content %}
{% set status_styles = {
'pass': 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-300',
'warn': 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300',
'fail': 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300'
'pass': 'bg-emerald-100 text-emerald-700 border-emerald-200',
'warn': 'bg-amber-100 text-amber-700 border-amber-200',
'fail': 'bg-red-100 text-red-700 border-red-200'
} %}
<div class="space-y-6 px-4 sm:px-0">
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<div class="flex items-center gap-3">
<h1 class="text-2xl font-bold text-slate-900 dark:text-white">数据完整性中心</h1>
<span class="rounded-full px-3 py-1 text-xs font-semibold uppercase {{ status_styles[report.overall_status] }}">
{{ report.overall_status }}
</span>
<div class="space-y-6">
<section class="overflow-hidden rounded-2xl bg-gradient-to-r {% if report.overall_status == 'pass' %}from-emerald-600 to-teal-700{% elif report.overall_status == 'warn' %}from-amber-500 to-orange-600{% else %}from-red-600 to-rose-700{% endif %} p-6 text-white shadow-xl">
<div class="flex flex-col gap-5 sm:flex-row sm:items-center sm:justify-between">
<div>
<div class="text-xs font-black uppercase tracking-[0.2em] text-white/70">Integrity Report</div>
<div class="mt-2 flex items-baseline gap-3">
<div class="text-5xl font-black">{{ report.totals.pass }}</div>
<div class="text-lg font-bold">项检查通过</div>
</div>
<div class="mt-2 text-sm text-white/70">生成于 {{ report.generated_at }}</div>
</div>
<div class="flex gap-3">
<div class="rounded-xl bg-white/10 px-5 py-3 text-center"><div class="text-2xl font-black">{{ report.totals.warn }}</div><div class="text-[10px] uppercase text-white/70">Warnings</div></div>
<div class="rounded-xl bg-white/10 px-5 py-3 text-center"><div class="text-2xl font-black">{{ report.totals.fail }}</div><div class="text-[10px] uppercase text-white/70">Failures</div></div>
</div>
<p class="mt-1 text-sm text-slate-500 dark:text-slate-400">
校验时间:{{ report.generated_at }}
</p>
</div>
<div class="flex gap-2">
<a href="{{ url_for('admin.data_integrity', format='json') }}"
class="rounded-lg border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50 dark:border-slate-600 dark:text-slate-200 dark:hover:bg-slate-800">
JSON
</a>
<a href="{{ url_for('admin.data_integrity') }}"
class="rounded-lg bg-yrtv-600 px-4 py-2 text-sm font-medium text-white hover:bg-yrtv-500">
重新校验
</a>
<div class="mt-6 flex gap-3">
<a href="{{ url_for('admin.data_integrity') }}" class="rounded-xl bg-white px-4 py-2 text-sm font-black text-slate-900">重新检查</a>
<a href="{{ url_for('admin.data_integrity', format='json') }}" target="_blank" class="rounded-xl border border-white/30 px-4 py-2 text-sm font-bold text-white">JSON 报告</a>
</div>
</div>
</section>
<div class="grid grid-cols-3 gap-3">
{% for status, label in [('pass', '通过'), ('warn', '警告'), ('fail', '失败')] %}
<div class="rounded-xl bg-white p-4 shadow dark:bg-slate-800">
<div class="text-sm text-slate-500 dark:text-slate-400">{{ label }}</div>
<div class="mt-1 text-3xl font-bold {% if status == 'pass' %}text-emerald-600{% elif status == 'warn' %}text-amber-600{% else %}text-red-600{% endif %}">
{{ report.totals[status] }}
</div>
<section class="grid grid-cols-2 gap-3 sm:grid-cols-3 xl:grid-cols-6">
{% for label, value in [
('Matches', report.counts.matches),
('Players', report.counts.players),
('Rounds', report.counts.rounds),
('Events', report.counts.events),
('Reports', report.counts.l3_match_reports),
('Awards', report.counts.l3_awards)
] %}
<div class="rounded-xl border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-800 dark:bg-slate-900">
<div class="text-[10px] font-black uppercase tracking-wider text-slate-400">{{ label }}</div>
<div class="mt-1 text-2xl font-black text-slate-900 dark:text-white">{{ '{:,}'.format(value or 0) }}</div>
</div>
{% endfor %}
</div>
</section>
<div class="rounded-xl bg-white p-5 shadow dark:bg-slate-800">
<h2 class="mb-4 text-lg font-semibold text-slate-900 dark:text-white">数据规模</h2>
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
{% for key, value in report.counts.items() %}
<div class="rounded-lg bg-slate-50 p-3 dark:bg-slate-900/60">
<div class="truncate text-xs uppercase tracking-wide text-slate-500">{{ key|replace('_', ' ') }}</div>
<div class="mt-1 text-xl font-semibold text-slate-900 dark:text-white">{{ "{:,}".format(value) }}</div>
<div class="grid grid-cols-1 gap-6 xl:grid-cols-[minmax(0,1fr)_300px]">
<section class="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900">
<div class="border-b border-slate-100 px-5 py-4 dark:border-slate-800">
<h2 class="font-black text-slate-900 dark:text-white">检查明细</h2>
<p class="text-xs text-slate-400">失败优先,其次警告,最后通过项</p>
</div>
{% endfor %}
</div>
</div>
<div class="overflow-hidden rounded-xl bg-white shadow dark:bg-slate-800">
<div class="border-b border-slate-200 px-5 py-4 dark:border-slate-700">
<h2 class="text-lg font-semibold text-slate-900 dark:text-white">校验项目</h2>
</div>
<div class="divide-y divide-slate-100 dark:divide-slate-700">
{% for check in report.checks %}
<div class="flex flex-col gap-2 px-5 py-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<div class="font-medium text-slate-900 dark:text-white">{{ check.name }}</div>
<div class="mt-1 text-sm text-slate-500 dark:text-slate-400">{{ check.detail }}</div>
</div>
<span class="self-start rounded-full px-3 py-1 text-xs font-semibold uppercase sm:self-center {{ status_styles[check.status] }}">
{{ check.status }}
</span>
<div class="divide-y divide-slate-100 dark:divide-slate-800">
{% for target_status in ['fail', 'warn', 'pass'] %}
{% for check in report.checks if check.status == target_status %}
<div class="flex flex-col gap-3 px-5 py-4 sm:flex-row sm:items-center sm:justify-between">
<div class="min-w-0">
<div class="font-bold text-slate-800 dark:text-slate-200">{{ check.name }}</div>
<div class="mt-1 text-xs leading-5 text-slate-400">{{ check.detail }}</div>
</div>
<span class="self-start rounded-full border px-2.5 py-1 text-[10px] font-black uppercase sm:self-center {{ status_styles[check.status] }}">{{ check.status }}</span>
</div>
{% endfor %}
{% endfor %}
</div>
{% endfor %}
</div>
</div>
</section>
<div>
<a href="{{ url_for('admin.dashboard') }}" class="text-sm font-medium text-yrtv-600 hover:text-yrtv-500">
返回管理后台
</a>
<aside class="space-y-4">
{% for title, entries in [
('Roster & Team', [
('Active roster', report.counts.active_roster),
('Roster versions', report.counts.roster_versions),
('Duo marts', report.counts.l3_duos),
('Lineup marts', report.counts.l3_lineups)
]),
('Profile Marts', [
('History', report.counts.l3_history),
('Periods', report.counts.l3_periods),
('Records', report.counts.l3_records),
('Record events', report.counts.l3_record_events)
]),
('Operations', [
('ETL jobs', report.counts.etl_jobs),
('Imports', report.counts.match_imports),
('Backups', report.counts.backup_sets),
('Web schema', report.counts.web_schema_version)
])
] %}
<section class="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900">
<h3 class="text-xs font-black uppercase tracking-wider text-slate-400">{{ title }}</h3>
<dl class="mt-3 space-y-2">
{% for label, value in entries %}
<div class="flex items-center justify-between"><dt class="text-sm text-slate-500">{{ label }}</dt><dd class="font-mono text-sm font-black text-slate-800 dark:text-slate-200">{{ '{:,}'.format(value or 0) }}</dd></div>
{% endfor %}
</dl>
</section>
{% endfor %}
</aside>
</div>
</div>
{% endblock %}
+79 -136
View File
@@ -1,153 +1,96 @@
{% extends "base.html" %}
{% extends "admin/base.html" %}
{% block title %}比赛导入 - YRTV{% endblock %}
{% block admin_title %}比赛导入{% endblock %}
{% block page_title %}比赛导入{% endblock %}
{% block page_subtitle %}校验网络抓包并启动可回滚的 L1 → L2 → L3 流水线{% endblock %}
{% block content %}
<div class="space-y-6 px-4 sm:px-0">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="space-y-2">
{% for category, message in messages %}
<div class="rounded-lg px-4 py-3 text-sm {% if category == 'success' %}bg-emerald-100 text-emerald-800{% elif category == 'warning' %}bg-amber-100 text-amber-800{% else %}bg-red-100 text-red-800{% endif %}">
{{ message }}
{% block admin_content %}
<div class="grid grid-cols-1 gap-6 xl:grid-cols-[minmax(0,1fr)_360px]">
<section class="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm dark:border-slate-800 dark:bg-slate-900">
<div class="flex items-start justify-between gap-4">
<div>
<h2 class="text-xl font-black text-slate-900 dark:text-white">上传 iframe_network.json</h2>
<p class="mt-1 text-sm text-slate-500">系统自动提取比赛 ID、计算 SHA256、检查必要接口并拒绝重复数据。</p>
</div>
<span class="rounded-full bg-yrtv-50 px-3 py-1 text-xs font-black text-yrtv-700">Max {{ '%.0f MB'|format(config.MAX_CONTENT_LENGTH / 1024 / 1024) }}</span>
</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold text-slate-900 dark:text-white">比赛数据导入</h1>
<p class="mt-1 text-sm text-slate-500 dark:text-slate-400">
上传完整的 iframe_network.json,系统会自动识别比赛 ID,并执行 L1 → L2 → L3。
</p>
</div>
<a href="{{ url_for('admin.dashboard') }}" class="text-sm font-medium text-yrtv-600 hover:text-yrtv-500">
返回管理后台
</a>
</div>
<form method="POST" enctype="multipart/form-data" class="mt-6 space-y-5">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<label id="drop-zone" class="group flex min-h-[260px] cursor-pointer flex-col items-center justify-center rounded-2xl border-2 border-dashed border-slate-300 bg-slate-50 px-6 text-center transition hover:border-yrtv-400 hover:bg-yrtv-50/40 dark:border-slate-700 dark:bg-slate-950/40">
<input id="capture-input" type="file" name="capture" accept=".json,application/json" required class="sr-only">
<div class="flex h-16 w-16 items-center justify-center rounded-2xl bg-white text-3xl shadow-sm transition group-hover:scale-105 dark:bg-slate-800"></div>
<div id="file-name" class="mt-4 font-black text-slate-800 dark:text-white">点击选择 JSON 文件</div>
<div class="mt-2 text-sm text-slate-400">或将 iframe_network.json 拖到这里</div>
<div class="mt-4 rounded-full bg-white px-3 py-1 text-[10px] font-bold uppercase tracking-wider text-slate-400 shadow-sm dark:bg-slate-800">UTF-8 · One Match · Network Capture</div>
</label>
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
<div class="rounded-xl bg-white p-6 shadow dark:bg-slate-800">
<h2 class="text-lg font-semibold text-slate-900 dark:text-white">上传抓包</h2>
<form method="POST" enctype="multipart/form-data" class="mt-5 space-y-4">
<div>
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300">
iframe_network.json
</label>
<input type="file" name="capture" accept=".json,application/json" required
class="mt-2 block w-full text-sm text-slate-600 file:mr-4 file:rounded-lg file:border-0 file:bg-yrtv-50 file:px-4 file:py-2 file:font-medium file:text-yrtv-700 hover:file:bg-yrtv-100 dark:text-slate-300">
</div>
<label class="flex items-start gap-2 text-sm text-slate-600 dark:text-slate-300">
<input type="checkbox" name="replace" value="1" class="mt-1 rounded border-slate-300 text-yrtv-600">
<span>允许替换已存在但内容不同的比赛。流水线失败时会自动恢复数据库。</span>
<div class="rounded-xl border border-amber-200 bg-amber-50 p-4 dark:border-amber-900/50 dark:bg-amber-950/20">
<label class="flex items-start gap-3">
<input type="checkbox" name="replace" value="1" class="mt-0.5 rounded border-amber-300 text-amber-600">
<span>
<span class="block text-sm font-black text-amber-800 dark:text-amber-300">允许替换内容不同的已有比赛</span>
<span class="mt-1 block text-xs leading-5 text-amber-700/80 dark:text-amber-400">仅在确认原始抓包需要修复时使用。相同内容永远拒绝重复导入。</span>
</span>
</label>
<button type="submit"
class="w-full rounded-lg bg-yrtv-600 px-4 py-2.5 font-medium text-white hover:bg-yrtv-500">
校验并开始导入
</button>
</form>
<div class="mt-5 rounded-lg bg-amber-50 p-3 text-xs text-amber-800 dark:bg-amber-900/30 dark:text-amber-200">
直接重复上传会被拒绝。每次流水线运行前都会备份 L1、L2、L3,并执行后置完整性检查。
</div>
</div>
<div class="rounded-xl bg-white p-6 shadow dark:bg-slate-800 lg:col-span-2">
<div class="flex items-center justify-between">
<h2 class="text-lg font-semibold text-slate-900 dark:text-white">作业状态</h2>
{% if selected_job %}
<span id="job-status" class="rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold uppercase text-slate-700 dark:bg-slate-700 dark:text-slate-200">
{{ selected_job.status }}
</span>
{% endif %}
</div>
{% if selected_job %}
<div class="mt-5">
<div class="flex justify-between text-sm text-slate-600 dark:text-slate-300">
<span id="job-stage">{{ selected_job.current_stage or 'queued' }}</span>
<span id="job-progress-label">{{ selected_job.progress }}%</span>
</div>
<div class="mt-2 h-2 overflow-hidden rounded-full bg-slate-200 dark:bg-slate-700">
<div id="job-progress" class="h-full bg-yrtv-500 transition-all" style="width: {{ selected_job.progress }}%"></div>
</div>
<p id="job-message" class="mt-3 text-sm text-slate-600 dark:text-slate-300">
{{ selected_job.message or '等待执行' }}
</p>
<pre id="job-log" class="mt-4 max-h-96 overflow-auto whitespace-pre-wrap rounded-lg bg-slate-950 p-4 text-xs text-slate-200">{{ selected_job.log_text }}</pre>
<div class="flex flex-col gap-3 sm:flex-row">
<button type="submit" class="flex-1 rounded-xl bg-yrtv-600 px-5 py-3 text-sm font-black text-white shadow-lg shadow-yrtv-200 hover:bg-yrtv-500">校验并开始导入</button>
<a href="{{ url_for('admin.jobs') }}" class="rounded-xl border border-slate-200 px-5 py-3 text-center text-sm font-bold text-slate-600 hover:bg-slate-50 dark:border-slate-700 dark:text-slate-300">查看作业中心</a>
</div>
{% else %}
<div class="mt-8 rounded-lg border border-dashed border-slate-300 p-8 text-center text-sm text-slate-500 dark:border-slate-600">
上传比赛或从下方选择历史作业。
</div>
{% endif %}
</div>
</div>
</form>
</section>
<div class="overflow-hidden rounded-xl bg-white shadow dark:bg-slate-800">
<div class="border-b border-slate-200 px-5 py-4 dark:border-slate-700">
<h2 class="font-semibold text-slate-900 dark:text-white">最近作业</h2>
</div>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-slate-200 dark:divide-slate-700">
<thead class="bg-slate-50 dark:bg-slate-900/50">
<tr>
<th class="px-5 py-3 text-left text-xs font-semibold uppercase text-slate-500">ID</th>
<th class="px-5 py-3 text-left text-xs font-semibold uppercase text-slate-500">类型</th>
<th class="px-5 py-3 text-left text-xs font-semibold uppercase text-slate-500">比赛</th>
<th class="px-5 py-3 text-left text-xs font-semibold uppercase text-slate-500">状态</th>
<th class="px-5 py-3 text-left text-xs font-semibold uppercase text-slate-500">阶段</th>
<th class="px-5 py-3 text-left text-xs font-semibold uppercase text-slate-500">耗时</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100 dark:divide-slate-700">
{% for job in jobs %}
<tr class="hover:bg-slate-50 dark:hover:bg-slate-700/40">
<td class="px-5 py-3 text-sm">
<a href="{{ url_for('admin.import_match', job_id=job.id) }}" class="font-medium text-yrtv-600">#{{ job.id }}</a>
</td>
<td class="px-5 py-3 text-sm text-slate-600 dark:text-slate-300">{{ job.job_type }}</td>
<td class="px-5 py-3 text-sm font-mono text-slate-600 dark:text-slate-300">{{ job.match_id or '-' }}</td>
<td class="px-5 py-3 text-sm text-slate-600 dark:text-slate-300">{{ job.status }}</td>
<td class="px-5 py-3 text-sm text-slate-600 dark:text-slate-300">{{ job.current_stage or '-' }}</td>
<td class="px-5 py-3 text-sm text-slate-600 dark:text-slate-300">{{ '%.2fs'|format(job.duration_seconds) if job.duration_seconds is not none else '-' }}</td>
</tr>
{% else %}
<tr><td colspan="6" class="px-5 py-8 text-center text-sm text-slate-500">暂无作业</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<aside class="space-y-6">
<section class="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900">
<h2 class="font-black text-slate-900 dark:text-white">导入流程</h2>
<ol class="mt-4 space-y-3">
{% for title, detail in [
('结构校验', 'JSON、比赛 ID、match/round 接口'),
('内容去重', 'SHA256 与 L1、待处理文件比对'),
('安全备份', '保存 L1/L2/L3 回滚快照'),
('分层构建', '串行执行 L1 → L2 → L3'),
('质量验收', '玩家数、回合与自动完整性规则')
] %}
<li class="flex gap-3">
<span class="flex h-6 w-6 flex-none items-center justify-center rounded-full bg-yrtv-100 text-xs font-black text-yrtv-700">{{ loop.index }}</span>
<span><b class="block text-sm text-slate-700 dark:text-slate-200">{{ title }}</b><span class="text-xs text-slate-400">{{ detail }}</span></span>
</li>
{% endfor %}
</ol>
</section>
<section class="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900">
<div class="flex items-center justify-between border-b border-slate-100 px-5 py-4 dark:border-slate-800">
<h2 class="font-black text-slate-900 dark:text-white">最近导入作业</h2>
<a href="{{ url_for('admin.jobs') }}" class="text-xs font-bold text-yrtv-600">全部</a>
</div>
<div class="divide-y divide-slate-100 dark:divide-slate-800">
{% for job in jobs %}
<a href="{{ url_for('admin.jobs', job_id=job.id) }}" class="flex items-center justify-between px-5 py-3 hover:bg-slate-50 dark:hover:bg-slate-800">
<div class="min-w-0">
<div class="truncate text-sm font-bold text-slate-700 dark:text-slate-200">#{{ job.id }} {{ job.match_id or job.job_type }}</div>
<div class="text-xs text-slate-400">{{ job.current_stage or job.status }}</div>
</div>
<span class="ml-3 rounded-full px-2 py-1 text-[10px] font-black uppercase {% if job.status == 'succeeded' %}bg-emerald-100 text-emerald-700{% elif job.status == 'failed' %}bg-red-100 text-red-700{% else %}bg-amber-100 text-amber-700{% endif %}">{{ job.status }}</span>
</a>
{% else %}
<div class="px-5 py-8 text-center text-sm text-slate-400">暂无作业</div>
{% endfor %}
</div>
</section>
</aside>
</div>
{% endblock %}
{% block scripts %}
{% if selected_job %}
{{ super() }}
<script>
const jobId = {{ selected_job.id }};
let pollTimer = null;
async function refreshJob() {
const response = await fetch(`/admin/api/jobs/${jobId}`);
if (!response.ok) return;
const job = await response.json();
document.getElementById('job-status').textContent = job.status;
document.getElementById('job-stage').textContent = job.current_stage || job.status;
document.getElementById('job-progress-label').textContent = `${job.progress}%`;
document.getElementById('job-progress').style.width = `${job.progress}%`;
document.getElementById('job-message').textContent = job.message || '';
const log = document.getElementById('job-log');
log.textContent = job.log_text || '';
log.scrollTop = log.scrollHeight;
if (job.status === 'succeeded' || job.status === 'failed') {
clearInterval(pollTimer);
}
}
pollTimer = setInterval(refreshJob, 1500);
refreshJob();
const input = document.getElementById('capture-input');
const fileName = document.getElementById('file-name');
input.addEventListener('change', () => {
fileName.textContent = input.files.length ? input.files[0].name : '点击选择 JSON 文件';
});
</script>
{% endif %}
{% endblock %}
+123
View File
@@ -0,0 +1,123 @@
{% extends "admin/base.html" %}
{% block admin_title %}作业中心{% endblock %}
{% block page_title %}作业中心{% endblock %}
{% block page_subtitle %}跟踪导入、全量构建、失败日志与执行耗时{% endblock %}
{% block admin_content %}
<div class="space-y-6">
<section class="grid grid-cols-2 gap-3 sm:grid-cols-5">
{% for key, label in [('total','全部'),('running','运行中'),('queued','排队'),('succeeded','成功'),('failed','失败')] %}
<a href="{{ url_for('admin.jobs', status=key if key != 'total' else None) }}"
class="rounded-xl border bg-white p-4 shadow-sm transition hover:border-yrtv-300 dark:bg-slate-900
{% if status == key or (key == 'total' and not status) %}border-yrtv-400 ring-2 ring-yrtv-100{% else %}border-slate-200 dark:border-slate-800{% endif %}">
<div class="text-xs font-bold uppercase text-slate-400">{{ label }}</div>
<div class="mt-1 text-2xl font-black text-slate-900 dark:text-white">{{ summary[key] }}</div>
</a>
{% endfor %}
</section>
<div class="grid grid-cols-1 gap-6 xl:grid-cols-[minmax(0,1fr)_420px]">
<section class="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900">
<div class="flex flex-col gap-3 border-b border-slate-100 px-5 py-4 sm:flex-row sm:items-center sm:justify-between dark:border-slate-800">
<div>
<h2 class="font-black text-slate-900 dark:text-white">执行记录</h2>
<p class="text-xs text-slate-400">最多显示最近 50 条</p>
</div>
<div class="flex gap-2">
<a href="{{ url_for('admin.import_match') }}" class="rounded-lg bg-yrtv-600 px-3 py-2 text-xs font-bold text-white">新建导入</a>
<a href="{{ url_for('admin.jobs') }}" class="rounded-lg border border-slate-200 px-3 py-2 text-xs font-bold text-slate-600 dark:border-slate-700 dark:text-slate-300">清除筛选</a>
</div>
</div>
<div class="overflow-x-auto">
<table class="min-w-full">
<thead class="bg-slate-50 text-left text-[10px] font-black uppercase tracking-wider text-slate-400 dark:bg-slate-950/50">
<tr><th class="px-5 py-3">Job</th><th class="px-4 py-3">Match</th><th class="px-4 py-3">Stage</th><th class="px-4 py-3">Status</th><th class="px-4 py-3 text-right">Duration</th></tr>
</thead>
<tbody class="divide-y divide-slate-100 dark:divide-slate-800">
{% for job in jobs %}
<tr class="{% if selected_job and selected_job.id == job.id %}bg-yrtv-50 dark:bg-yrtv-950/20{% endif %} hover:bg-slate-50 dark:hover:bg-slate-800/50">
<td class="px-5 py-3">
<a href="{{ url_for('admin.jobs', job_id=job.id, status=status, type=job_type) }}" class="font-black text-yrtv-600">#{{ job.id }}</a>
<div class="text-xs text-slate-400">{{ job.job_type }}</div>
</td>
<td class="max-w-[180px] truncate px-4 py-3 font-mono text-xs text-slate-500">{{ job.match_id or '-' }}</td>
<td class="px-4 py-3 text-xs text-slate-500">{{ job.current_stage or '-' }} · {{ job.progress }}%</td>
<td class="px-4 py-3">
<span class="rounded-full px-2 py-1 text-[10px] font-black uppercase
{% if job.status == 'succeeded' %}bg-emerald-100 text-emerald-700
{% elif job.status == 'failed' %}bg-red-100 text-red-700
{% elif job.status == 'running' %}bg-amber-100 text-amber-700
{% else %}bg-slate-100 text-slate-600{% endif %}">{{ job.status }}</span>
</td>
<td class="px-4 py-3 text-right font-mono text-xs text-slate-500">{{ '%.2fs'|format(job.duration_seconds) if job.duration_seconds is not none else '-' }}</td>
</tr>
{% else %}
<tr><td colspan="5" class="px-5 py-12 text-center text-sm text-slate-400">当前筛选没有作业</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
<aside class="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900">
{% if selected_job %}
<div class="flex items-start justify-between">
<div>
<div class="text-xs font-black uppercase tracking-wider text-slate-400">Job Detail</div>
<h2 class="mt-1 text-xl font-black text-slate-900 dark:text-white">#{{ selected_job.id }} {{ selected_job.job_type }}</h2>
</div>
<span id="job-status" class="rounded-full bg-slate-100 px-3 py-1 text-xs font-black uppercase text-slate-600">{{ selected_job.status }}</span>
</div>
<div class="mt-5">
<div class="flex justify-between text-xs text-slate-500"><span id="job-stage">{{ selected_job.current_stage or selected_job.status }}</span><span id="job-progress-label">{{ selected_job.progress }}%</span></div>
<div class="mt-2 h-2 overflow-hidden rounded-full bg-slate-100 dark:bg-slate-800"><div id="job-progress" class="h-full rounded-full bg-yrtv-500 transition-all" style="width:{{ selected_job.progress }}%"></div></div>
<p id="job-message" class="mt-3 text-sm text-slate-600 dark:text-slate-300">{{ selected_job.message }}</p>
</div>
<dl class="mt-5 grid grid-cols-2 gap-3 text-xs">
<div class="rounded-lg bg-slate-50 p-3 dark:bg-slate-800"><dt class="text-slate-400">Created</dt><dd class="mt-1 font-mono text-slate-700 dark:text-slate-200">{{ selected_job.created_at }}</dd></div>
<div class="rounded-lg bg-slate-50 p-3 dark:bg-slate-800"><dt class="text-slate-400">Duration</dt><dd class="mt-1 font-mono text-slate-700 dark:text-slate-200">{{ '%.3fs'|format(selected_job.duration_seconds) if selected_job.duration_seconds is not none else '-' }}</dd></div>
</dl>
<div class="mt-5 flex items-center justify-between">
<h3 class="text-xs font-black uppercase tracking-wider text-slate-400">Execution Log</h3>
<button onclick="copyJobLog()" class="text-xs font-bold text-yrtv-600">复制日志</button>
</div>
<pre id="job-log" class="mt-2 max-h-[420px] overflow-auto whitespace-pre-wrap rounded-xl bg-slate-950 p-4 text-[11px] leading-5 text-slate-300">{{ selected_job.log_text }}</pre>
{% else %}
<div class="flex min-h-[420px] flex-col items-center justify-center text-center">
<div class="flex h-14 w-14 items-center justify-center rounded-2xl bg-slate-100 text-2xl dark:bg-slate-800"></div>
<h2 class="mt-4 font-black text-slate-800 dark:text-white">选择一个作业</h2>
<p class="mt-2 max-w-xs text-sm text-slate-400">查看阶段进度、执行信息和完整构建日志。</p>
</div>
{% endif %}
</aside>
</div>
</div>
{% endblock %}
{% block scripts %}
{{ super() }}
{% if selected_job %}
<script>
const jobId = {{ selected_job.id }};
let jobPoll = null;
async function refreshJob() {
const response = await fetch(`/admin/api/jobs/${jobId}`);
if (!response.ok) return;
const job = await response.json();
document.getElementById('job-status').textContent = job.status;
document.getElementById('job-stage').textContent = job.current_stage || job.status;
document.getElementById('job-progress-label').textContent = `${job.progress}%`;
document.getElementById('job-progress').style.width = `${job.progress}%`;
document.getElementById('job-message').textContent = job.message || '';
document.getElementById('job-log').textContent = job.log_text || '';
if (['succeeded', 'failed'].includes(job.status) && jobPoll) clearInterval(jobPoll);
}
function copyJobLog() {
navigator.clipboard.writeText(document.getElementById('job-log').textContent);
}
jobPoll = setInterval(refreshJob, 1500);
refreshJob();
</script>
{% endif %}
{% endblock %}
+44 -32
View File
@@ -1,38 +1,50 @@
{% extends "base.html" %}
{% block content %}
<div class="min-h-full flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
<div class="max-w-md w-full space-y-8">
<div>
<h2 class="mt-6 text-center text-3xl font-extrabold text-gray-900 dark:text-white">
Admin Login
</h2>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative" role="alert">
<span class="block sm:inline">{{ message }}</span>
</div>
{% endfor %}
{% endif %}
{% endwith %}
<form class="mt-8 space-y-6" action="{{ url_for('admin.login') }}" method="POST">
<div class="rounded-md shadow-sm -space-y-px">
<div>
<label for="token" class="sr-only">Admin Token</label>
<input id="token" name="token" type="password" required class="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md rounded-b-md focus:outline-none focus:ring-yrtv-500 focus:border-yrtv-500 focus:z-10 sm:text-sm" placeholder="Enter Admin Token">
</div>
</div>
{% block title %}Admin Login - YRTV{% endblock %}
<div>
<button type="submit" class="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-yrtv-600 hover:bg-yrtv-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-yrtv-500">
Sign in
</button>
{% block content %}
<div class="mx-auto grid min-h-[640px] max-w-5xl overflow-hidden rounded-3xl border border-slate-200 bg-white shadow-2xl dark:border-slate-700 dark:bg-slate-900 lg:grid-cols-2">
<section class="hidden bg-gradient-to-br from-slate-950 via-slate-900 to-yrtv-950 p-10 text-white lg:flex lg:flex-col lg:justify-between">
<div>
<div class="text-xs font-black uppercase tracking-[0.3em] text-yrtv-300">YRTV Operations</div>
<h1 class="mt-5 text-4xl font-black leading-tight">私人战队数据<br>运营控制台</h1>
<p class="mt-5 max-w-sm text-sm leading-7 text-slate-300">管理比赛导入、流水线、数据质量、数据库查询、备份和系统状态。</p>
</div>
<div class="grid grid-cols-2 gap-3">
{% for label in ['Atomic Pipeline', 'Automated Integrity', 'Read-only SQL', 'Rollback Snapshots'] %}
<div class="rounded-xl border border-white/10 bg-white/5 p-3 text-xs font-bold text-slate-300">{{ label }}</div>
{% endfor %}
</div>
</section>
<section class="flex items-center px-6 py-12 sm:px-12">
<div class="w-full">
<div class="flex h-14 w-14 items-center justify-center rounded-2xl bg-yrtv-100 text-2xl font-black text-yrtv-700">Y</div>
<h2 class="mt-6 text-3xl font-black text-slate-900 dark:text-white">管理员登录</h2>
<p class="mt-2 text-sm text-slate-500">请输入部署环境配置的 Admin Token。</p>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="mt-5 space-y-2">
{% for category, message in messages %}
<div class="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm font-medium text-red-700">{{ message }}</div>
{% endfor %}
</div>
</form>
</div>
{% endif %}
{% endwith %}
<form class="mt-8 space-y-5" action="{{ url_for('admin.login') }}" method="POST">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<div>
<label for="token" class="text-xs font-black uppercase tracking-wider text-slate-500">Admin Token</label>
<input id="token" name="token" type="password" required autocomplete="current-password"
class="mt-2 block w-full rounded-xl border-slate-200 bg-slate-50 px-4 py-3 text-slate-900 focus:border-yrtv-500 focus:ring-yrtv-500 dark:border-slate-700 dark:bg-slate-800 dark:text-white"
placeholder="Enter your admin token">
</div>
<button type="submit" class="w-full rounded-xl bg-yrtv-600 px-5 py-3 font-black text-white shadow-lg shadow-yrtv-200 hover:bg-yrtv-500">进入控制台</button>
</form>
<a href="{{ url_for('main.index') }}" class="mt-5 block text-center text-sm font-bold text-slate-400 hover:text-yrtv-600">返回 YRTV 前台</a>
</div>
</section>
</div>
{% endblock %}
+113 -45
View File
@@ -1,52 +1,120 @@
{% extends "base.html" %}
{% extends "admin/base.html" %}
{% block content %}
<div class="bg-white dark:bg-slate-800 shadow rounded-lg p-6">
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-6">SQL Runner</h2>
<form action="{{ url_for('admin.sql_runner') }}" method="POST" class="mb-6">
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Database</label>
<select name="db_name" class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 dark:bg-slate-700 dark:text-white">
<option value="l2" {% if db_name == 'l2' %}selected{% endif %}>L2 (Facts)</option>
<option value="l3" {% if db_name == 'l3' %}selected{% endif %}>L3 (Features)</option>
<option value="web" {% if db_name == 'web' %}selected{% endif %}>Web (App Data)</option>
</select>
{% block admin_title %}查询控制台{% endblock %}
{% block page_title %}只读查询控制台{% endblock %}
{% block page_subtitle %}探索 L2 事实、L3 集市和 Web 状态,不允许任何写操作{% endblock %}
{% block admin_content %}
<div class="grid grid-cols-1 gap-6 xl:grid-cols-[260px_minmax(0,1fr)]">
<aside class="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900">
<div class="border-b border-slate-100 px-4 py-4 dark:border-slate-800">
<div class="text-xs font-black uppercase tracking-wider text-slate-400">Database Objects</div>
<div class="mt-1 font-black text-slate-800 dark:text-white">{{ db_name|upper }}</div>
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Query</label>
<textarea name="query" rows="5" class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 font-mono text-sm dark:bg-slate-700 dark:text-white" placeholder="SELECT * FROM table LIMIT 10">{{ query }}</textarea>
<div class="max-h-[680px] overflow-auto p-2">
{% for item in catalog %}
<button type="button" onclick="selectTable('{{ item.name }}')" class="flex w-full items-center justify-between rounded-lg px-3 py-2 text-left hover:bg-slate-50 dark:hover:bg-slate-800">
<span class="truncate font-mono text-xs text-slate-600 dark:text-slate-300">{{ item.name }}</span>
<span class="ml-2 text-[9px] font-black uppercase text-slate-300">{{ item.type }}</span>
</button>
{% else %}
<div class="px-3 py-8 text-center text-xs text-slate-400">没有可用对象</div>
{% endfor %}
</div>
<button type="submit" class="bg-yrtv-600 text-white py-2 px-4 rounded hover:bg-yrtv-700">Run Query</button>
</form>
{% if error %}
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-6">
{{ error }}
</div>
{% endif %}
{% if result %}
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700 border">
<thead class="bg-gray-50 dark:bg-slate-700">
<tr>
{% for col in result.columns %}
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider border-b">{{ col }}</th>
{% endfor %}
</tr>
</thead>
<tbody class="bg-white dark:bg-slate-800 divide-y divide-gray-200 dark:divide-gray-700">
{% for row in result.rows %}
<tr>
{% for col in result.columns %}
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400 border-b">{{ row[col] }}</td>
{% endfor %}
</tr>
</aside>
<div class="min-w-0 space-y-6">
<section class="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900">
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h2 class="font-black text-slate-900 dark:text-white">SQL Editor</h2>
<p class="mt-1 text-xs text-slate-400">仅支持单条 SELECT;未指定 LIMIT 时自动限制 50 行。</p>
</div>
<form method="GET" action="{{ url_for('admin.sql_runner') }}">
<select name="db_name" onchange="this.form.submit()" class="rounded-lg border-slate-200 bg-slate-50 text-sm font-bold dark:border-slate-700 dark:bg-slate-800 dark:text-white">
<option value="l2" {% if db_name == 'l2' %}selected{% endif %}>L2 Facts</option>
<option value="l3" {% if db_name == 'l3' %}selected{% endif %}>L3 Marts</option>
<option value="web" {% if db_name == 'web' %}selected{% endif %}>Web App</option>
</select>
</form>
</div>
<div class="mt-4 flex flex-wrap gap-2">
{% if db_name == 'l2' %}
{% set samples = [
('最近比赛', 'SELECT match_id, map_name, start_time, score_team1, score_team2 FROM fact_matches ORDER BY start_time DESC LIMIT 10'),
('队员比赛', 'SELECT steam_id_64, match_id, rating, kills, deaths, adr FROM fact_match_players ORDER BY match_id DESC LIMIT 20')
] %}
{% elif db_name == 'l3' %}
{% set samples = [
('Roster Features', 'SELECT steam_id_64, total_matches, score_overall, tier_percentile FROM dm_player_features WHERE score_overall > 0 ORDER BY score_overall DESC'),
('周期荣誉', 'SELECT award_type, period_key, steam_id_64, matches, avg_rating FROM dm_player_awards ORDER BY period_start DESC LIMIT 20')
] %}
{% else %}
{% set samples = [
('最近作业', 'SELECT id, job_type, status, current_stage, duration_seconds, created_at FROM etl_jobs ORDER BY id DESC LIMIT 10'),
('Roster Versions', 'SELECT id, name, effective_from, effective_to, is_current FROM team_roster_versions ORDER BY effective_from DESC')
] %}
{% endif %}
{% for label, sample in samples %}
<button type="button" onclick='setQuery({{ sample|tojson }})' class="rounded-full bg-slate-100 px-3 py-1.5 text-xs font-bold text-slate-500 hover:bg-yrtv-100 hover:text-yrtv-700 dark:bg-slate-800">{{ label }}</button>
{% endfor %}
</tbody>
</table>
</div>
<form action="{{ url_for('admin.sql_runner') }}" method="POST" class="mt-4">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="db_name" value="{{ db_name }}">
<textarea id="query-editor" name="query" rows="9" spellcheck="false" class="block w-full resize-y rounded-xl border border-slate-200 bg-slate-950 p-4 font-mono text-sm leading-6 text-emerald-300 shadow-inner focus:border-yrtv-500 focus:ring-yrtv-500" placeholder="SELECT * FROM table_name LIMIT 10">{{ query }}</textarea>
<div class="mt-4 flex items-center justify-between">
<div class="flex items-center gap-2 text-xs text-slate-400"><span class="h-2 w-2 rounded-full bg-emerald-500"></span> Read-only guard enabled</div>
<button type="submit" class="rounded-xl bg-yrtv-600 px-5 py-2.5 text-sm font-black text-white hover:bg-yrtv-500">执行查询</button>
</div>
</form>
</section>
{% if error %}
<div class="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm font-medium text-red-700">{{ error }}</div>
{% endif %}
{% if result %}
<section class="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900">
<div class="flex items-center justify-between border-b border-slate-100 px-5 py-4 dark:border-slate-800">
<h2 class="font-black text-slate-900 dark:text-white">查询结果</h2>
<div class="flex gap-2 text-xs">
<span class="rounded-full bg-slate-100 px-3 py-1 font-mono text-slate-500">{{ row_count }} rows</span>
<span class="rounded-full bg-emerald-100 px-3 py-1 font-mono text-emerald-700">{{ '%.2fms'|format(duration_ms or 0) }}</span>
</div>
</div>
<div class="max-h-[620px] overflow-auto">
<table class="min-w-full divide-y divide-slate-100 text-left dark:divide-slate-800">
<thead class="sticky top-0 bg-slate-50 text-[10px] font-black uppercase tracking-wider text-slate-400 dark:bg-slate-950">
<tr>{% for col in result.columns %}<th class="whitespace-nowrap px-4 py-3">{{ col }}</th>{% endfor %}</tr>
</thead>
<tbody class="divide-y divide-slate-100 font-mono text-xs dark:divide-slate-800">
{% for row in result.rows %}
<tr class="hover:bg-slate-50 dark:hover:bg-slate-800/50">
{% for col in result.columns %}
<td class="max-w-[320px] px-4 py-3 text-slate-600 dark:text-slate-300" title="{{ row[col] }}">{{ row[col]|string|truncate(160) }}</td>
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
{% endif %}
</div>
{% endif %}
</div>
{% endblock %}
{% block scripts %}
{{ super() }}
<script>
function setQuery(value) {
document.getElementById('query-editor').value = value;
}
function selectTable(name) {
setQuery(`SELECT * FROM "${name}" LIMIT 20`);
}
</script>
{% endblock %}
+90
View File
@@ -0,0 +1,90 @@
{% extends "admin/base.html" %}
{% block admin_title %}系统信息{% endblock %}
{% block page_title %}系统与存储{% endblock %}
{% block page_subtitle %}数据库文件、备份快照、运行参数与安全配置{% endblock %}
{% block admin_content %}
<div class="space-y-6">
<section class="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
{% for database in databases %}
<div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900">
<div class="flex items-center justify-between">
<div class="text-xs font-black uppercase tracking-widest text-slate-400">{{ database.name }}</div>
<span class="h-2.5 w-2.5 rounded-full {% if database.exists and database.quick_check in ['ok', 'managed by app'] %}bg-emerald-500{% else %}bg-red-500{% endif %}"></span>
</div>
<div class="mt-3 text-2xl font-black text-slate-900 dark:text-white">{{ database.size_text }}</div>
<div class="mt-1 truncate font-mono text-[10px] text-slate-400" title="{{ database.path }}">{{ database.path }}</div>
<div class="mt-3 text-xs font-bold {% if database.quick_check in ['ok', 'managed by app'] %}text-emerald-600{% else %}text-red-600{% endif %}">{{ database.quick_check }}</div>
</div>
{% endfor %}
</section>
<div class="grid grid-cols-1 gap-6 xl:grid-cols-[minmax(0,1fr)_360px]">
<section class="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900">
<div class="flex items-center justify-between border-b border-slate-100 px-5 py-4 dark:border-slate-800">
<div>
<h2 class="font-black text-slate-900 dark:text-white">回滚快照</h2>
<p class="text-xs text-slate-400">Pipeline 自动保留最近 3 组</p>
</div>
<span class="rounded-full bg-slate-100 px-3 py-1 text-xs font-mono text-slate-500">{{ backup_summary.sets }} sets</span>
</div>
<div class="divide-y divide-slate-100 dark:divide-slate-800">
{% for backup in backups %}
<div class="px-5 py-4">
<div class="flex items-center justify-between">
<div>
<div class="font-black text-slate-800 dark:text-white">{{ backup.name }}</div>
<div class="mt-1 text-xs text-slate-400">{{ backup.created_at or 'No manifest timestamp' }}</div>
</div>
<div class="text-right">
<div class="font-mono text-sm font-black text-slate-700 dark:text-slate-200">{{ backup.size_text }}</div>
<div class="text-[10px] uppercase text-emerald-600">Verified snapshot</div>
</div>
</div>
<div class="mt-3 flex gap-2">
{% for name, item in backup.databases.items() %}
<span class="rounded bg-slate-100 px-2 py-1 text-[10px] font-bold uppercase text-slate-500 dark:bg-slate-800">{{ name }} · {{ item.quick_check }}</span>
{% endfor %}
</div>
</div>
{% else %}
<div class="px-5 py-12 text-center text-sm text-slate-400">暂无备份快照</div>
{% endfor %}
</div>
</section>
<aside class="space-y-6">
<section class="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900">
<h2 class="font-black text-slate-900 dark:text-white">运行参数</h2>
<dl class="mt-4 space-y-3 text-sm">
{% for label, value in [
('Web schema', 'v' + config.web_schema_version|string),
('SQLite timeout', config.sqlite_timeout|string + 's'),
('Slow query', config.slow_query_threshold|string + 's'),
('Max upload', '%.0f MB'|format(config.max_upload_mb))
] %}
<div class="flex items-center justify-between"><dt class="text-slate-500">{{ label }}</dt><dd class="font-mono font-black text-slate-800 dark:text-slate-200">{{ value }}</dd></div>
{% endfor %}
</dl>
</section>
<section class="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900">
<h2 class="font-black text-slate-900 dark:text-white">安全配置</h2>
<div class="mt-4 space-y-3">
{% for label, configured in [
('SECRET_KEY', config.secret_key_configured),
('ADMIN_TOKEN', config.admin_token_configured)
] %}
<div class="flex items-center justify-between rounded-lg bg-slate-50 p-3 dark:bg-slate-800">
<span class="font-mono text-xs text-slate-600 dark:text-slate-300">{{ label }}</span>
<span class="rounded-full px-2 py-1 text-[10px] font-black uppercase {% if configured %}bg-emerald-100 text-emerald-700{% else %}bg-amber-100 text-amber-700{% endif %}">{{ 'configured' if configured else 'dev default' }}</span>
</div>
{% endfor %}
</div>
<p class="mt-4 text-xs leading-5 text-slate-400">本页只展示配置状态,不回显任何密钥或令牌。</p>
</section>
</aside>
</div>
</div>
{% endblock %}
+2
View File
@@ -46,6 +46,7 @@
<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('discover.index') }}" class="{% if request.endpoint and 'discover' in request.endpoint %}border-cyan-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>
@@ -86,6 +87,7 @@
<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('discover.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>
+136
View File
@@ -0,0 +1,136 @@
{% extends "base.html" %}
{% block title %}发现 - YRTV{% endblock %}
{% block content %}
{% set tone_styles = {
'positive': 'border-emerald-200 bg-emerald-50 dark:border-emerald-900/50 dark:bg-emerald-950/20',
'negative': 'border-rose-200 bg-rose-50 dark:border-rose-900/50 dark:bg-rose-950/20',
'fun': 'border-cyan-200 bg-cyan-50 dark:border-cyan-900/50 dark:bg-cyan-950/20'
} %}
{% set tone_badges = {
'positive': 'bg-emerald-100 text-emerald-700',
'negative': 'bg-rose-100 text-rose-700',
'fun': 'bg-cyan-100 text-cyan-700'
} %}
{% set medal_styles = {
'gold': 'from-amber-300 to-yellow-500 text-amber-950',
'silver': 'from-slate-200 to-slate-400 text-slate-800',
'bronze': 'from-orange-300 to-amber-700 text-orange-950'
} %}
<div class="space-y-10 px-4 sm:px-0">
<section class="overflow-hidden rounded-3xl bg-gradient-to-br from-slate-950 via-cyan-950 to-yrtv-950 p-8 text-white shadow-2xl">
<div class="flex flex-col gap-8 lg:flex-row lg:items-end lg:justify-between">
<div>
<div class="text-xs font-black uppercase tracking-[0.3em] text-cyan-300">Data Discovery</div>
<h1 class="mt-3 text-4xl font-black">有趣的数据,不只展示好的一面</h1>
<p class="mt-3 max-w-2xl text-sm leading-7 text-slate-300">从 885 条 roster 比赛记录中自动发现高光、低谷、反差和个人习惯。每张卡片都有统计证据。</p>
</div>
<div class="grid grid-cols-3 gap-3 text-center">
{% for label, value in [('发现', insights|length), ('勋章维度', medals|length), ('获奖队员', leaderboard|length)] %}
<div class="rounded-2xl bg-white/10 px-5 py-3"><div class="text-2xl font-black">{{ value }}</div><div class="text-[10px] uppercase text-slate-400">{{ label }}</div></div>
{% endfor %}
</div>
</div>
</section>
<section id="insights" class="space-y-5">
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h2 class="text-2xl font-black text-slate-900 dark:text-white">趣味发现</h2>
<p class="text-sm text-slate-500">正面、负面和纯粹有趣的数据都保留。</p>
</div>
<div class="flex gap-2">
<a href="{{ url_for('discover.index', dimension=dimension) }}#insights" class="rounded-full px-4 py-2 text-sm font-bold {% if not tone %}bg-slate-900 text-white dark:bg-white dark:text-slate-900{% else %}bg-white text-slate-500 dark:bg-slate-800{% endif %}">全部</a>
{% for key, label in tone_labels.items() %}
<a href="{{ url_for('discover.index', tone=key, dimension=dimension) }}#insights" class="rounded-full px-4 py-2 text-sm font-bold {% if tone == key %}{{ tone_badges[key] }}{% else %}bg-white text-slate-500 dark:bg-slate-800{% endif %}">{{ label }}</a>
{% endfor %}
</div>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
{% for insight in insights %}
<article class="rounded-2xl border p-5 shadow-sm transition hover:-translate-y-0.5 hover:shadow-md {{ tone_styles[insight.tone] }}">
<div class="flex items-start justify-between gap-3">
<div>
<span class="rounded-full px-2.5 py-1 text-[10px] font-black uppercase {{ tone_badges[insight.tone] }}">{{ insight.tone_label }} · {{ insight.category }}</span>
<h3 class="mt-3 text-xl font-black text-slate-900 dark:text-white">{{ insight.title }}</h3>
</div>
<div class="text-right">
<div class="text-3xl font-black text-slate-900 dark:text-white">
{% if insight.metric_unit == '%' %}{{ '%.0f'|format(insight.metric_value) }}{% elif insight.metric_value == insight.metric_value|int %}{{ insight.metric_value|int }}{% else %}{{ '%.2f'|format(insight.metric_value) }}{% endif %}<span class="text-sm">{{ insight.metric_unit }}</span>
</div>
<div class="text-[10px] uppercase text-slate-400">{{ insight.metric_label }}</div>
</div>
</div>
<p class="mt-4 text-sm leading-6 text-slate-600 dark:text-slate-300">{{ insight.description }}</p>
<div class="mt-4 flex items-center justify-between border-t border-black/5 pt-3 dark:border-white/10">
<a href="{{ url_for('players.detail', steam_id=insight.steam_id_64) }}" class="font-bold text-slate-800 hover:text-yrtv-600 dark:text-white">{{ insight.identity.username or insight.steam_id_64 }}</a>
<span class="text-xs text-slate-400">
{% if insight.evidence.matches %}{{ insight.evidence.matches }} 场样本{% endif %}
{% if insight.evidence.map %} · {{ insight.evidence.map }}{% endif %}
</span>
</div>
</article>
{% else %}
<div class="col-span-full rounded-2xl bg-white py-16 text-center text-slate-400 dark:bg-slate-800">当前筛选暂无发现</div>
{% endfor %}
</div>
</section>
<section id="medals" class="space-y-5">
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h2 class="text-2xl font-black text-slate-900 dark:text-white">表现勋章墙</h2>
<p class="text-sm text-slate-500">每张地图、每个 ELO 分段按 Rating 评选金银铜,最低 5 场。</p>
</div>
<div class="flex gap-2">
<a href="{{ url_for('discover.index', tone=tone) }}#medals" class="rounded-full px-4 py-2 text-sm font-bold {% if not dimension %}bg-slate-900 text-white dark:bg-white dark:text-slate-900{% else %}bg-white text-slate-500 dark:bg-slate-800{% endif %}">全部</a>
<a href="{{ url_for('discover.index', tone=tone, dimension='map') }}#medals" class="rounded-full px-4 py-2 text-sm font-bold {% if dimension == 'map' %}bg-yrtv-600 text-white{% else %}bg-white text-slate-500 dark:bg-slate-800{% endif %}">分地图</a>
<a href="{{ url_for('discover.index', tone=tone, dimension='elo') }}#medals" class="rounded-full px-4 py-2 text-sm font-bold {% if dimension == 'elo' %}bg-yrtv-600 text-white{% else %}bg-white text-slate-500 dark:bg-slate-800{% endif %}">分 ELO</a>
</div>
</div>
<div class="grid grid-cols-1 gap-5 lg:grid-cols-2">
{% for group in medals %}
<article class="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900">
<div class="flex items-center justify-between border-b border-slate-100 px-5 py-4 dark:border-slate-800">
<div>
<div class="text-[10px] font-black uppercase tracking-wider text-slate-400">{{ 'Map Medal' if group.dimension_type == 'map' else 'ELO Medal' }}</div>
<h3 class="text-lg font-black text-slate-900 dark:text-white">{{ group.dimension_label }}</h3>
</div>
<span class="rounded-full bg-slate-100 px-3 py-1 text-xs font-bold text-slate-500 dark:bg-slate-800">{{ group.medals|length }} medals</span>
</div>
<div class="divide-y divide-slate-100 dark:divide-slate-800">
{% for medal in group.medals %}
<a href="{{ url_for('players.detail', steam_id=medal.steam_id_64) }}" class="flex items-center gap-4 px-5 py-4 hover:bg-slate-50 dark:hover:bg-slate-800/40">
<div class="flex h-11 w-11 flex-none items-center justify-center rounded-full bg-gradient-to-br text-lg font-black shadow {{ medal_styles[medal.medal_tier] }}">{{ medal.medal_rank }}</div>
<div class="min-w-0 flex-1">
<div class="truncate font-black text-slate-900 dark:text-white">{{ medal.identity.username or medal.steam_id_64 }}</div>
<div class="text-xs text-slate-400">{{ medal.matches }} 场 · K/D {{ '%.2f'|format(medal.avg_kd) }} · ADR {{ '%.1f'|format(medal.avg_adr) }}</div>
</div>
<div class="text-right">
<div class="text-xl font-black text-yrtv-600">{{ '%.2f'|format(medal.avg_rating) }}</div>
<div class="text-[10px] uppercase text-slate-400">{{ '%.0f%%'|format(medal.win_rate * 100) }} win</div>
</div>
</a>
{% endfor %}
</div>
</article>
{% endfor %}
</div>
</section>
<section class="rounded-2xl bg-gradient-to-r from-amber-500 to-orange-600 p-6 text-white shadow-xl">
<h2 class="text-xl font-black">勋章总榜</h2>
<div class="mt-5 grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{% for player in leaderboard %}
<a href="{{ url_for('players.detail', steam_id=player.steam_id_64) }}" class="flex items-center justify-between rounded-xl bg-white/10 p-4 hover:bg-white/20">
<div><div class="font-black">{{ loop.index }}. {{ player.identity.username or player.steam_id_64 }}</div><div class="mt-1 text-xs text-amber-100">🥇 {{ player.gold }} · 🥈 {{ player.silver }} · 🥉 {{ player.bronze }}</div></div>
<div class="text-3xl font-black">{{ player.medals }}</div>
</a>
{% endfor %}
</div>
</section>
</div>
{% endblock %}
+1 -1
View File
@@ -1,6 +1,6 @@
<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-lg dark:border-slate-700 dark:bg-slate-800"
x-data="{ selected: 'last_20', periods: {{ period_stats|tojson }} }">
x-data="{ selected: 'last_20' }">
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h3 class="text-lg font-bold text-gray-900 dark:text-white">阶段表现</h3>
@@ -42,4 +42,22 @@
{% endfor %}
</div>
{% endif %}
{% if performance_medals %}
<div class="mt-4 border-t border-white/10 pt-4">
<div class="mb-2 text-[10px] font-black uppercase tracking-widest text-slate-400">Map & ELO Medals</div>
<div class="flex gap-2 overflow-x-auto">
{% for medal in performance_medals %}
<a href="{{ url_for('discover.index', dimension=medal.dimension_type) }}#medals"
class="whitespace-nowrap rounded-full px-3 py-1 text-xs font-bold
{% if medal.medal_tier == 'gold' %}bg-amber-400/20 text-amber-200
{% elif medal.medal_tier == 'silver' %}bg-slate-300/20 text-slate-200
{% else %}bg-orange-500/20 text-orange-200{% endif %}">
{% if medal.medal_tier == 'gold' %}🥇{% elif medal.medal_tier == 'silver' %}🥈{% else %}🥉{% endif %}
{{ medal.dimension_label }}
</a>
{% endfor %}
</div>
</div>
{% endif %}
</section>