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/
|
output_arena/
|
||||||
database/backups/
|
database/backups/
|
||||||
database/.pipeline.lock
|
database/.pipeline.lock
|
||||||
|
runtime-data/
|
||||||
arena/
|
arena/
|
||||||
scripts/
|
scripts/
|
||||||
experiment
|
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
|
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
|
||||||
|
|||||||
@@ -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 战队的私人数据站。它不是公共玩家排行榜,而是让队员拥有类似职业选手的个人主页,并为战队提供比赛档案、队内比较、阵容分析、对手情报和战术工具。
|
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
@@ -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.
|
||||||
|
|||||||
@@ -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
|
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)
|
||||||
|
|
||||||
|
|||||||
@@ -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 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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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.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():
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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:
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
<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 %}
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
('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">
|
||||||
|
|||||||
@@ -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
@@ -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">© 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>
|
</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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user