2.0.0 Beta : Self-hosted Release
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
.git
|
||||
.gitignore
|
||||
.venv
|
||||
__pycache__
|
||||
*.pyc
|
||||
.pytest_cache
|
||||
database/backups
|
||||
database/.pipeline.lock
|
||||
runtime-data
|
||||
output_arena
|
||||
*.db.bak
|
||||
.env
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -69,6 +69,7 @@ output/
|
||||
output_arena/
|
||||
database/backups/
|
||||
database/.pipeline.lock
|
||||
runtime-data/
|
||||
arena/
|
||||
scripts/
|
||||
experiment
|
||||
|
||||
+27
@@ -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"]
|
||||
@@ -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.
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
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:
|
||||
python3 -m venv .venv
|
||||
$(PYTHON) -m pip install -r requirements.txt
|
||||
|
||||
bootstrap:
|
||||
$(PYTHON) -m database.bootstrap
|
||||
|
||||
run:
|
||||
$(PYTHON) -m web.app
|
||||
|
||||
@@ -30,3 +33,15 @@ l3-all:
|
||||
|
||||
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)"
|
||||
|
||||
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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 战队的私人数据站。它不是公共玩家排行榜,而是让队员拥有类似职业选手的个人主页,并为战队提供比赛档案、队内比较、阵容分析、对手情报和战术工具。
|
||||
|
||||
@@ -27,7 +29,7 @@ YRTV 是面向固定 CS2 战队的私人数据站。它不是公共玩家排行
|
||||
- 111 个日/周/月/季/年度奖项
|
||||
- 19 条正面/负面/趣味数据发现
|
||||
- 36 枚地图与 ELO 分段勋章
|
||||
- 36 项自动化测试通过
|
||||
- 40 项自动化测试通过
|
||||
- 43 项数据完整性检查通过
|
||||
|
||||
数据规模会随导入变化,Admin 数据完整性中心显示的结果是运行时事实。
|
||||
@@ -102,7 +104,40 @@ YRTV 是面向固定 CS2 战队的私人数据站。它不是公共玩家排行
|
||||
- L1/L2/L3/Web 文件状态和回滚快照可视化
|
||||
- 后台写操作使用 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 check # 编译检查 + 自动化测试
|
||||
make pipeline # 备份后执行完整 L1 -> L2 -> L3
|
||||
make bootstrap # 初始化空运行时数据库
|
||||
make prepare-data # 复制现有数据到 runtime-data
|
||||
make docker-up # 构建并启动容器
|
||||
make docker-logs # 查看容器日志
|
||||
|
||||
make l1 # 仅构建 L1
|
||||
make l2 # 仅构建 L2
|
||||
@@ -242,6 +281,8 @@ Flask services and player profiles
|
||||
## 数据库治理
|
||||
|
||||
- 所有运行路径集中定义在 `database/paths.py`
|
||||
- `YRTV_DATA_DIR` 可将数据库、备份、锁和导入文件移出代码仓库
|
||||
- 未设置 `YRTV_DATA_DIR` 时继续兼容原 `database/` 数据路径
|
||||
- 完整编排入口为 `database/pipeline.py`
|
||||
- 同一时间只允许一个 pipeline
|
||||
- Pipeline 运行前备份 L1/L2/L3
|
||||
@@ -252,6 +293,24 @@ Flask services and player profiles
|
||||
- 高频玩家历史、Party、事件和经济查询具有专用索引
|
||||
- 数据库和目录规则详见 `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 数据完整性中心检查:
|
||||
@@ -289,6 +348,8 @@ yrtv/
|
||||
│ ├── maintenance.py # 备份、恢复、健康检查
|
||||
│ ├── job_store.py # ETL 作业状态
|
||||
│ └── pipeline.py # 完整流水线
|
||||
├── docker/
|
||||
│ └── entrypoint.sh
|
||||
├── tests/ # 自动化测试
|
||||
├── utils/ # JSON 结构分析工具
|
||||
├── web/
|
||||
@@ -297,6 +358,9 @@ yrtv/
|
||||
│ ├── templates/
|
||||
│ └── static/
|
||||
├── Makefile
|
||||
├── Dockerfile
|
||||
├── docker-compose.yml
|
||||
├── .env.example
|
||||
├── requirements.txt
|
||||
└── wsgi.py
|
||||
```
|
||||
@@ -305,7 +369,7 @@ yrtv/
|
||||
|
||||
- 当前主要数据源为 5E iframe 网络响应。
|
||||
- 不包含自动网页下载器和 Demo parser。
|
||||
- 认证仍是单一 Admin Token,适合私人部署,不适合开放注册。
|
||||
- 支持独立 Viewer Token 和 Admin Token,但不提供开放注册或多用户权限系统。
|
||||
- SQLite 适合当前单战队规模,不面向高并发多租户。
|
||||
- 部分高级空间能力需要地图边界、路径和 Demo 数据,当前显示 `N/A`。
|
||||
- `StatsService` 仍保留部分兼容逻辑;新功能已拆入 Player Profile、Team Performance、Roster Version 等领域服务。
|
||||
|
||||
+9
-1
@@ -22,6 +22,9 @@ This directory separates four different responsibilities:
|
||||
8. Missing metrics are stored as `NULL`, not fabricated zero values.
|
||||
9. `Admin -> Data Integrity` is the operational source of truth.
|
||||
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
|
||||
|
||||
@@ -31,11 +34,16 @@ make l2 # Rebuild normalized facts
|
||||
make l3 # Rebuild active-roster features
|
||||
make pipeline # Run L1 -> L2 -> L3 with backup and validation
|
||||
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
|
||||
|
||||
- `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.
|
||||
- `schema_bkp/`: historical schema research only; not used at runtime.
|
||||
- `L1B/`: reserved demo-parser integration; not used at runtime.
|
||||
|
||||
@@ -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}')
|
||||
|
||||
@@ -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
@@ -1,26 +1,43 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
DATABASE_ROOT = PROJECT_ROOT / 'database'
|
||||
DATABASE_CODE_ROOT = PROJECT_ROOT / 'database'
|
||||
|
||||
L1_DIR = DATABASE_ROOT / 'L1'
|
||||
L2_DIR = DATABASE_ROOT / 'L2'
|
||||
L3_DIR = DATABASE_ROOT / 'L3'
|
||||
WEB_DIR = DATABASE_ROOT / 'Web'
|
||||
_runtime_override = os.environ.get('YRTV_DATA_DIR')
|
||||
RUNTIME_DATA_ROOT = (
|
||||
Path(_runtime_override).expanduser().resolve()
|
||||
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'
|
||||
L2_DB = L2_DIR / 'L2.db'
|
||||
L3_DB = L3_DIR / 'L3.db'
|
||||
WEB_DB = WEB_DIR / 'Web_App.sqlite'
|
||||
|
||||
L2_SCHEMA = L2_DIR / 'schema.sql'
|
||||
L3_SCHEMA = L3_DIR / 'schema.sql'
|
||||
WEB_SCHEMA = WEB_DIR / 'schema.sql'
|
||||
L2_SCHEMA = DATABASE_CODE_ROOT / 'L2' / 'schema.sql'
|
||||
L3_SCHEMA = DATABASE_CODE_ROOT / 'L3' / 'schema.sql'
|
||||
WEB_SCHEMA = DATABASE_CODE_ROOT / 'Web' / 'schema.sql'
|
||||
|
||||
OUTPUT_ARENA = PROJECT_ROOT / 'output_arena'
|
||||
BACKUP_ROOT = DATABASE_ROOT / 'backups'
|
||||
PIPELINE_LOCK = DATABASE_ROOT / '.pipeline.lock'
|
||||
_import_override = os.environ.get('YRTV_IMPORT_DIR')
|
||||
OUTPUT_ARENA = (
|
||||
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():
|
||||
@@ -33,4 +50,3 @@ def ensure_runtime_directories():
|
||||
BACKUP_ROOT,
|
||||
):
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
@@ -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
|
||||
Executable
+17
@@ -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 -
|
||||
|
||||
@@ -3,6 +3,8 @@ import io
|
||||
import os
|
||||
import shutil
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
@@ -60,6 +62,42 @@ class ApplicationIntegrationTests(unittest.TestCase):
|
||||
self.assertEqual(response.status_code, 200)
|
||||
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):
|
||||
response = self.client.get('/discover/')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
@@ -567,6 +605,72 @@ class DatabaseGovernanceTests(unittest.TestCase):
|
||||
self.assertTrue(path.is_absolute())
|
||||
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):
|
||||
from database.paths import L1_DB
|
||||
from web.services.import_service import MatchImportService
|
||||
|
||||
+32
-1
@@ -3,7 +3,7 @@ import os
|
||||
|
||||
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.database import close_dbs, initialize_web_db
|
||||
from web.auth import get_csrf_token
|
||||
@@ -19,8 +19,38 @@ def create_app(config_object=Config):
|
||||
app.teardown_appcontext(close_dbs)
|
||||
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 (
|
||||
admin,
|
||||
access,
|
||||
awards,
|
||||
discover,
|
||||
main,
|
||||
@@ -33,6 +63,7 @@ def create_app(config_object=Config):
|
||||
wiki,
|
||||
)
|
||||
app.register_blueprint(main.bp)
|
||||
app.register_blueprint(access.bp)
|
||||
app.register_blueprint(matches.bp)
|
||||
app.register_blueprint(players.bp)
|
||||
app.register_blueprint(teams.bp)
|
||||
|
||||
+30
-1
@@ -1,13 +1,37 @@
|
||||
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:
|
||||
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')
|
||||
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_L3_PATH = str(L3_DB)
|
||||
@@ -20,6 +44,11 @@ class Config:
|
||||
SLOW_QUERY_THRESHOLD_SECONDS = float(
|
||||
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
|
||||
ITEMS_PER_PAGE = 20
|
||||
|
||||
@@ -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'))
|
||||
@@ -13,6 +13,9 @@ 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
|
||||
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 time
|
||||
|
||||
@@ -38,11 +41,47 @@ def logout():
|
||||
@bp.route('/')
|
||||
@admin_required
|
||||
def dashboard():
|
||||
if not TeamContextService.get_active_roster_ids():
|
||||
return redirect(url_for('admin.setup'))
|
||||
return render_template(
|
||||
'admin/dashboard.html',
|
||||
**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')
|
||||
@admin_required
|
||||
def data_integrity():
|
||||
|
||||
@@ -1,8 +1,28 @@
|
||||
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
|
||||
|
||||
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('/')
|
||||
def index():
|
||||
recent_matches = StatsService.get_recent_matches(limit=5)
|
||||
|
||||
@@ -164,6 +164,10 @@ class AdminService:
|
||||
'sqlite_timeout': Config.SQLITE_TIMEOUT_SECONDS,
|
||||
'slow_query_threshold': Config.SLOW_QUERY_THRESHOLD_SECONDS,
|
||||
'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': (
|
||||
Config.SECRET_KEY != 'yrtv-dev-only-change-me'
|
||||
),
|
||||
@@ -172,4 +176,3 @@ class AdminService:
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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 %}
|
||||
@@ -19,6 +19,7 @@
|
||||
<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="text-[9px] font-bold uppercase tracking-wider text-slate-500">{{ brand.primary }} Control Plane</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>
|
||||
@@ -83,4 +84,3 @@
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
@@ -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 %}
|
||||
@@ -62,11 +62,17 @@
|
||||
('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))
|
||||
('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>
|
||||
{% endfor %}
|
||||
</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 class="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900">
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
{% block content %}
|
||||
<div class="space-y-8 px-4 sm:px-0">
|
||||
<div>
|
||||
<p class="text-xs font-bold uppercase tracking-widest text-amber-600">YRTV Awards</p>
|
||||
<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>
|
||||
<p class="mt-2 text-sm text-gray-500">完全基于已导入比赛。月、季度和年度奖项设有最低场次门槛。</p>
|
||||
</div>
|
||||
|
||||
+16
-2
@@ -3,6 +3,11 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<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>
|
||||
<script src="https://cdn.tailwindcss.com"></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">
|
||||
<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 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>
|
||||
@@ -111,10 +119,16 @@
|
||||
<!-- Footer -->
|
||||
<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">
|
||||
<p class="text-center text-sm text-gray-500">© 2026 YRTV Data Platform. All rights reserved. 赣ICP备2026001600号</p>
|
||||
<p class="text-center text-sm font-bold text-gray-500">© 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>
|
||||
</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 %}
|
||||
|
||||
<script>
|
||||
|
||||
@@ -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">
|
||||
<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>
|
||||
<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>
|
||||
<p class="mt-3 max-w-2xl text-sm leading-7 text-slate-300">从 885 条 roster 比赛记录中自动发现高光、低谷、反差和个人习惯。每张卡片都有统计证据。</p>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
<!-- Hero Section -->
|
||||
<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="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">
|
||||
JKTV CS2 队伍数据洞察平台
|
||||
</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 %}">
|
||||
{{ 'VICTORY' if post_match_report.is_win else 'DEFEAT' }}
|
||||
</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>
|
||||
<p class="mt-3 max-w-3xl text-sm text-gray-600 dark:text-gray-300">{{ post_match_report.summary_text }}</p>
|
||||
</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">
|
||||
<div class="flex flex-col gap-6 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<div class="text-xs font-bold uppercase tracking-[0.25em] text-yrtv-300">Professional Identity</div>
|
||||
<div class="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">
|
||||
<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>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
{% block content %}
|
||||
<div class="space-y-6 px-4 sm:px-0">
|
||||
<div>
|
||||
<p class="text-xs font-bold uppercase tracking-widest text-yrtv-600">Post Match</p>
|
||||
<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>
|
||||
<p class="mt-2 text-sm text-gray-500">表现变化只与该队员比赛发生前的最近 20 场比较。</p>
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<div class="space-y-8 px-4 sm:px-0">
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-bold uppercase tracking-widest text-yrtv-600">Team Career</p>
|
||||
<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>
|
||||
<p class="mt-2 text-sm text-gray-500">只统计 roster 成员在同一队伍实际共同出场的比赛。</p>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user