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