2.0.0-rc2: Achievements Refactored & Admin improved.
This commit is contained in:
@@ -25,8 +25,10 @@ YRTV 是面向固定 CS2 战队的私人数据站。它不是公共玩家排行
|
|||||||
- 203 次职业纪录刷新事件
|
- 203 次职业纪录刷新事件
|
||||||
- 2 个战队赛季
|
- 2 个战队赛季
|
||||||
- 111 个日/周/月/季/年度奖项
|
- 111 个日/周/月/季/年度奖项
|
||||||
- 31 项自动化测试通过
|
- 19 条正面/负面/趣味数据发现
|
||||||
- 39 项数据完整性检查通过
|
- 36 枚地图与 ELO 分段勋章
|
||||||
|
- 36 项自动化测试通过
|
||||||
|
- 43 项数据完整性检查通过
|
||||||
|
|
||||||
数据规模会随导入变化,Admin 数据完整性中心显示的结果是运行时事实。
|
数据规模会随导入变化,Admin 数据完整性中心显示的结果是运行时事实。
|
||||||
|
|
||||||
@@ -77,6 +79,16 @@ YRTV 是面向固定 CS2 战队的私人数据站。它不是公共玩家排行
|
|||||||
- 个人主页展示荣誉数量和近期奖章
|
- 个人主页展示荣誉数量和近期奖章
|
||||||
- 每次职业纪录刷新保留时间和对应比赛
|
- 每次职业纪录刷新保留时间和对应比赛
|
||||||
|
|
||||||
|
### 发现与勋章
|
||||||
|
|
||||||
|
- 独立 `/discover/` 发现模块
|
||||||
|
- 高光、低谷和趣味数据同时展示
|
||||||
|
- 爆种、尽力局、躺赢、低谷、稳定性、夜间比赛和连胜等发现
|
||||||
|
- 每张达到门槛的地图评选金银铜
|
||||||
|
- 每个达到门槛的 ELO 分段评选金银铜
|
||||||
|
- 勋章最低 5 场,展示 Rating、K/D、ADR、胜率和样本数
|
||||||
|
- 个人职业主页同步展示已获得勋章
|
||||||
|
|
||||||
### 数据运营
|
### 数据运营
|
||||||
|
|
||||||
- Admin 上传 `iframe_network.json`
|
- Admin 上传 `iframe_network.json`
|
||||||
@@ -85,6 +97,10 @@ YRTV 是面向固定 CS2 战队的私人数据站。它不是公共玩家排行
|
|||||||
- 后台执行 L1 → L2 → L3
|
- 后台执行 L1 → L2 → L3
|
||||||
- 实时查看作业阶段、进度、日志和耗时
|
- 实时查看作业阶段、进度、日志和耗时
|
||||||
- 数据完整性中心与 JSON 报告
|
- 数据完整性中心与 JSON 报告
|
||||||
|
- 独立运营总览、比赛导入、作业中心、数据质量、查询控制台和系统信息
|
||||||
|
- 数据库对象目录与只读 SQL 执行耗时
|
||||||
|
- L1/L2/L3/Web 文件状态和回滚快照可视化
|
||||||
|
- 后台写操作使用 CSRF 防护
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
|
|
||||||
@@ -213,6 +229,8 @@ Flask services and player profiles
|
|||||||
- `dm_player_record_events`
|
- `dm_player_record_events`
|
||||||
- `dm_team_season_stats`
|
- `dm_team_season_stats`
|
||||||
- `dm_player_awards`
|
- `dm_player_awards`
|
||||||
|
- `dm_discovery_insights`
|
||||||
|
- `dm_performance_medals`
|
||||||
|
|
||||||
### Web:应用状态
|
### Web:应用状态
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -335,6 +335,13 @@ def main(force_all: bool = False, workers: int = 1, create_backup: bool = True):
|
|||||||
processed_ids,
|
processed_ids,
|
||||||
)
|
)
|
||||||
logger.info("Narrative marts rebuilt: %s", narrative_counts)
|
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]
|
quick_check = conn_l3.execute("PRAGMA quick_check").fetchone()[0]
|
||||||
if quick_check != 'ok':
|
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
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
@@ -587,6 +587,61 @@ ON dm_player_awards(steam_id_64, period_start DESC);
|
|||||||
CREATE INDEX IF NOT EXISTS idx_player_awards_period
|
CREATE INDEX IF NOT EXISTS idx_player_awards_period
|
||||||
ON dm_player_awards(award_type, period_start DESC);
|
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
|
-- Schema Summary
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
@@ -609,4 +664,6 @@ ON dm_player_awards(award_type, period_start DESC);
|
|||||||
-- dm_player_record_events: Historical record-breaking moments
|
-- dm_player_record_events: Historical record-breaking moments
|
||||||
-- dm_team_season_stats: Calendar season team summaries
|
-- dm_team_season_stats: Calendar season team summaries
|
||||||
-- dm_player_awards: Daily/weekly/monthly/quarterly/yearly awards
|
-- 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
@@ -48,19 +48,63 @@ class JobStore:
|
|||||||
finally:
|
finally:
|
||||||
db.close()
|
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()
|
db = self._connect()
|
||||||
try:
|
try:
|
||||||
rows = db.execute(
|
rows = db.execute(
|
||||||
"""
|
"""
|
||||||
SELECT *
|
SELECT status, COUNT(*) AS count
|
||||||
FROM etl_jobs
|
FROM etl_jobs
|
||||||
ORDER BY created_at DESC, id DESC
|
GROUP BY status
|
||||||
LIMIT ?
|
"""
|
||||||
""",
|
|
||||||
[max(1, min(int(limit), 100))],
|
|
||||||
).fetchall()
|
).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:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
@@ -195,4 +239,3 @@ class JobStore:
|
|||||||
db.commit()
|
db.commit()
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|||||||
+163
-2
@@ -46,6 +46,7 @@ class ApplicationIntegrationTests(unittest.TestCase):
|
|||||||
'/matches/',
|
'/matches/',
|
||||||
'/reports/',
|
'/reports/',
|
||||||
'/awards/',
|
'/awards/',
|
||||||
|
'/discover/',
|
||||||
'/players/',
|
'/players/',
|
||||||
f'/players/{self.roster_ids[0]}',
|
f'/players/{self.roster_ids[0]}',
|
||||||
'/teams/',
|
'/teams/',
|
||||||
@@ -59,6 +60,35 @@ class ApplicationIntegrationTests(unittest.TestCase):
|
|||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertGreater(len(response.data), 100)
|
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):
|
def test_match_detail_contains_post_match_report(self):
|
||||||
l3 = sqlite3.connect(Config.DB_L3_PATH)
|
l3 = sqlite3.connect(Config.DB_L3_PATH)
|
||||||
try:
|
try:
|
||||||
@@ -103,10 +133,11 @@ class ApplicationIntegrationTests(unittest.TestCase):
|
|||||||
def test_admin_integrity_page_and_json_render(self):
|
def test_admin_integrity_page_and_json_render(self):
|
||||||
with self.client.session_transaction() as session:
|
with self.client.session_transaction() as session:
|
||||||
session['is_admin'] = True
|
session['is_admin'] = True
|
||||||
|
session['_csrf_token'] = 'test-csrf'
|
||||||
|
|
||||||
response = self.client.get('/admin/data-integrity')
|
response = self.client.get('/admin/data-integrity')
|
||||||
self.assertEqual(response.status_code, 200)
|
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')
|
response = self.client.get('/admin/data-integrity?format=json')
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
@@ -120,13 +151,66 @@ class ApplicationIntegrationTests(unittest.TestCase):
|
|||||||
|
|
||||||
response = self.client.get('/admin/import-match')
|
response = self.client.get('/admin/import-match')
|
||||||
self.assertEqual(response.status_code, 200)
|
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):
|
def test_duplicate_match_upload_is_rejected_without_starting_job(self):
|
||||||
from database.paths import L1_DB
|
from database.paths import L1_DB
|
||||||
|
|
||||||
with self.client.session_transaction() as session:
|
with self.client.session_transaction() as session:
|
||||||
session['is_admin'] = True
|
session['is_admin'] = True
|
||||||
|
session['_csrf_token'] = 'test-csrf'
|
||||||
with sqlite3.connect(str(L1_DB)) as db:
|
with sqlite3.connect(str(L1_DB)) as db:
|
||||||
raw = db.execute(
|
raw = db.execute(
|
||||||
"""
|
"""
|
||||||
@@ -142,6 +226,7 @@ class ApplicationIntegrationTests(unittest.TestCase):
|
|||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
'/admin/import-match',
|
'/admin/import-match',
|
||||||
data={
|
data={
|
||||||
|
'_csrf_token': 'test-csrf',
|
||||||
'capture': (
|
'capture': (
|
||||||
upload,
|
upload,
|
||||||
'iframe_network.json',
|
'iframe_network.json',
|
||||||
@@ -178,6 +263,8 @@ class ApplicationIntegrationTests(unittest.TestCase):
|
|||||||
):
|
):
|
||||||
with self.subTest(label=label):
|
with self.subTest(label=label):
|
||||||
self.assertIn(label.encode('utf-8'), response.data)
|
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):
|
def test_profile_period_api_and_trend_window(self):
|
||||||
steam_id = self.roster_ids[0]
|
steam_id = self.roster_ids[0]
|
||||||
@@ -699,6 +786,80 @@ class DatabaseGovernanceTests(unittest.TestCase):
|
|||||||
self.assertNotIn((award_type, period_key), seen)
|
self.assertNotIn((award_type, period_key), seen)
|
||||||
seen.add((award_type, period_key))
|
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):
|
def test_seasons_only_include_team_matches(self):
|
||||||
l2 = sqlite3.connect(Config.DB_L2_PATH)
|
l2 = sqlite3.connect(Config.DB_L2_PATH)
|
||||||
l3 = sqlite3.connect(Config.DB_L3_PATH)
|
l3 = sqlite3.connect(Config.DB_L3_PATH)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
|
|||||||
from flask import Flask
|
from flask import Flask
|
||||||
from web.config import Config
|
from web.config import Config
|
||||||
from web.database import close_dbs, initialize_web_db
|
from web.database import close_dbs, initialize_web_db
|
||||||
|
from web.auth import get_csrf_token
|
||||||
|
|
||||||
|
|
||||||
def create_app(config_object=Config):
|
def create_app(config_object=Config):
|
||||||
@@ -16,10 +17,12 @@ def create_app(config_object=Config):
|
|||||||
from web.services.roster_version_service import RosterVersionService
|
from web.services.roster_version_service import RosterVersionService
|
||||||
RosterVersionService.ensure_initial_version()
|
RosterVersionService.ensure_initial_version()
|
||||||
app.teardown_appcontext(close_dbs)
|
app.teardown_appcontext(close_dbs)
|
||||||
|
app.jinja_env.globals['csrf_token'] = get_csrf_token
|
||||||
|
|
||||||
from web.routes import (
|
from web.routes import (
|
||||||
admin,
|
admin,
|
||||||
awards,
|
awards,
|
||||||
|
discover,
|
||||||
main,
|
main,
|
||||||
matches,
|
matches,
|
||||||
opponents,
|
opponents,
|
||||||
@@ -39,6 +42,7 @@ def create_app(config_object=Config):
|
|||||||
app.register_blueprint(opponents.bp)
|
app.register_blueprint(opponents.bp)
|
||||||
app.register_blueprint(reports.bp)
|
app.register_blueprint(reports.bp)
|
||||||
app.register_blueprint(awards.bp)
|
app.register_blueprint(awards.bp)
|
||||||
|
app.register_blueprint(discover.bp)
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|
||||||
|
|||||||
+33
-1
@@ -1,5 +1,8 @@
|
|||||||
from functools import wraps
|
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):
|
def admin_required(f):
|
||||||
@wraps(f)
|
@wraps(f)
|
||||||
@@ -9,3 +12,32 @@ def admin_required(f):
|
|||||||
flash('Admin access required', 'warning')
|
flash('Admin access required', 'warning')
|
||||||
return redirect(url_for('admin.login'))
|
return redirect(url_for('admin.login'))
|
||||||
return decorated_function
|
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
@@ -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.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.database import query_db
|
||||||
|
from web.services.admin_service import AdminService
|
||||||
from web.services.etl_service import EtlService
|
from web.services.etl_service import EtlService
|
||||||
import hmac
|
import hmac
|
||||||
|
import time
|
||||||
|
|
||||||
bp = Blueprint('admin', __name__, url_prefix='/admin')
|
bp = Blueprint('admin', __name__, url_prefix='/admin')
|
||||||
|
|
||||||
@bp.route('/login', methods=['GET', 'POST'])
|
@bp.route('/login', methods=['GET', 'POST'])
|
||||||
def login():
|
def login():
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
|
validate_csrf()
|
||||||
token = request.form.get('token') or ''
|
token = request.form.get('token') or ''
|
||||||
if hmac.compare_digest(token, Config.ADMIN_TOKEN):
|
if hmac.compare_digest(token, Config.ADMIN_TOKEN):
|
||||||
session['is_admin'] = True
|
session['is_admin'] = True
|
||||||
@@ -26,7 +38,10 @@ def logout():
|
|||||||
@bp.route('/')
|
@bp.route('/')
|
||||||
@admin_required
|
@admin_required
|
||||||
def dashboard():
|
def dashboard():
|
||||||
return render_template('admin/dashboard.html')
|
return render_template(
|
||||||
|
'admin/dashboard.html',
|
||||||
|
**AdminService.get_overview(),
|
||||||
|
)
|
||||||
|
|
||||||
@bp.route('/data-integrity')
|
@bp.route('/data-integrity')
|
||||||
@admin_required
|
@admin_required
|
||||||
@@ -40,6 +55,7 @@ def data_integrity():
|
|||||||
|
|
||||||
@bp.route('/trigger_etl', methods=['POST'])
|
@bp.route('/trigger_etl', methods=['POST'])
|
||||||
@admin_required
|
@admin_required
|
||||||
|
@csrf_protected
|
||||||
def trigger_etl():
|
def trigger_etl():
|
||||||
from database.job_store import JobStore
|
from database.job_store import JobStore
|
||||||
|
|
||||||
@@ -68,6 +84,7 @@ def import_match():
|
|||||||
|
|
||||||
store = JobStore(Config.DB_WEB_PATH)
|
store = JobStore(Config.DB_WEB_PATH)
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
|
validate_csrf()
|
||||||
upload = request.files.get('capture')
|
upload = request.files.get('capture')
|
||||||
if not upload or not upload.filename:
|
if not upload or not upload.filename:
|
||||||
flash('请选择 iframe_network.json 文件。', 'error')
|
flash('请选择 iframe_network.json 文件。', 'error')
|
||||||
@@ -91,7 +108,7 @@ def import_match():
|
|||||||
'success',
|
'success',
|
||||||
)
|
)
|
||||||
return redirect(url_for(
|
return redirect(url_for(
|
||||||
'admin.import_match',
|
'admin.jobs',
|
||||||
job_id=prepared['job_id'],
|
job_id=prepared['job_id'],
|
||||||
))
|
))
|
||||||
except (DuplicateMatchError, ImportValidationError) as exc:
|
except (DuplicateMatchError, ImportValidationError) as exc:
|
||||||
@@ -106,14 +123,34 @@ def import_match():
|
|||||||
)
|
)
|
||||||
flash(f'启动导入失败:{exc}', 'error')
|
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(
|
return render_template(
|
||||||
'admin/import_match.html',
|
'admin/import_match.html',
|
||||||
jobs=store.list_jobs(30),
|
jobs=store.list_jobs(5),
|
||||||
selected_job=selected_job,
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@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
|
result = None
|
||||||
error = None
|
error = None
|
||||||
query = ""
|
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':
|
if request.method == 'POST':
|
||||||
|
validate_csrf()
|
||||||
query = (request.form.get('query') or '').strip()
|
query = (request.form.get('query') or '').strip()
|
||||||
db_name = request.form.get('db_name', 'l2')
|
db_name = request.form.get('db_name', 'l2')
|
||||||
|
|
||||||
@@ -151,8 +193,10 @@ def sql_runner():
|
|||||||
query = statement
|
query = statement
|
||||||
if 'LIMIT' not in statement.upper():
|
if 'LIMIT' not in statement.upper():
|
||||||
query = f"{statement} LIMIT 50"
|
query = f"{statement} LIMIT 50"
|
||||||
|
started = time.perf_counter()
|
||||||
rows = query_db(db_name, query)
|
rows = query_db(db_name, query)
|
||||||
|
duration_ms = (time.perf_counter() - started) * 1000
|
||||||
|
row_count = len(rows)
|
||||||
if rows:
|
if rows:
|
||||||
columns = rows[0].keys()
|
columns = rows[0].keys()
|
||||||
result = {'columns': columns, 'rows': rows}
|
result = {'columns': columns, 'rows': rows}
|
||||||
@@ -160,5 +204,18 @@ def sql_runner():
|
|||||||
result = {'columns': [], 'rows': []}
|
result = {'columns': [], 'rows': []}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
error = str(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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
|
|
||||||
@@ -3,6 +3,7 @@ from web.services.stats_service import StatsService
|
|||||||
from web.services.feature_service import FeatureService
|
from web.services.feature_service import FeatureService
|
||||||
from web.services.player_profile_service import PlayerProfileService
|
from web.services.player_profile_service import PlayerProfileService
|
||||||
from web.services.narrative_service import NarrativeService
|
from web.services.narrative_service import NarrativeService
|
||||||
|
from web.services.discovery_service import DiscoveryService
|
||||||
from web.services.web_service import WebService
|
from web.services.web_service import WebService
|
||||||
from web.database import execute_db, query_db
|
from web.database import execute_db, query_db
|
||||||
from web.config import Config
|
from web.config import Config
|
||||||
@@ -205,6 +206,7 @@ def detail(steam_id):
|
|||||||
records=records,
|
records=records,
|
||||||
professional_identity=NarrativeService.get_player_identity(steam_id),
|
professional_identity=NarrativeService.get_player_identity(steam_id),
|
||||||
honors=NarrativeService.get_player_honors(steam_id),
|
honors=NarrativeService.get_player_honors(steam_id),
|
||||||
|
performance_medals=DiscoveryService.get_player_medals(steam_id),
|
||||||
l2_stats=l2_stats,
|
l2_stats=l2_stats,
|
||||||
side_stats=side_stats)
|
side_stats=side_stats)
|
||||||
|
|
||||||
|
|||||||
@@ -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'
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -246,6 +246,7 @@ class IntegrityService:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def _check_l3(db, roster_ids, checks, counts):
|
def _check_l3(db, roster_ids, checks, counts):
|
||||||
required_tables = {
|
required_tables = {
|
||||||
|
'dm_discovery_insights',
|
||||||
'dm_duo_stats',
|
'dm_duo_stats',
|
||||||
'dm_match_player_reports',
|
'dm_match_player_reports',
|
||||||
'dm_match_reports',
|
'dm_match_reports',
|
||||||
@@ -257,6 +258,7 @@ class IntegrityService:
|
|||||||
'dm_player_record_events',
|
'dm_player_record_events',
|
||||||
'dm_player_records',
|
'dm_player_records',
|
||||||
'dm_player_weapon_stats',
|
'dm_player_weapon_stats',
|
||||||
|
'dm_performance_medals',
|
||||||
'dm_lineup_stats',
|
'dm_lineup_stats',
|
||||||
'dm_team_season_stats',
|
'dm_team_season_stats',
|
||||||
}
|
}
|
||||||
@@ -309,6 +311,12 @@ class IntegrityService:
|
|||||||
counts['l3_awards'] = db.execute(
|
counts['l3_awards'] = db.execute(
|
||||||
'SELECT COUNT(*) FROM dm_player_awards'
|
'SELECT COUNT(*) FROM dm_player_awards'
|
||||||
).fetchone()[0]
|
).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)
|
expected_matches = counts.get('matches', 0)
|
||||||
IntegrityService._check(
|
IntegrityService._check(
|
||||||
checks,
|
checks,
|
||||||
@@ -443,6 +451,8 @@ class IntegrityService:
|
|||||||
('l3_record_events', 'Record event mart'),
|
('l3_record_events', 'Record event mart'),
|
||||||
('l3_seasons', 'Team season mart'),
|
('l3_seasons', 'Team season mart'),
|
||||||
('l3_awards', 'Player award mart'),
|
('l3_awards', 'Player award mart'),
|
||||||
|
('l3_discoveries', 'Discovery insight mart'),
|
||||||
|
('l3_medals', 'Map and ELO medal mart'),
|
||||||
):
|
):
|
||||||
value = counts[key]
|
value = counts[key]
|
||||||
IntegrityService._check(
|
IntegrityService._check(
|
||||||
@@ -453,6 +463,53 @@ class IntegrityService:
|
|||||||
value,
|
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:
|
if roster_ids:
|
||||||
placeholders = ','.join('?' for _ in roster_ids)
|
placeholders = ','.join('?' for _ in roster_ids)
|
||||||
scope_sql = f"AND steam_id_64 IN ({placeholders})"
|
scope_sql = f"AND steam_id_64 IN ({placeholders})"
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|
||||||
@@ -1,57 +1,147 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "admin/base.html" %}
|
||||||
|
|
||||||
{% block content %}
|
{% block admin_title %}运营总览{% endblock %}
|
||||||
<div class="bg-white dark:bg-slate-800 shadow rounded-lg p-6">
|
{% block page_title %}运营总览{% endblock %}
|
||||||
<div class="flex justify-between items-center mb-6">
|
{% block page_subtitle %}数据规模、流水线状态与最近比赛一屏掌握{% endblock %}
|
||||||
<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>
|
{% block admin_content %}
|
||||||
</div>
|
{% set health_color = 'emerald' if integrity.overall_status == 'pass' else ('amber' if integrity.overall_status == 'warn' else 'red') %}
|
||||||
|
<div class="space-y-7">
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<section class="grid grid-cols-2 gap-3 xl:grid-cols-5">
|
||||||
<!-- ETL Controls -->
|
{% for label, value, note in [
|
||||||
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
('比赛总量', metrics.matches, 'L2 matches'),
|
||||||
<h3 class="text-lg font-bold text-gray-900 dark:text-white mb-4">数据管线 (ETL)</h3>
|
('Active Roster', metrics.roster_players, roster_versions|string + ' roster version'),
|
||||||
<div class="space-y-2">
|
('赛后报告', metrics.reports, 'L3 reports'),
|
||||||
<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>
|
('周期奖项', metrics.awards, 'Awards generated'),
|
||||||
<button onclick="triggerEtl()" class="w-full bg-blue-600 text-white py-2 px-4 rounded hover:bg-blue-700">运行完整 L1 → L2 → L3</button>
|
('备份占用', metrics.backup_size, integrity.counts.backup_sets|string + ' snapshots')
|
||||||
</div>
|
] %}
|
||||||
<div id="etlResult" class="mt-4 text-sm text-gray-600 dark:text-gray-400"></div>
|
<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>
|
</div>
|
||||||
|
{% endfor %}
|
||||||
<!-- Tools -->
|
</section>
|
||||||
<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="grid grid-cols-1 gap-6 xl:grid-cols-3">
|
||||||
<div class="space-y-2">
|
<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">
|
||||||
<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>
|
<div class="flex flex-col gap-6 sm:flex-row sm:items-start sm:justify-between">
|
||||||
<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>
|
<div>
|
||||||
<a href="{{ url_for('wiki.index') }}" class="block w-full text-center bg-gray-600 text-white py-2 px-4 rounded hover:bg-gray-700">Manage Wiki</a>
|
<div 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>
|
<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>
|
||||||
|
|
||||||
|
<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>
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
{{ super() }}
|
||||||
<script>
|
<script>
|
||||||
function triggerEtl() {
|
async function triggerPipeline() {
|
||||||
const resultDiv = document.getElementById('etlResult');
|
const button = document.getElementById('pipeline-button');
|
||||||
resultDiv.innerText = "正在创建后台流水线...";
|
const message = document.getElementById('pipeline-message');
|
||||||
|
button.disabled = true;
|
||||||
fetch("{{ url_for('admin.trigger_etl') }}", {
|
message.textContent = '正在创建后台流水线...';
|
||||||
method: 'POST',
|
try {
|
||||||
headers: {
|
const response = await fetch("{{ url_for('admin.trigger_etl') }}", {
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
method: 'POST',
|
||||||
},
|
headers: {'X-CSRF-Token': "{{ csrf_token() }}"}
|
||||||
})
|
});
|
||||||
.then(response => response.json())
|
const data = await response.json();
|
||||||
.then(data => {
|
if (!response.ok || !data.success) throw new Error(data.error || '启动失败');
|
||||||
if (data.success) {
|
window.location.href = "{{ url_for('admin.jobs') }}?job_id=" + data.job_id;
|
||||||
window.location.href = "{{ url_for('admin.import_match') }}?job_id=" + data.job_id;
|
} catch (error) {
|
||||||
} else {
|
message.textContent = error.message;
|
||||||
resultDiv.innerText = data.error || "启动失败";
|
button.disabled = false;
|
||||||
}
|
}
|
||||||
})
|
|
||||||
.catch(err => {
|
|
||||||
resultDiv.innerText = "Error: " + err;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -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 = {
|
{% set status_styles = {
|
||||||
'pass': 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-300',
|
'pass': 'bg-emerald-100 text-emerald-700 border-emerald-200',
|
||||||
'warn': 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300',
|
'warn': 'bg-amber-100 text-amber-700 border-amber-200',
|
||||||
'fail': 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300'
|
'fail': 'bg-red-100 text-red-700 border-red-200'
|
||||||
} %}
|
} %}
|
||||||
<div class="space-y-6 px-4 sm:px-0">
|
<div class="space-y-6">
|
||||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
<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>
|
<div class="flex flex-col gap-5 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div class="flex items-center gap-3">
|
<div>
|
||||||
<h1 class="text-2xl font-bold text-slate-900 dark:text-white">数据完整性中心</h1>
|
<div class="text-xs font-black uppercase tracking-[0.2em] text-white/70">Integrity Report</div>
|
||||||
<span class="rounded-full px-3 py-1 text-xs font-semibold uppercase {{ status_styles[report.overall_status] }}">
|
<div class="mt-2 flex items-baseline gap-3">
|
||||||
{{ report.overall_status }}
|
<div class="text-5xl font-black">{{ report.totals.pass }}</div>
|
||||||
</span>
|
<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>
|
</div>
|
||||||
<p class="mt-1 text-sm text-slate-500 dark:text-slate-400">
|
|
||||||
校验时间:{{ report.generated_at }}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2">
|
<div class="mt-6 flex gap-3">
|
||||||
<a href="{{ url_for('admin.data_integrity', format='json') }}"
|
<a href="{{ url_for('admin.data_integrity') }}" class="rounded-xl bg-white px-4 py-2 text-sm font-black text-slate-900">重新检查</a>
|
||||||
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">
|
<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>
|
||||||
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>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
|
|
||||||
<div class="grid grid-cols-3 gap-3">
|
<section class="grid grid-cols-2 gap-3 sm:grid-cols-3 xl:grid-cols-6">
|
||||||
{% for status, label in [('pass', '通过'), ('warn', '警告'), ('fail', '失败')] %}
|
{% for label, value in [
|
||||||
<div class="rounded-xl bg-white p-4 shadow dark:bg-slate-800">
|
('Matches', report.counts.matches),
|
||||||
<div class="text-sm text-slate-500 dark:text-slate-400">{{ label }}</div>
|
('Players', report.counts.players),
|
||||||
<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 %}">
|
('Rounds', report.counts.rounds),
|
||||||
{{ report.totals[status] }}
|
('Events', report.counts.events),
|
||||||
</div>
|
('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>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</section>
|
||||||
|
|
||||||
<div class="rounded-xl bg-white p-5 shadow dark:bg-slate-800">
|
<div class="grid grid-cols-1 gap-6 xl:grid-cols-[minmax(0,1fr)_300px]">
|
||||||
<h2 class="mb-4 text-lg font-semibold text-slate-900 dark:text-white">数据规模</h2>
|
<section class="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900">
|
||||||
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
|
<div class="border-b border-slate-100 px-5 py-4 dark:border-slate-800">
|
||||||
{% for key, value in report.counts.items() %}
|
<h2 class="font-black text-slate-900 dark:text-white">检查明细</h2>
|
||||||
<div class="rounded-lg bg-slate-50 p-3 dark:bg-slate-900/60">
|
<p class="text-xs text-slate-400">失败优先,其次警告,最后通过项</p>
|
||||||
<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>
|
</div>
|
||||||
{% endfor %}
|
<div class="divide-y divide-slate-100 dark:divide-slate-800">
|
||||||
</div>
|
{% for target_status in ['fail', 'warn', 'pass'] %}
|
||||||
</div>
|
{% 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="overflow-hidden rounded-xl bg-white shadow dark:bg-slate-800">
|
<div class="min-w-0">
|
||||||
<div class="border-b border-slate-200 px-5 py-4 dark:border-slate-700">
|
<div class="font-bold text-slate-800 dark:text-slate-200">{{ check.name }}</div>
|
||||||
<h2 class="text-lg font-semibold text-slate-900 dark:text-white">校验项目</h2>
|
<div class="mt-1 text-xs leading-5 text-slate-400">{{ check.detail }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="divide-y divide-slate-100 dark:divide-slate-700">
|
<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>
|
||||||
{% for check in report.checks %}
|
</div>
|
||||||
<div class="flex flex-col gap-2 px-5 py-4 sm:flex-row sm:items-center sm:justify-between">
|
{% endfor %}
|
||||||
<div>
|
{% endfor %}
|
||||||
<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>
|
</div>
|
||||||
{% endfor %}
|
</section>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<aside class="space-y-4">
|
||||||
<a href="{{ url_for('admin.dashboard') }}" class="text-sm font-medium text-yrtv-600 hover:text-yrtv-500">
|
{% for title, entries in [
|
||||||
返回管理后台
|
('Roster & Team', [
|
||||||
</a>
|
('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>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -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 %}
|
{% block admin_content %}
|
||||||
<div class="space-y-6 px-4 sm:px-0">
|
<div class="grid grid-cols-1 gap-6 xl:grid-cols-[minmax(0,1fr)_360px]">
|
||||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
<section class="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm dark:border-slate-800 dark:bg-slate-900">
|
||||||
{% if messages %}
|
<div class="flex items-start justify-between gap-4">
|
||||||
<div class="space-y-2">
|
<div>
|
||||||
{% for category, message in messages %}
|
<h2 class="text-xl font-black text-slate-900 dark:text-white">上传 iframe_network.json</h2>
|
||||||
<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 %}">
|
<p class="mt-1 text-sm text-slate-500">系统自动提取比赛 ID、计算 SHA256、检查必要接口并拒绝重复数据。</p>
|
||||||
{{ message }}
|
</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>
|
</div>
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% endwith %}
|
|
||||||
|
|
||||||
<div class="flex items-center justify-between">
|
<form method="POST" enctype="multipart/form-data" class="mt-6 space-y-5">
|
||||||
<div>
|
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||||
<h1 class="text-2xl font-bold text-slate-900 dark:text-white">比赛数据导入</h1>
|
<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">
|
||||||
<p class="mt-1 text-sm text-slate-500 dark:text-slate-400">
|
<input id="capture-input" type="file" name="capture" accept=".json,application/json" required class="sr-only">
|
||||||
上传完整的 iframe_network.json,系统会自动识别比赛 ID,并执行 L1 → L2 → L3。
|
<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>
|
||||||
</p>
|
<div id="file-name" class="mt-4 font-black text-slate-800 dark:text-white">点击选择 JSON 文件</div>
|
||||||
</div>
|
<div class="mt-2 text-sm text-slate-400">或将 iframe_network.json 拖到这里</div>
|
||||||
<a href="{{ url_for('admin.dashboard') }}" class="text-sm font-medium text-yrtv-600 hover:text-yrtv-500">
|
<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>
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
<div class="rounded-xl border border-amber-200 bg-amber-50 p-4 dark:border-amber-900/50 dark:bg-amber-950/20">
|
||||||
<div class="rounded-xl bg-white p-6 shadow dark:bg-slate-800">
|
<label class="flex items-start gap-3">
|
||||||
<h2 class="text-lg font-semibold text-slate-900 dark:text-white">上传抓包</h2>
|
<input type="checkbox" name="replace" value="1" class="mt-0.5 rounded border-amber-300 text-amber-600">
|
||||||
<form method="POST" enctype="multipart/form-data" class="mt-5 space-y-4">
|
<span>
|
||||||
<div>
|
<span class="block text-sm font-black text-amber-800 dark:text-amber-300">允许替换内容不同的已有比赛</span>
|
||||||
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300">
|
<span class="mt-1 block text-xs leading-5 text-amber-700/80 dark:text-amber-400">仅在确认原始抓包需要修复时使用。相同内容永远拒绝重复导入。</span>
|
||||||
iframe_network.json
|
</span>
|
||||||
</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>
|
|
||||||
</label>
|
</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>
|
</div>
|
||||||
|
|
||||||
{% if selected_job %}
|
<div class="flex flex-col gap-3 sm:flex-row">
|
||||||
<div class="mt-5">
|
<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>
|
||||||
<div class="flex justify-between text-sm text-slate-600 dark:text-slate-300">
|
<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>
|
||||||
<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>
|
</div>
|
||||||
{% else %}
|
</form>
|
||||||
<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">
|
</section>
|
||||||
上传比赛或从下方选择历史作业。
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="overflow-hidden rounded-xl bg-white shadow dark:bg-slate-800">
|
<aside class="space-y-6">
|
||||||
<div class="border-b border-slate-200 px-5 py-4 dark:border-slate-700">
|
<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-semibold text-slate-900 dark:text-white">最近作业</h2>
|
<h2 class="font-black text-slate-900 dark:text-white">导入流程</h2>
|
||||||
</div>
|
<ol class="mt-4 space-y-3">
|
||||||
<div class="overflow-x-auto">
|
{% for title, detail in [
|
||||||
<table class="min-w-full divide-y divide-slate-200 dark:divide-slate-700">
|
('结构校验', 'JSON、比赛 ID、match/round 接口'),
|
||||||
<thead class="bg-slate-50 dark:bg-slate-900/50">
|
('内容去重', 'SHA256 与 L1、待处理文件比对'),
|
||||||
<tr>
|
('安全备份', '保存 L1/L2/L3 回滚快照'),
|
||||||
<th class="px-5 py-3 text-left text-xs font-semibold uppercase text-slate-500">ID</th>
|
('分层构建', '串行执行 L1 → L2 → L3'),
|
||||||
<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>
|
<li class="flex gap-3">
|
||||||
<th class="px-5 py-3 text-left text-xs font-semibold uppercase text-slate-500">阶段</th>
|
<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>
|
||||||
<th class="px-5 py-3 text-left text-xs font-semibold uppercase text-slate-500">耗时</th>
|
<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>
|
||||||
</tr>
|
</li>
|
||||||
</thead>
|
{% endfor %}
|
||||||
<tbody class="divide-y divide-slate-100 dark:divide-slate-700">
|
</ol>
|
||||||
{% for job in jobs %}
|
</section>
|
||||||
<tr class="hover:bg-slate-50 dark:hover:bg-slate-700/40">
|
|
||||||
<td class="px-5 py-3 text-sm">
|
<section class="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900">
|
||||||
<a href="{{ url_for('admin.import_match', job_id=job.id) }}" class="font-medium text-yrtv-600">#{{ job.id }}</a>
|
<div class="flex items-center justify-between border-b border-slate-100 px-5 py-4 dark:border-slate-800">
|
||||||
</td>
|
<h2 class="font-black text-slate-900 dark:text-white">最近导入作业</h2>
|
||||||
<td class="px-5 py-3 text-sm text-slate-600 dark:text-slate-300">{{ job.job_type }}</td>
|
<a href="{{ url_for('admin.jobs') }}" class="text-xs font-bold text-yrtv-600">全部</a>
|
||||||
<td class="px-5 py-3 text-sm font-mono text-slate-600 dark:text-slate-300">{{ job.match_id or '-' }}</td>
|
</div>
|
||||||
<td class="px-5 py-3 text-sm text-slate-600 dark:text-slate-300">{{ job.status }}</td>
|
<div class="divide-y divide-slate-100 dark:divide-slate-800">
|
||||||
<td class="px-5 py-3 text-sm text-slate-600 dark:text-slate-300">{{ job.current_stage or '-' }}</td>
|
{% for job in jobs %}
|
||||||
<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>
|
<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">
|
||||||
</tr>
|
<div class="min-w-0">
|
||||||
{% else %}
|
<div class="truncate text-sm font-bold text-slate-700 dark:text-slate-200">#{{ job.id }} {{ job.match_id or job.job_type }}</div>
|
||||||
<tr><td colspan="6" class="px-5 py-8 text-center text-sm text-slate-500">暂无作业</td></tr>
|
<div class="text-xs text-slate-400">{{ job.current_stage or job.status }}</div>
|
||||||
{% endfor %}
|
</div>
|
||||||
</tbody>
|
<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>
|
||||||
</table>
|
</a>
|
||||||
</div>
|
{% else %}
|
||||||
</div>
|
<div class="px-5 py-8 text-center text-sm text-slate-400">暂无作业</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
{% if selected_job %}
|
{{ super() }}
|
||||||
<script>
|
<script>
|
||||||
const jobId = {{ selected_job.id }};
|
const input = document.getElementById('capture-input');
|
||||||
let pollTimer = null;
|
const fileName = document.getElementById('file-name');
|
||||||
|
input.addEventListener('change', () => {
|
||||||
async function refreshJob() {
|
fileName.textContent = input.files.length ? input.files[0].name : '点击选择 JSON 文件';
|
||||||
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();
|
|
||||||
</script>
|
</script>
|
||||||
{% endif %}
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -1,38 +1,50 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block content %}
|
{% block title %}Admin Login - YRTV{% endblock %}
|
||||||
<div class="min-h-full flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
|
|
||||||
<div class="max-w-md w-full space-y-8">
|
|
||||||
<div>
|
|
||||||
<h2 class="mt-6 text-center text-3xl font-extrabold text-gray-900 dark:text-white">
|
|
||||||
Admin Login
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
|
||||||
{% if messages %}
|
|
||||||
{% for category, message in messages %}
|
|
||||||
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative" role="alert">
|
|
||||||
<span class="block sm:inline">{{ message }}</span>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
{% endif %}
|
|
||||||
{% endwith %}
|
|
||||||
|
|
||||||
<form class="mt-8 space-y-6" action="{{ url_for('admin.login') }}" method="POST">
|
|
||||||
<div class="rounded-md shadow-sm -space-y-px">
|
|
||||||
<div>
|
|
||||||
<label for="token" class="sr-only">Admin Token</label>
|
|
||||||
<input id="token" name="token" type="password" required class="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md rounded-b-md focus:outline-none focus:ring-yrtv-500 focus:border-yrtv-500 focus:z-10 sm:text-sm" placeholder="Enter Admin Token">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
{% block content %}
|
||||||
<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">
|
<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">
|
||||||
Sign in
|
<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">
|
||||||
</button>
|
<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>
|
</div>
|
||||||
</form>
|
{% endif %}
|
||||||
</div>
|
{% 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>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
+113
-45
@@ -1,52 +1,120 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "admin/base.html" %}
|
||||||
|
|
||||||
{% block content %}
|
{% block admin_title %}查询控制台{% endblock %}
|
||||||
<div class="bg-white dark:bg-slate-800 shadow rounded-lg p-6">
|
{% block page_title %}只读查询控制台{% endblock %}
|
||||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-6">SQL Runner</h2>
|
{% block page_subtitle %}探索 L2 事实、L3 集市和 Web 状态,不允许任何写操作{% endblock %}
|
||||||
|
|
||||||
<form action="{{ url_for('admin.sql_runner') }}" method="POST" class="mb-6">
|
{% block admin_content %}
|
||||||
<div class="mb-4">
|
<div class="grid grid-cols-1 gap-6 xl:grid-cols-[260px_minmax(0,1fr)]">
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Database</label>
|
<aside class="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900">
|
||||||
<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">
|
<div class="border-b border-slate-100 px-4 py-4 dark:border-slate-800">
|
||||||
<option value="l2" {% if db_name == 'l2' %}selected{% endif %}>L2 (Facts)</option>
|
<div class="text-xs font-black uppercase tracking-wider text-slate-400">Database Objects</div>
|
||||||
<option value="l3" {% if db_name == 'l3' %}selected{% endif %}>L3 (Features)</option>
|
<div class="mt-1 font-black text-slate-800 dark:text-white">{{ db_name|upper }}</div>
|
||||||
<option value="web" {% if db_name == 'web' %}selected{% endif %}>Web (App Data)</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-4">
|
<div class="max-h-[680px] overflow-auto p-2">
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Query</label>
|
{% for item in catalog %}
|
||||||
<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>
|
<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>
|
</div>
|
||||||
<button type="submit" class="bg-yrtv-600 text-white py-2 px-4 rounded hover:bg-yrtv-700">Run Query</button>
|
</aside>
|
||||||
</form>
|
|
||||||
|
<div class="min-w-0 space-y-6">
|
||||||
{% if error %}
|
<section class="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900">
|
||||||
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-6">
|
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
{{ error }}
|
<div>
|
||||||
</div>
|
<h2 class="font-black text-slate-900 dark:text-white">SQL Editor</h2>
|
||||||
{% endif %}
|
<p class="mt-1 text-xs text-slate-400">仅支持单条 SELECT;未指定 LIMIT 时自动限制 50 行。</p>
|
||||||
|
</div>
|
||||||
{% if result %}
|
<form method="GET" action="{{ url_for('admin.sql_runner') }}">
|
||||||
<div class="overflow-x-auto">
|
<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">
|
||||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700 border">
|
<option value="l2" {% if db_name == 'l2' %}selected{% endif %}>L2 Facts</option>
|
||||||
<thead class="bg-gray-50 dark:bg-slate-700">
|
<option value="l3" {% if db_name == 'l3' %}selected{% endif %}>L3 Marts</option>
|
||||||
<tr>
|
<option value="web" {% if db_name == 'web' %}selected{% endif %}>Web App</option>
|
||||||
{% for col in result.columns %}
|
</select>
|
||||||
<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>
|
</form>
|
||||||
{% endfor %}
|
</div>
|
||||||
</tr>
|
|
||||||
</thead>
|
<div class="mt-4 flex flex-wrap gap-2">
|
||||||
<tbody class="bg-white dark:bg-slate-800 divide-y divide-gray-200 dark:divide-gray-700">
|
{% if db_name == 'l2' %}
|
||||||
{% for row in result.rows %}
|
{% set samples = [
|
||||||
<tr>
|
('最近比赛', 'SELECT match_id, map_name, start_time, score_team1, score_team2 FROM fact_matches ORDER BY start_time DESC LIMIT 10'),
|
||||||
{% for col in result.columns %}
|
('队员比赛', 'SELECT steam_id_64, match_id, rating, kills, deaths, adr FROM fact_match_players ORDER BY match_id DESC LIMIT 20')
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400 border-b">{{ row[col] }}</td>
|
] %}
|
||||||
{% endfor %}
|
{% elif db_name == 'l3' %}
|
||||||
</tr>
|
{% 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 %}
|
{% endfor %}
|
||||||
</tbody>
|
</div>
|
||||||
</table>
|
|
||||||
|
<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>
|
</div>
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% 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 %}
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -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('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('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('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('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('awards.index') }}" class="{% if request.endpoint and 'awards' in request.endpoint %}border-amber-500 text-gray-900 dark:text-white{% else %}border-transparent text-gray-500 dark:text-gray-300 hover:border-gray-300 hover:text-gray-700 dark:hover:text-white{% endif %} inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">荣誉</a>
|
||||||
<a href="{{ url_for('teams.index') }}" class="{% if request.endpoint and 'teams' in request.endpoint %}border-yrtv-500 text-gray-900 dark:text-white{% else %}border-transparent text-gray-500 dark:text-gray-300 hover:border-gray-300 hover:text-gray-700 dark:hover:text-white{% endif %} inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">战队</a>
|
<a href="{{ url_for('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('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('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('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('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('awards.index') }}" class="border-transparent text-gray-500 hover:bg-gray-50 hover:border-gray-300 hover:text-gray-700 block pl-3 pr-4 py-2 border-l-4 text-base font-medium dark:text-white dark:hover:bg-slate-700">荣誉</a>
|
||||||
<a href="{{ url_for('teams.index') }}" class="border-transparent text-gray-500 hover:bg-gray-50 hover:border-gray-300 hover:text-gray-700 block pl-3 pr-4 py-2 border-l-4 text-base font-medium dark:text-white dark:hover:bg-slate-700">战队</a>
|
<a href="{{ url_for('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>
|
||||||
|
|||||||
@@ -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,6 +1,6 @@
|
|||||||
<div class="grid grid-cols-1 gap-8 xl:grid-cols-2">
|
<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"
|
<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 class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-lg font-bold text-gray-900 dark:text-white">阶段表现</h3>
|
<h3 class="text-lg font-bold text-gray-900 dark:text-white">阶段表现</h3>
|
||||||
|
|||||||
@@ -42,4 +42,22 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% 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>
|
</section>
|
||||||
|
|||||||
Reference in New Issue
Block a user