2.0.0 Beta : Self-hosted Release

This commit is contained in:
2026-08-09 02:03:29 +08:00
parent 63a0751aba
commit 37cb05eb70
33 changed files with 810 additions and 31 deletions
+13
View File
@@ -0,0 +1,13 @@
.git
.gitignore
.venv
__pycache__
*.pyc
.pytest_cache
database/backups
database/.pipeline.lock
runtime-data
output_arena
*.db.bak
.env
+19
View File
@@ -0,0 +1,19 @@
# Generate with: python -c "import secrets; print(secrets.token_urlsafe(48))"
SECRET_KEY=replace-with-a-long-random-secret
ADMIN_TOKEN=replace-with-a-strong-admin-token
# public: everyone can read; private: VIEWER_TOKEN required
SITE_VISIBILITY=private
VIEWER_TOKEN=replace-with-a-viewer-password
YRTV_PORT=5001
YRTV_DATA_PATH=./runtime-data
BRAND_PRIMARY=Superjacky6
BRAND_ALIASES=jacky,jk,yr,jacky0987
WEB_CONCURRENCY=1
WEB_THREADS=2
WEB_TIMEOUT=120
SLOW_QUERY_THRESHOLD_SECONDS=0.25
+1
View File
@@ -69,6 +69,7 @@ output/
output_arena/ output_arena/
database/backups/ database/backups/
database/.pipeline.lock database/.pipeline.lock
runtime-data/
arena/ arena/
scripts/ scripts/
experiment experiment
+27
View File
@@ -0,0 +1,27 @@
FROM python:3.11-slim
LABEL org.opencontainers.image.title="YRTV"
LABEL org.opencontainers.image.description="Private CS2 team analytics by Superjacky6"
LABEL org.opencontainers.image.authors="Superjacky6, jacky, jk, yr, jacky0987"
LABEL org.opencontainers.image.version="2.0.0-beta"
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
ENV YRTV_DATA_DIR=/data
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN chmod +x /app/docker/entrypoint.sh \
&& mkdir -p /data
VOLUME ["/data"]
EXPOSE 5000
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:5000/healthz', timeout=3)"
ENTRYPOINT ["/app/docker/entrypoint.sh"]
+17
View File
@@ -0,0 +1,17 @@
SPDX-License-Identifier: AGPL-3.0-or-later
YRTV is free software licensed under the GNU Affero General Public License,
version 3 or (at your option) any later version.
You may copy, modify and redistribute this software under the terms of that
license. If you modify YRTV and make it available over a network, you must make
the complete corresponding source code of your modified version available to
its users under the same license.
The full license text is available at:
https://www.gnu.org/licenses/agpl-3.0.html
Copyright (C) 2026 SuperJacky6
Project attribution is described in NOTICE.md.
+16 -1
View File
@@ -1,11 +1,14 @@
PYTHON := .venv/bin/python PYTHON := .venv/bin/python
.PHONY: install run test check l1 l2 l3 l3-all pipeline .PHONY: install bootstrap run test check l1 l2 l3 l3-all pipeline prepare-data docker-up docker-down docker-logs
install: install:
python3 -m venv .venv python3 -m venv .venv
$(PYTHON) -m pip install -r requirements.txt $(PYTHON) -m pip install -r requirements.txt
bootstrap:
$(PYTHON) -m database.bootstrap
run: run:
$(PYTHON) -m web.app $(PYTHON) -m web.app
@@ -30,3 +33,15 @@ l3-all:
pipeline: pipeline:
$(PYTHON) -c "from web.app import create_app; create_app(); from database.job_store import JobStore; from database.pipeline import run_pipeline; job_id = JobStore().create_job('manual_pipeline', created_by='cli'); print('job_id=', job_id); raise SystemExit(0 if run_pipeline(job_id) else 1)" $(PYTHON) -c "from web.app import create_app; create_app(); from database.job_store import JobStore; from database.pipeline import run_pipeline; job_id = JobStore().create_job('manual_pipeline', created_by='cli'); print('job_id=', job_id); raise SystemExit(0 if run_pipeline(job_id) else 1)"
prepare-data:
$(PYTHON) -m database.migrate_data --target runtime-data --include-imports
docker-up:
docker compose up -d --build
docker-down:
docker compose down
docker-logs:
docker compose logs -f yrtv
+19
View File
@@ -0,0 +1,19 @@
# YRTV Attribution Notice
YRTV was created and maintained by **Superjacky6**.
Recognized project signatures:
- Superjacky6
- jacky
- jk
- yr
- jacky0987
Public deployments and redistributed builds must retain the visible YRTV and
Superjacky6 attribution in the application footer, metadata and documentation.
The application does not claim ownership of imported match data, platform
content, player identities or third-party assets. Operators are responsible for
obtaining appropriate permission before publishing team or player data.
+68 -4
View File
@@ -1,4 +1,6 @@
# YRTV 2.0.0 Beta # YRTV 2.0.0 Beta · Superjacky6 Self-hosted Edition
> Created by **Superjacky6** · jacky / jk / yr / jacky0987
YRTV 是面向固定 CS2 战队的私人数据站。它不是公共玩家排行榜,而是让队员拥有类似职业选手的个人主页,并为战队提供比赛档案、队内比较、阵容分析、对手情报和战术工具。 YRTV 是面向固定 CS2 战队的私人数据站。它不是公共玩家排行榜,而是让队员拥有类似职业选手的个人主页,并为战队提供比赛档案、队内比较、阵容分析、对手情报和战术工具。
@@ -27,7 +29,7 @@ YRTV 是面向固定 CS2 战队的私人数据站。它不是公共玩家排行
- 111 个日/周/月/季/年度奖项 - 111 个日/周/月/季/年度奖项
- 19 条正面/负面/趣味数据发现 - 19 条正面/负面/趣味数据发现
- 36 枚地图与 ELO 分段勋章 - 36 枚地图与 ELO 分段勋章
- 36 项自动化测试通过 - 40 项自动化测试通过
- 43 项数据完整性检查通过 - 43 项数据完整性检查通过
数据规模会随导入变化,Admin 数据完整性中心显示的结果是运行时事实。 数据规模会随导入变化,Admin 数据完整性中心显示的结果是运行时事实。
@@ -102,7 +104,40 @@ YRTV 是面向固定 CS2 战队的私人数据站。它不是公共玩家排行
- L1/L2/L3/Web 文件状态和回滚快照可视化 - L1/L2/L3/Web 文件状态和回滚快照可视化
- 后台写操作使用 CSRF 防护 - 后台写操作使用 CSRF 防护
## 快速开始 ## Docker 自托管
推荐给其他战队使用 Docker Compose
```bash
cp .env.example .env
# 修改 SECRET_KEY、ADMIN_TOKEN、VIEWER_TOKEN
docker compose up -d --build
```
默认访问 `http://127.0.0.1:5001`。首次空安装:
1. 使用 `.env` 中的 `ADMIN_TOKEN` 登录 `/admin/`
2. 首次启动向导创建战队和 roster。
3.`/admin/import-match` 上传自己的 `iframe_network.json`
运行数据保存在 `YRTV_DATA_PATH`,默认是 Git 忽略的
`./runtime-data``git pull` 或重建镜像不会覆盖该目录。
### 迁移当前数据
当前仓库内已有数据迁移到外置目录:
```bash
make prepare-data
cp .env.example .env
docker compose up -d --build
```
迁移使用 SQLite Backup API,不修改源数据库,并生成
`runtime-data/migration-manifest.json`、SHA256 和 `quick_check` 结果。
## Python 开发模式
环境要求: 环境要求:
@@ -140,6 +175,10 @@ make run
make run # 启动 Flask make run # 启动 Flask
make check # 编译检查 + 自动化测试 make check # 编译检查 + 自动化测试
make pipeline # 备份后执行完整 L1 -> L2 -> L3 make pipeline # 备份后执行完整 L1 -> L2 -> L3
make bootstrap # 初始化空运行时数据库
make prepare-data # 复制现有数据到 runtime-data
make docker-up # 构建并启动容器
make docker-logs # 查看容器日志
make l1 # 仅构建 L1 make l1 # 仅构建 L1
make l2 # 仅构建 L2 make l2 # 仅构建 L2
@@ -242,6 +281,8 @@ Flask services and player profiles
## 数据库治理 ## 数据库治理
- 所有运行路径集中定义在 `database/paths.py` - 所有运行路径集中定义在 `database/paths.py`
- `YRTV_DATA_DIR` 可将数据库、备份、锁和导入文件移出代码仓库
- 未设置 `YRTV_DATA_DIR` 时继续兼容原 `database/` 数据路径
- 完整编排入口为 `database/pipeline.py` - 完整编排入口为 `database/pipeline.py`
- 同一时间只允许一个 pipeline - 同一时间只允许一个 pipeline
- Pipeline 运行前备份 L1/L2/L3 - Pipeline 运行前备份 L1/L2/L3
@@ -252,6 +293,24 @@ Flask services and player profiles
- 高频玩家历史、Party、事件和经济查询具有专用索引 - 高频玩家历史、Party、事件和经济查询具有专用索引
- 数据库和目录规则详见 `database/README.md` - 数据库和目录规则详见 `database/README.md`
## 访问模式
- `SITE_VISIBILITY=public`:前台公开,Admin 仍需令牌。
- `SITE_VISIBILITY=private`:前台需要 `VIEWER_TOKEN`
- `/healthz` 始终开放给 Docker 和反向代理健康检查。
- 后台写操作具有 CSRF 防护。
Docker 示例默认使用 `private`
## 品牌与许可证
- 主品牌:`Superjacky6`
- 项目签名:`jacky / jk / yr / jacky0987`
- 页面、报告、后台、元数据、CLI 和容器镜像保留品牌署名
- 比赛事实和统计指标中不写入水印
- 许可证:AGPL-3.0-or-later
- 署名规则:`NOTICE.md`
## 数据质量 ## 数据质量
Admin 数据完整性中心检查: Admin 数据完整性中心检查:
@@ -289,6 +348,8 @@ yrtv/
│ ├── maintenance.py # 备份、恢复、健康检查 │ ├── maintenance.py # 备份、恢复、健康检查
│ ├── job_store.py # ETL 作业状态 │ ├── job_store.py # ETL 作业状态
│ └── pipeline.py # 完整流水线 │ └── pipeline.py # 完整流水线
├── docker/
│ └── entrypoint.sh
├── tests/ # 自动化测试 ├── tests/ # 自动化测试
├── utils/ # JSON 结构分析工具 ├── utils/ # JSON 结构分析工具
├── web/ ├── web/
@@ -297,6 +358,9 @@ yrtv/
│ ├── templates/ │ ├── templates/
│ └── static/ │ └── static/
├── Makefile ├── Makefile
├── Dockerfile
├── docker-compose.yml
├── .env.example
├── requirements.txt ├── requirements.txt
└── wsgi.py └── wsgi.py
``` ```
@@ -305,7 +369,7 @@ yrtv/
- 当前主要数据源为 5E iframe 网络响应。 - 当前主要数据源为 5E iframe 网络响应。
- 不包含自动网页下载器和 Demo parser。 - 不包含自动网页下载器和 Demo parser。
- 认证仍是单一 Admin Token,适合私人部署,不适合开放注册 - 支持独立 Viewer Token 和 Admin Token,但不提供开放注册或多用户权限系统
- SQLite 适合当前单战队规模,不面向高并发多租户。 - SQLite 适合当前单战队规模,不面向高并发多租户。
- 部分高级空间能力需要地图边界、路径和 Demo 数据,当前显示 `N/A` - 部分高级空间能力需要地图边界、路径和 Demo 数据,当前显示 `N/A`
- `StatsService` 仍保留部分兼容逻辑;新功能已拆入 Player Profile、Team Performance、Roster Version 等领域服务。 - `StatsService` 仍保留部分兼容逻辑;新功能已拆入 Player Profile、Team Performance、Roster Version 等领域服务。
+9 -1
View File
@@ -22,6 +22,9 @@ This directory separates four different responsibilities:
8. Missing metrics are stored as `NULL`, not fabricated zero values. 8. Missing metrics are stored as `NULL`, not fabricated zero values.
9. `Admin -> Data Integrity` is the operational source of truth. 9. `Admin -> Data Integrity` is the operational source of truth.
10. Web schema changes increment `Config.WEB_SCHEMA_VERSION`. 10. Web schema changes increment `Config.WEB_SCHEMA_VERSION`.
11. `YRTV_DATA_DIR` owns runtime databases; code schemas always stay in Git.
12. Container and production deployments must keep runtime data outside the
image and source checkout.
## Entry Points ## Entry Points
@@ -31,11 +34,16 @@ make l2 # Rebuild normalized facts
make l3 # Rebuild active-roster features make l3 # Rebuild active-roster features
make pipeline # Run L1 -> L2 -> L3 with backup and validation make pipeline # Run L1 -> L2 -> L3 with backup and validation
make check # Compile and run tests make check # Compile and run tests
make bootstrap # Initialize an empty runtime data root
make prepare-data # Copy legacy data into runtime-data
``` ```
## Directory Policy ## Directory Policy
- `L1/`, `L2/`, `L3/`, `Web/`: active code, schema and database. - Without `YRTV_DATA_DIR`, `L1/`, `L2/`, `L3/`, `Web/` remain the compatible
development runtime.
- With `YRTV_DATA_DIR`, those folders provide code and schemas while databases
live under the configured external directory.
- `backups/`: generated rollback snapshots; ignored by Git. - `backups/`: generated rollback snapshots; ignored by Git.
- `schema_bkp/`: historical schema research only; not used at runtime. - `schema_bkp/`: historical schema research only; not used at runtime.
- `L1B/`: reserved demo-parser integration; not used at runtime. - `L1B/`: reserved demo-parser integration; not used at runtime.
+66
View File
@@ -0,0 +1,66 @@
import sqlite3
from database.paths import (
L1_DB,
L2_DB,
L2_SCHEMA,
L3_DB,
L3_SCHEMA,
RUNTIME_DATA_ROOT,
ensure_runtime_directories,
)
def _apply_schema(database_path, schema_path):
db = sqlite3.connect(str(database_path))
try:
db.execute('PRAGMA foreign_keys = ON')
db.executescript(schema_path.read_text(encoding='utf-8'))
result = db.execute('PRAGMA quick_check').fetchone()[0]
if result != 'ok':
raise RuntimeError(
f'{database_path.name} quick_check failed: {result}'
)
db.commit()
finally:
db.close()
def bootstrap_runtime():
ensure_runtime_directories()
l1 = sqlite3.connect(str(L1_DB))
try:
l1.execute(
"""
CREATE TABLE IF NOT EXISTS raw_iframe_network (
match_id TEXT PRIMARY KEY,
content TEXT NOT NULL,
processed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
)
l1.commit()
finally:
l1.close()
_apply_schema(L2_DB, L2_SCHEMA)
_apply_schema(L3_DB, L3_SCHEMA)
from web.database import initialize_web_db
initialize_web_db()
return {
'data_root': str(RUNTIME_DATA_ROOT),
'l1': str(L1_DB),
'l2': str(L2_DB),
'l3': str(L3_DB),
}
if __name__ == '__main__':
result = bootstrap_runtime()
print('YRTV runtime initialized')
for key, value in result.items():
print(f' {key}: {value}')
+107
View File
@@ -0,0 +1,107 @@
import argparse
from datetime import datetime, timezone
import hashlib
import json
from pathlib import Path
import shutil
import sqlite3
from database.paths import DATABASE_CODE_ROOT, PROJECT_ROOT
DATABASE_LAYOUT = {
'L1': ('L1', 'L1.db'),
'L2': ('L2', 'L2.db'),
'L3': ('L3', 'L3.db'),
'Web': ('Web', 'Web_App.sqlite'),
}
def _sha256(path):
digest = hashlib.sha256()
with path.open('rb') as file:
for chunk in iter(lambda: file.read(1024 * 1024), b''):
digest.update(chunk)
return digest.hexdigest()
def _sqlite_copy(source, target):
target.parent.mkdir(parents=True, exist_ok=True)
source_db = sqlite3.connect(str(source))
target_db = sqlite3.connect(str(target))
try:
source_db.backup(target_db)
result = target_db.execute('PRAGMA quick_check').fetchone()[0]
if result != 'ok':
raise RuntimeError(f'{target.name} quick_check failed: {result}')
finally:
source_db.close()
target_db.close()
def migrate_data(source_root, target_root, include_imports=False):
source_root = Path(source_root).expanduser().resolve()
target_root = Path(target_root).expanduser().resolve()
if source_root == target_root:
raise ValueError('Source and target data roots must differ')
target_root.mkdir(parents=True, exist_ok=True)
manifest = {
'created_at': datetime.now(timezone.utc).isoformat(),
'source_root': str(source_root),
'target_root': str(target_root),
'databases': {},
}
for name, (directory, filename) in DATABASE_LAYOUT.items():
source = source_root / directory / filename
target = target_root / directory / filename
if not source.exists():
raise FileNotFoundError(f'Missing source database: {source}')
_sqlite_copy(source, target)
manifest['databases'][name] = {
'path': str(target),
'size_bytes': target.stat().st_size,
'sha256': _sha256(target),
'quick_check': 'ok',
}
if include_imports:
legacy_imports = PROJECT_ROOT / 'output_arena'
if legacy_imports.exists():
destination = target_root / 'imports'
shutil.copytree(
legacy_imports,
destination,
dirs_exist_ok=True,
)
manifest['imports'] = str(destination)
manifest_path = target_root / 'migration-manifest.json'
manifest_path.write_text(
json.dumps(manifest, ensure_ascii=True, indent=2),
encoding='utf-8',
)
return manifest_path
def _parse_args():
parser = argparse.ArgumentParser()
parser.add_argument(
'--source',
default=str(DATABASE_CODE_ROOT),
help='Existing runtime data root',
)
parser.add_argument('--target', required=True)
parser.add_argument('--include-imports', action='store_true')
return parser.parse_args()
if __name__ == '__main__':
args = _parse_args()
path = migrate_data(
args.source,
args.target,
include_imports=args.include_imports,
)
print(f'Migration completed: {path}')
+28 -12
View File
@@ -1,26 +1,43 @@
import os
from pathlib import Path from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parent.parent PROJECT_ROOT = Path(__file__).resolve().parent.parent
DATABASE_ROOT = PROJECT_ROOT / 'database' DATABASE_CODE_ROOT = PROJECT_ROOT / 'database'
L1_DIR = DATABASE_ROOT / 'L1' _runtime_override = os.environ.get('YRTV_DATA_DIR')
L2_DIR = DATABASE_ROOT / 'L2' RUNTIME_DATA_ROOT = (
L3_DIR = DATABASE_ROOT / 'L3' Path(_runtime_override).expanduser().resolve()
WEB_DIR = DATABASE_ROOT / 'Web' if _runtime_override
else DATABASE_CODE_ROOT
)
L1_DIR = RUNTIME_DATA_ROOT / 'L1'
L2_DIR = RUNTIME_DATA_ROOT / 'L2'
L3_DIR = RUNTIME_DATA_ROOT / 'L3'
WEB_DIR = RUNTIME_DATA_ROOT / 'Web'
L1_DB = L1_DIR / 'L1.db' L1_DB = L1_DIR / 'L1.db'
L2_DB = L2_DIR / 'L2.db' L2_DB = L2_DIR / 'L2.db'
L3_DB = L3_DIR / 'L3.db' L3_DB = L3_DIR / 'L3.db'
WEB_DB = WEB_DIR / 'Web_App.sqlite' WEB_DB = WEB_DIR / 'Web_App.sqlite'
L2_SCHEMA = L2_DIR / 'schema.sql' L2_SCHEMA = DATABASE_CODE_ROOT / 'L2' / 'schema.sql'
L3_SCHEMA = L3_DIR / 'schema.sql' L3_SCHEMA = DATABASE_CODE_ROOT / 'L3' / 'schema.sql'
WEB_SCHEMA = WEB_DIR / 'schema.sql' WEB_SCHEMA = DATABASE_CODE_ROOT / 'Web' / 'schema.sql'
OUTPUT_ARENA = PROJECT_ROOT / 'output_arena' _import_override = os.environ.get('YRTV_IMPORT_DIR')
BACKUP_ROOT = DATABASE_ROOT / 'backups' OUTPUT_ARENA = (
PIPELINE_LOCK = DATABASE_ROOT / '.pipeline.lock' Path(_import_override).expanduser().resolve()
if _import_override
else (
RUNTIME_DATA_ROOT / 'imports'
if _runtime_override
else PROJECT_ROOT / 'output_arena'
)
)
BACKUP_ROOT = RUNTIME_DATA_ROOT / 'backups'
PIPELINE_LOCK = RUNTIME_DATA_ROOT / '.pipeline.lock'
def ensure_runtime_directories(): def ensure_runtime_directories():
@@ -33,4 +50,3 @@ def ensure_runtime_directories():
BACKUP_ROOT, BACKUP_ROOT,
): ):
path.mkdir(parents=True, exist_ok=True) path.mkdir(parents=True, exist_ok=True)
+31
View File
@@ -0,0 +1,31 @@
services:
yrtv:
build:
context: .
dockerfile: Dockerfile
image: superjacky6/yrtv:2.0.0-beta
container_name: yrtv
restart: unless-stopped
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
env_file:
- .env
environment:
YRTV_DATA_DIR: /data
PORT: 5000
ports:
- "${YRTV_PORT:-5001}:5000"
volumes:
- "${YRTV_DATA_PATH:-./runtime-data}:/data"
healthcheck:
test:
- CMD
- python
- -c
- "import urllib.request; urllib.request.urlopen('http://127.0.0.1:5000/healthz', timeout=3)"
interval: 30s
timeout: 5s
start_period: 20s
retries: 3
+17
View File
@@ -0,0 +1,17 @@
#!/bin/sh
set -eu
echo "YRTV 2.0.0 Beta · Superjacky6 Data Lab"
echo "Aliases: jacky / jk / yr / jacky0987"
echo "Runtime data: ${YRTV_DATA_DIR:-/data}"
python -m database.bootstrap
exec gunicorn wsgi:app \
--bind "0.0.0.0:${PORT:-5000}" \
--workers "${WEB_CONCURRENCY:-1}" \
--threads "${WEB_THREADS:-2}" \
--timeout "${WEB_TIMEOUT:-120}" \
--access-logfile - \
--error-logfile -
+104
View File
@@ -3,6 +3,8 @@ import io
import os import os
import shutil import shutil
import sqlite3 import sqlite3
import subprocess
import sys
import tempfile import tempfile
import unittest import unittest
from pathlib import Path from pathlib import Path
@@ -60,6 +62,42 @@ class ApplicationIntegrationTests(unittest.TestCase):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertGreater(len(response.data), 100) self.assertGreater(len(response.data), 100)
def test_health_endpoint_and_brand_watermark(self):
response = self.client.get('/healthz')
self.assertEqual(response.status_code, 200)
self.assertEqual(response.get_json()['brand'], 'Superjacky6')
response = self.client.get('/')
self.assertIn(b'Superjacky6', response.data)
self.assertIn(b'jacky', response.data)
def test_private_visibility_requires_viewer_token(self):
from web.app import create_app
class PrivateConfig(Config):
TESTING = True
SECRET_KEY = 'private-test'
SITE_VISIBILITY = 'private'
VIEWER_TOKEN = 'viewer-secret'
app = create_app(PrivateConfig)
client = app.test_client()
response = client.get('/')
self.assertEqual(response.status_code, 302)
self.assertIn('/access/', response.location)
self.assertEqual(client.get('/healthz').status_code, 200)
with client.session_transaction() as session:
session['_csrf_token'] = 'viewer-csrf'
response = client.post(
'/access/',
data={
'_csrf_token': 'viewer-csrf',
'token': 'viewer-secret',
},
)
self.assertEqual(response.status_code, 302)
self.assertEqual(client.get('/').status_code, 200)
def test_discovery_filters_and_player_medals_render(self): def test_discovery_filters_and_player_medals_render(self):
response = self.client.get('/discover/') response = self.client.get('/discover/')
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
@@ -567,6 +605,72 @@ class DatabaseGovernanceTests(unittest.TestCase):
self.assertTrue(path.is_absolute()) self.assertTrue(path.is_absolute())
self.assertTrue(path.exists()) self.assertTrue(path.exists())
def test_external_data_root_bootstraps_without_touching_current_data(self):
current_hashes = {}
for path in (
Path(Config.DB_L2_PATH),
Path(Config.DB_L3_PATH),
Path(Config.DB_WEB_PATH),
):
current_hashes[str(path)] = path.stat().st_size
with tempfile.TemporaryDirectory() as temp_dir:
env = os.environ.copy()
env['YRTV_DATA_DIR'] = temp_dir
result = subprocess.run(
[sys.executable, '-m', 'database.bootstrap'],
cwd=Config.BASE_DIR,
env=env,
capture_output=True,
text=True,
timeout=60,
)
self.assertEqual(result.returncode, 0, result.stderr)
runtime = Path(temp_dir)
expected_tables = {
runtime / 'L1' / 'L1.db': 'raw_iframe_network',
runtime / 'L2' / 'L2.db': 'fact_matches',
runtime / 'L3' / 'L3.db': 'dm_player_features',
runtime / 'Web' / 'Web_App.sqlite': 'team_lineups',
}
for path, table in expected_tables.items():
with self.subTest(path=path):
self.assertTrue(path.exists())
with sqlite3.connect(str(path)) as db:
self.assertEqual(
db.execute('PRAGMA quick_check').fetchone()[0],
'ok',
)
self.assertIsNotNone(db.execute(
"""
SELECT 1 FROM sqlite_master
WHERE type = 'table' AND name = ?
""",
[table],
).fetchone())
for path, size in current_hashes.items():
self.assertEqual(Path(path).stat().st_size, size)
def test_existing_data_migrates_to_external_root(self):
from database.migrate_data import migrate_data
from database.paths import DATABASE_CODE_ROOT
with tempfile.TemporaryDirectory() as temp_dir:
manifest_path = migrate_data(
DATABASE_CODE_ROOT,
temp_dir,
include_imports=False,
)
manifest = json.loads(manifest_path.read_text(encoding='utf-8'))
self.assertEqual(
set(manifest['databases']),
{'L1', 'L2', 'L3', 'Web'},
)
for item in manifest['databases'].values():
self.assertEqual(item['quick_check'], 'ok')
self.assertEqual(len(item['sha256']), 64)
def test_valid_capture_is_identified_from_network_urls(self): def test_valid_capture_is_identified_from_network_urls(self):
from database.paths import L1_DB from database.paths import L1_DB
from web.services.import_service import MatchImportService from web.services.import_service import MatchImportService
+32 -1
View File
@@ -3,7 +3,7 @@ import os
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
from flask import Flask from flask import Flask, redirect, request, session, url_for
from web.config import Config from web.config import Config
from web.database import close_dbs, initialize_web_db from web.database import close_dbs, initialize_web_db
from web.auth import get_csrf_token from web.auth import get_csrf_token
@@ -19,8 +19,38 @@ def create_app(config_object=Config):
app.teardown_appcontext(close_dbs) app.teardown_appcontext(close_dbs)
app.jinja_env.globals['csrf_token'] = get_csrf_token app.jinja_env.globals['csrf_token'] = get_csrf_token
@app.context_processor
def inject_brand():
return {
'brand': {
'primary': app.config['BRAND_PRIMARY'],
'aliases': app.config['BRAND_ALIASES'],
'signature': app.config['BRAND_SIGNATURE'],
}
}
@app.before_request
def require_private_access():
if app.config['SITE_VISIBILITY'] != 'private':
return None
endpoint = request.endpoint or ''
if (
endpoint == 'static'
or endpoint == 'main.healthz'
or endpoint.startswith('access.')
or endpoint.startswith('admin.')
or session.get('viewer_access')
or session.get('is_admin')
):
return None
return redirect(url_for(
'access.login',
next=request.full_path if request.method == 'GET' else None,
))
from web.routes import ( from web.routes import (
admin, admin,
access,
awards, awards,
discover, discover,
main, main,
@@ -33,6 +63,7 @@ def create_app(config_object=Config):
wiki, wiki,
) )
app.register_blueprint(main.bp) app.register_blueprint(main.bp)
app.register_blueprint(access.bp)
app.register_blueprint(matches.bp) app.register_blueprint(matches.bp)
app.register_blueprint(players.bp) app.register_blueprint(players.bp)
app.register_blueprint(teams.bp) app.register_blueprint(teams.bp)
+30 -1
View File
@@ -1,13 +1,37 @@
import os import os
from database.paths import L2_DB, L3_DB, WEB_DB, WEB_SCHEMA from database.paths import (
L2_DB,
L3_DB,
OUTPUT_ARENA,
RUNTIME_DATA_ROOT,
WEB_DB,
WEB_SCHEMA,
)
class Config: class Config:
BASE_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), '..')) BASE_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
DATA_ROOT = str(RUNTIME_DATA_ROOT)
IMPORT_DIR = str(OUTPUT_ARENA)
SECRET_KEY = os.environ.get('SECRET_KEY', 'yrtv-dev-only-change-me') SECRET_KEY = os.environ.get('SECRET_KEY', 'yrtv-dev-only-change-me')
ADMIN_TOKEN = os.environ.get('ADMIN_TOKEN', 'yrtv-admin-dev') ADMIN_TOKEN = os.environ.get('ADMIN_TOKEN', 'yrtv-admin-dev')
SITE_VISIBILITY = os.environ.get('SITE_VISIBILITY', 'public').lower()
VIEWER_TOKEN = os.environ.get('VIEWER_TOKEN', '')
BRAND_PRIMARY = os.environ.get('BRAND_PRIMARY', 'Superjacky6')
BRAND_ALIASES = [
value.strip()
for value in os.environ.get(
'BRAND_ALIASES',
'jacky,jk,yr,jacky0987',
).split(',')
if value.strip()
]
BRAND_SIGNATURE = (
f"{BRAND_PRIMARY} / {' / '.join(BRAND_ALIASES)}"
)
DB_L2_PATH = str(L2_DB) DB_L2_PATH = str(L2_DB)
DB_L3_PATH = str(L3_DB) DB_L3_PATH = str(L3_DB)
@@ -20,6 +44,11 @@ class Config:
SLOW_QUERY_THRESHOLD_SECONDS = float( SLOW_QUERY_THRESHOLD_SECONDS = float(
os.environ.get('SLOW_QUERY_THRESHOLD_SECONDS', '0.25') os.environ.get('SLOW_QUERY_THRESHOLD_SECONDS', '0.25')
) )
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SAMESITE = 'Lax'
SESSION_COOKIE_SECURE = (
os.environ.get('SESSION_COOKIE_SECURE', 'false').lower() == 'true'
)
# Pagination # Pagination
ITEMS_PER_PAGE = 20 ITEMS_PER_PAGE = 20
+42
View File
@@ -0,0 +1,42 @@
import hmac
from flask import (
Blueprint,
current_app,
flash,
redirect,
render_template,
request,
session,
url_for,
)
from web.auth import validate_csrf
bp = Blueprint('access', __name__, url_prefix='/access')
@bp.route('/', methods=['GET', 'POST'])
def login():
if current_app.config['SITE_VISIBILITY'] != 'private':
return redirect(url_for('main.index'))
if request.method == 'POST':
validate_csrf()
token = request.form.get('token') or ''
viewer_token = current_app.config['VIEWER_TOKEN']
if viewer_token and hmac.compare_digest(
token,
viewer_token,
):
session['viewer_access'] = True
target = request.args.get('next')
if not target or not target.startswith('/') or target.startswith('//'):
target = url_for('main.index')
return redirect(target)
flash('访问密码错误', 'error')
return render_template('access/login.html')
@bp.route('/logout')
def logout():
session.pop('viewer_access', None)
return redirect(url_for('access.login'))
+39
View File
@@ -13,6 +13,9 @@ from web.auth import admin_required, csrf_protected, validate_csrf
from web.database import query_db from web.database import query_db
from web.services.admin_service import AdminService from web.services.admin_service import AdminService
from web.services.etl_service import EtlService from web.services.etl_service import EtlService
from web.services.team_context_service import TeamContextService
from web.services.web_service import WebService
from web.services.roster_version_service import RosterVersionService
import hmac import hmac
import time import time
@@ -38,11 +41,47 @@ def logout():
@bp.route('/') @bp.route('/')
@admin_required @admin_required
def dashboard(): def dashboard():
if not TeamContextService.get_active_roster_ids():
return redirect(url_for('admin.setup'))
return render_template( return render_template(
'admin/dashboard.html', 'admin/dashboard.html',
**AdminService.get_overview(), **AdminService.get_overview(),
) )
@bp.route('/setup', methods=['GET', 'POST'])
@admin_required
def setup():
if request.method == 'POST':
validate_csrf()
name = (request.form.get('name') or '').strip()
description = (request.form.get('description') or '').strip()
raw_ids = request.form.get('steam_ids') or ''
player_ids = []
for value in raw_ids.replace(',', '\n').splitlines():
steam_id = value.strip()
if steam_id and steam_id not in player_ids:
player_ids.append(steam_id)
if not name:
flash('请输入战队名称', 'error')
elif not player_ids:
flash('至少添加一名队员 Steam ID', 'error')
elif any(not value.isdigit() for value in player_ids):
flash('Steam ID 只能包含数字', 'error')
else:
lineup_id = WebService.save_lineup(
name,
description,
player_ids,
)
RosterVersionService.snapshot_roster(
player_ids,
name=f'{name} Initial Roster',
)
flash('战队初始化完成', 'success')
return redirect(url_for('admin.dashboard'))
return render_template('admin/setup.html')
@bp.route('/data-integrity') @bp.route('/data-integrity')
@admin_required @admin_required
def data_integrity(): def data_integrity():
+20
View File
@@ -1,8 +1,28 @@
from flask import Blueprint, render_template, request, jsonify from flask import Blueprint, render_template, request, jsonify
from web.database import query_db
from web.config import Config
from web.services.stats_service import StatsService from web.services.stats_service import StatsService
bp = Blueprint('main', __name__) bp = Blueprint('main', __name__)
@bp.route('/healthz')
def healthz():
try:
for db_name in ('l2', 'l3', 'web'):
query_db(db_name, 'SELECT 1', one=True)
except Exception as exc:
return jsonify({
'status': 'unhealthy',
'error': str(exc),
'brand': Config.BRAND_PRIMARY,
}), 503
return jsonify({
'status': 'ok',
'version': '2.0.0-beta',
'brand': Config.BRAND_PRIMARY,
})
@bp.route('/') @bp.route('/')
def index(): def index():
recent_matches = StatsService.get_recent_matches(limit=5) recent_matches = StatsService.get_recent_matches(limit=5)
+4 -1
View File
@@ -164,6 +164,10 @@ class AdminService:
'sqlite_timeout': Config.SQLITE_TIMEOUT_SECONDS, 'sqlite_timeout': Config.SQLITE_TIMEOUT_SECONDS,
'slow_query_threshold': Config.SLOW_QUERY_THRESHOLD_SECONDS, 'slow_query_threshold': Config.SLOW_QUERY_THRESHOLD_SECONDS,
'max_upload_mb': Config.MAX_CONTENT_LENGTH / 1024 / 1024, 'max_upload_mb': Config.MAX_CONTENT_LENGTH / 1024 / 1024,
'data_root': Config.DATA_ROOT,
'import_dir': Config.IMPORT_DIR,
'site_visibility': Config.SITE_VISIBILITY,
'brand_primary': Config.BRAND_PRIMARY,
'secret_key_configured': ( 'secret_key_configured': (
Config.SECRET_KEY != 'yrtv-dev-only-change-me' Config.SECRET_KEY != 'yrtv-dev-only-change-me'
), ),
@@ -172,4 +176,3 @@ class AdminService:
), ),
}, },
} }
+31
View File
@@ -0,0 +1,31 @@
{% extends "base.html" %}
{% block title %}Private Access - YRTV{% endblock %}
{% block content %}
<div class="mx-auto flex min-h-[620px] max-w-3xl items-center justify-center px-4">
<div class="w-full overflow-hidden rounded-3xl border border-slate-200 bg-white shadow-2xl dark:border-slate-700 dark:bg-slate-900">
<div class="bg-gradient-to-br from-slate-950 via-yrtv-950 to-slate-900 px-8 py-10 text-white">
<div class="text-xs font-black uppercase tracking-[0.3em] text-yrtv-300">{{ brand.primary }} Private Analytics</div>
<h1 class="mt-4 text-3xl font-black">这是一座私人战队数据站</h1>
<p class="mt-3 text-sm leading-7 text-slate-300">输入战队提供的访问密码后,可以查看比赛、职业主页、发现、荣誉和战队履历。</p>
</div>
<div class="p-8">
{% with messages = get_flashed_messages(with_categories=true) %}
{% for category, message in messages %}
<div class="mb-4 rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{{ message }}</div>
{% endfor %}
{% endwith %}
<form method="POST" class="space-y-4">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<div>
<label class="text-xs font-black uppercase tracking-wider text-slate-500">Viewer Token</label>
<input type="password" name="token" required class="mt-2 block w-full rounded-xl border-slate-200 bg-slate-50 px-4 py-3 focus:border-yrtv-500 focus:ring-yrtv-500 dark:border-slate-700 dark:bg-slate-800" placeholder="输入访问密码">
</div>
<button class="w-full rounded-xl bg-yrtv-600 px-5 py-3 font-black text-white hover:bg-yrtv-500">进入 YRTV</button>
</form>
<div class="mt-6 text-center text-xs text-slate-400">Protected by {{ brand.signature }}</div>
</div>
</div>
</div>
{% endblock %}
+1 -1
View File
@@ -19,6 +19,7 @@
<div> <div>
<div class="text-xs font-black uppercase tracking-[0.25em] text-yrtv-400">YRTV</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 class="mt-1 text-lg font-black text-white">Operations</div>
<div class="text-[9px] font-bold uppercase tracking-wider text-slate-500">{{ brand.primary }} Control Plane</div>
</div> </div>
<span class="rounded-full bg-amber-400/10 px-2 py-1 text-[10px] font-bold uppercase text-amber-300">Beta</span> <span class="rounded-full bg-amber-400/10 px-2 py-1 text-[10px] font-bold uppercase text-amber-300">Beta</span>
</div> </div>
@@ -83,4 +84,3 @@
</div> </div>
</div> </div>
{% endblock %} {% endblock %}
+39
View File
@@ -0,0 +1,39 @@
{% extends "base.html" %}
{% block title %}First Setup - YRTV{% endblock %}
{% block content %}
<div class="mx-auto max-w-3xl px-4 py-8">
<div class="overflow-hidden rounded-3xl border border-slate-200 bg-white shadow-2xl dark:border-slate-700 dark:bg-slate-900">
<div class="bg-gradient-to-r from-yrtv-700 to-slate-900 p-8 text-white">
<div class="text-xs font-black uppercase tracking-[0.3em] text-yrtv-200">{{ brand.primary }} Self-hosted</div>
<h1 class="mt-3 text-3xl font-black">创建你的私人战队 HLTV</h1>
<p class="mt-3 text-sm text-slate-200">这一步只建立战队与 roster,不会生成或修改比赛数据。之后从 Admin 上传自己的 iframe_network.json。</p>
</div>
<div class="p-8">
{% with messages = get_flashed_messages(with_categories=true) %}
{% for category, message in messages %}
<div class="mb-4 rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{{ message }}</div>
{% endfor %}
{% endwith %}
<form method="POST" class="space-y-5">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<div>
<label class="text-xs font-black uppercase tracking-wider text-slate-500">战队名称</label>
<input name="name" required class="mt-2 block w-full rounded-xl border-slate-200 bg-slate-50 px-4 py-3 dark:border-slate-700 dark:bg-slate-800" placeholder="My Team">
</div>
<div>
<label class="text-xs font-black uppercase tracking-wider text-slate-500">说明</label>
<input name="description" class="mt-2 block w-full rounded-xl border-slate-200 bg-slate-50 px-4 py-3 dark:border-slate-700 dark:bg-slate-800" placeholder="Private CS2 roster">
</div>
<div>
<label class="text-xs font-black uppercase tracking-wider text-slate-500">Roster Steam ID</label>
<textarea name="steam_ids" rows="8" required class="mt-2 block w-full rounded-xl border-slate-200 bg-slate-50 px-4 py-3 font-mono text-sm dark:border-slate-700 dark:bg-slate-800" placeholder="每行一个 Steam ID"></textarea>
<p class="mt-2 text-xs text-slate-400">支持换行或逗号分隔。后续可在 Clubhouse 修改。</p>
</div>
<button class="w-full rounded-xl bg-yrtv-600 px-5 py-3 font-black text-white hover:bg-yrtv-500">创建战队并进入后台</button>
</form>
</div>
</div>
</div>
{% endblock %}
+7 -1
View File
@@ -62,11 +62,17 @@
('Web schema', 'v' + config.web_schema_version|string), ('Web schema', 'v' + config.web_schema_version|string),
('SQLite timeout', config.sqlite_timeout|string + 's'), ('SQLite timeout', config.sqlite_timeout|string + 's'),
('Slow query', config.slow_query_threshold|string + 's'), ('Slow query', config.slow_query_threshold|string + 's'),
('Max upload', '%.0f MB'|format(config.max_upload_mb)) ('Max upload', '%.0f MB'|format(config.max_upload_mb)),
('Visibility', config.site_visibility),
('Brand', config.brand_primary)
] %} ] %}
<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> <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 %} {% endfor %}
</dl> </dl>
<div class="mt-4 space-y-2 border-t border-slate-100 pt-4 text-[10px] dark:border-slate-800">
<div><span class="font-bold uppercase text-slate-400">Data root</span><div class="mt-1 break-all font-mono text-slate-500">{{ config.data_root }}</div></div>
<div><span class="font-bold uppercase text-slate-400">Import dir</span><div class="mt-1 break-all font-mono text-slate-500">{{ config.import_dir }}</div></div>
</div>
</section> </section>
<section class="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900"> <section class="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900">
+1 -1
View File
@@ -5,7 +5,7 @@
{% block content %} {% block content %}
<div class="space-y-8 px-4 sm:px-0"> <div class="space-y-8 px-4 sm:px-0">
<div> <div>
<p class="text-xs font-bold uppercase tracking-widest text-amber-600">YRTV Awards</p> <p class="text-xs font-bold uppercase tracking-widest text-amber-600">YRTV Awards · {{ brand.primary }}</p>
<h1 class="mt-1 text-3xl font-black text-gray-900 dark:text-white">周期最佳与荣誉榜</h1> <h1 class="mt-1 text-3xl font-black text-gray-900 dark:text-white">周期最佳与荣誉榜</h1>
<p class="mt-2 text-sm text-gray-500">完全基于已导入比赛。月、季度和年度奖项设有最低场次门槛。</p> <p class="mt-2 text-sm text-gray-500">完全基于已导入比赛。月、季度和年度奖项设有最低场次门槛。</p>
</div> </div>
+16 -2
View File
@@ -3,6 +3,11 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="author" content="{{ brand.signature }}">
<meta name="creator" content="{{ brand.primary }}">
<meta name="generator" content="YRTV 2.0.0 Beta by {{ brand.primary }}">
<meta property="og:site_name" content="YRTV · {{ brand.primary }}">
<meta property="og:title" content="{% block og_title %}YRTV Private CS2 Analytics{% endblock %}">
<title>{% block title %}YRTV - CS2 Data Platform{% endblock %}</title> <title>{% block title %}YRTV - CS2 Data Platform{% endblock %}</title>
<script src="https://cdn.tailwindcss.com"></script> <script src="https://cdn.tailwindcss.com"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/hammer.js/2.0.8/hammer.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/hammer.js/2.0.8/hammer.min.js"></script>
@@ -40,7 +45,10 @@
<div class="flex justify-between h-16"> <div class="flex justify-between h-16">
<div class="flex"> <div class="flex">
<div class="flex-shrink-0 flex items-center"> <div class="flex-shrink-0 flex items-center">
<a href="{{ url_for('main.index') }}" class="text-2xl font-bold text-yrtv-600">YRTV</a> <a href="{{ url_for('main.index') }}" class="group">
<span class="block text-2xl font-bold leading-none text-yrtv-600">YRTV</span>
<span class="mt-0.5 block text-[8px] font-black uppercase tracking-[0.18em] text-slate-400 group-hover:text-yrtv-500">by {{ brand.primary }}</span>
</a>
</div> </div>
<div class="hidden sm:ml-6 sm:flex sm:space-x-8"> <div class="hidden sm:ml-6 sm:flex sm:space-x-8">
<a href="{{ url_for('main.index') }}" class="{% if request.endpoint == 'main.index' %}border-yrtv-500 text-gray-900 dark:text-white{% else %}border-transparent text-gray-500 dark:text-gray-300 hover:border-gray-300 hover:text-gray-700 dark:hover:text-white{% endif %} inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">首页</a> <a href="{{ url_for('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>
@@ -111,10 +119,16 @@
<!-- Footer --> <!-- Footer -->
<footer class="bg-white dark:bg-slate-800 border-t border-slate-200 dark:border-slate-700 mt-auto"> <footer class="bg-white dark:bg-slate-800 border-t border-slate-200 dark:border-slate-700 mt-auto">
<div class="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8"> <div class="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
<p class="text-center text-sm text-gray-500">&copy; 2026 YRTV Data Platform. All rights reserved. 赣ICP备2026001600号</p> <p class="text-center text-sm font-bold text-gray-500">&copy; 2026 YRTV · {{ brand.primary }} Data Lab</p>
<p class="mt-1 text-center text-[10px] uppercase tracking-[0.2em] text-gray-400">{{ brand.aliases|join(' / ') }} · 赣ICP备2026001600号</p>
</div> </div>
</footer> </footer>
<div class="pointer-events-none fixed bottom-3 right-4 z-[90] select-none text-right opacity-20">
<div class="text-xs font-black uppercase tracking-[0.2em] text-yrtv-600">{{ brand.primary }}</div>
<div class="text-[8px] font-bold text-slate-500">{{ brand.aliases|join(' · ') }}</div>
</div>
{% block scripts %}{% endblock %} {% block scripts %}{% endblock %}
<script> <script>
+1 -1
View File
@@ -22,7 +22,7 @@
<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"> <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 class="flex flex-col gap-8 lg:flex-row lg:items-end lg:justify-between">
<div> <div>
<div class="text-xs font-black uppercase tracking-[0.3em] text-cyan-300">Data Discovery</div> <div class="text-xs font-black uppercase tracking-[0.3em] text-cyan-300">{{ brand.primary }} · Data Discovery</div>
<h1 class="mt-3 text-4xl font-black">有趣的数据,不只展示好的一面</h1> <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> <p class="mt-3 max-w-2xl text-sm leading-7 text-slate-300">从 885 条 roster 比赛记录中自动发现高光、低谷、反差和个人习惯。每张卡片都有统计证据。</p>
</div> </div>
+1
View File
@@ -5,6 +5,7 @@
<!-- Hero Section --> <!-- Hero Section -->
<div class="bg-gradient-to-r from-yrtv-900 to-yrtv-600 rounded-2xl shadow-xl overflow-hidden"> <div class="bg-gradient-to-r from-yrtv-900 to-yrtv-600 rounded-2xl shadow-xl overflow-hidden">
<div class="px-6 py-12 sm:px-12 sm:py-16 lg:py-20 text-center"> <div class="px-6 py-12 sm:px-12 sm:py-16 lg:py-20 text-center">
<div class="mb-4 text-xs font-black uppercase tracking-[0.3em] text-yrtv-200">{{ brand.signature }}</div>
<h1 class="text-4xl font-extrabold tracking-tight text-white sm:text-5xl lg:text-6xl"> <h1 class="text-4xl font-extrabold tracking-tight text-white sm:text-5xl lg:text-6xl">
JKTV CS2 队伍数据洞察平台 JKTV CS2 队伍数据洞察平台
</h1> </h1>
@@ -6,7 +6,7 @@
<span class="rounded-lg px-3 py-1 text-xs font-black {% if post_match_report.is_win %}bg-green-100 text-green-700{% else %}bg-red-100 text-red-600{% endif %}"> <span class="rounded-lg px-3 py-1 text-xs font-black {% if post_match_report.is_win %}bg-green-100 text-green-700{% else %}bg-red-100 text-red-600{% endif %}">
{{ 'VICTORY' if post_match_report.is_win else 'DEFEAT' }} {{ 'VICTORY' if post_match_report.is_win else 'DEFEAT' }}
</span> </span>
<h2 class="text-xl font-black text-gray-900 dark:text-white">赛后报告</h2> <h2 class="text-xl font-black text-gray-900 dark:text-white">赛后报告 <span class="ml-2 text-[10px] uppercase tracking-wider text-slate-400">by {{ brand.primary }}</span></h2>
</div> </div>
<p class="mt-3 max-w-3xl text-sm text-gray-600 dark:text-gray-300">{{ post_match_report.summary_text }}</p> <p class="mt-3 max-w-3xl text-sm text-gray-600 dark:text-gray-300">{{ post_match_report.summary_text }}</p>
</div> </div>
@@ -1,7 +1,7 @@
<section class="overflow-hidden rounded-2xl border border-gray-100 bg-gradient-to-r from-slate-900 via-slate-800 to-yrtv-900 p-6 text-white shadow-xl dark:border-slate-700"> <section class="overflow-hidden rounded-2xl border border-gray-100 bg-gradient-to-r from-slate-900 via-slate-800 to-yrtv-900 p-6 text-white shadow-xl dark:border-slate-700">
<div class="flex flex-col gap-6 lg:flex-row lg:items-center lg:justify-between"> <div class="flex flex-col gap-6 lg:flex-row lg:items-center lg:justify-between">
<div> <div>
<div class="text-xs font-bold uppercase tracking-[0.25em] text-yrtv-300">Professional Identity</div> <div class="text-xs font-bold uppercase tracking-[0.25em] text-yrtv-300">{{ brand.primary }} · Professional Identity</div>
<div class="mt-2 flex flex-wrap items-center gap-3"> <div class="mt-2 flex flex-wrap items-center gap-3">
<span class="rounded-full bg-white/10 px-3 py-1 text-sm font-bold uppercase">{{ professional_identity.member_role or 'member' }}</span> <span class="rounded-full bg-white/10 px-3 py-1 text-sm font-bold uppercase">{{ professional_identity.member_role or 'member' }}</span>
<span class="text-sm text-slate-300">{{ professional_identity.roster_version or 'Active Roster' }}</span> <span class="text-sm text-slate-300">{{ professional_identity.roster_version or 'Active Roster' }}</span>
+1 -1
View File
@@ -5,7 +5,7 @@
{% block content %} {% block content %}
<div class="space-y-6 px-4 sm:px-0"> <div class="space-y-6 px-4 sm:px-0">
<div> <div>
<p class="text-xs font-bold uppercase tracking-widest text-yrtv-600">Post Match</p> <p class="text-xs font-bold uppercase tracking-widest text-yrtv-600">{{ brand.primary }} · Post Match</p>
<h1 class="mt-1 text-3xl font-black text-gray-900 dark:text-white">赛后报告中心</h1> <h1 class="mt-1 text-3xl font-black text-gray-900 dark:text-white">赛后报告中心</h1>
<p class="mt-2 text-sm text-gray-500">表现变化只与该队员比赛发生前的最近 20 场比较。</p> <p class="mt-2 text-sm text-gray-500">表现变化只与该队员比赛发生前的最近 20 场比较。</p>
</div> </div>
+1 -1
View File
@@ -6,7 +6,7 @@
<div class="space-y-8 px-4 sm:px-0"> <div class="space-y-8 px-4 sm:px-0">
<div class="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between"> <div class="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div> <div>
<p class="text-xs font-bold uppercase tracking-widest text-yrtv-600">Team Career</p> <p class="text-xs font-bold uppercase tracking-widest text-yrtv-600">{{ brand.primary }} · Team Career</p>
<h1 class="mt-1 text-3xl font-black text-gray-900 dark:text-white">战队履历与阵容表现</h1> <h1 class="mt-1 text-3xl font-black text-gray-900 dark:text-white">战队履历与阵容表现</h1>
<p class="mt-2 text-sm text-gray-500">只统计 roster 成员在同一队伍实际共同出场的比赛。</p> <p class="mt-2 text-sm text-gray-500">只统计 roster 成员在同一队伍实际共同出场的比赛。</p>
</div> </div>