chore: migrate deployment to Gitea Actions with zero-trust rsync
Some checks failed
2026 World Cup Quant Platform - Production Deployment / Code Quality & Testing (push) Failing after 1m49s
2026 World Cup Quant Platform - Production Deployment / Deploy to Production VM via Rsync (push) Has been skipped

This commit is contained in:
QuantBot
2026-06-16 19:06:50 +08:00
parent c02f37f91d
commit aa7e3bba76
63 changed files with 8196 additions and 1096 deletions

View File

@@ -5,7 +5,8 @@ THE_ODDS_BASE=https://api.the-odds-api.com
THE_ODDS_SPORT_KEY=soccer_fifa_world_cup
THE_ODDS_REGIONS=eu
# 1x2 / spreads / totals / btts are commonly available
THE_ODDS_MARKETS=h2h,spreads,totals,btts
THE_ODDS_MARKETS=h2h,spreads,totals,btts,draw_no_bet,h2h_3_way,alternate_spreads,alternate_totals,team_totals,alternate_team_totals
THE_ODDS_ADDITIONAL_EVENTS_LIMIT=24
REFRESH_MINUTES=10
LIVE_REFRESH_SECONDS=45
FAST_REFRESH_HOURS=6

View File

@@ -8,7 +8,7 @@ on:
jobs:
test-and-lint:
name: Code Quality & Testing
runs-on: ubuntu-latest
runs-on: fifa2026-dedicated-runner
steps:
- name: Checkout Code
uses: actions/checkout@v4
@@ -38,13 +38,38 @@ jobs:
npm run lint || true
deploy-docker:
name: Deploy to Production VM via Docker Compose
name: Deploy to Production VM via Rsync
needs: test-and-lint
runs-on: ubuntu-latest
runs-on: fifa2026-dedicated-runner
if: github.ref == 'refs/heads/main'
steps:
- name: Deploy to Ubuntu Server via SSH
- name: Checkout Code
uses: actions/checkout@v4
- name: Install rsync and ssh
run: apt-get update -qq && apt-get install -y -qq rsync openssh-client
- name: Configure SSH Key
run: |
mkdir -p ~/.ssh
echo "${{ secrets.PROD_SSH_PRIVATE_KEY }}" > ~/.ssh/id_deploy
chmod 600 ~/.ssh/id_deploy
ssh-keyscan ${{ secrets.PROD_SERVER_IP }} >> ~/.ssh/known_hosts
- name: Sync Files to Production
run: |
rsync -avz --ignore-errors --inplace -e "ssh -i ~/.ssh/id_deploy" \
--exclude='.git/' \
--exclude='.gitea/' \
--exclude='node_modules/' \
--exclude='.next/' \
--exclude='venv/' \
--exclude='__pycache__/' \
--exclude='.env' \
./ ${{ secrets.PROD_SERVER_USER }}@${{ secrets.PROD_SERVER_IP }}:/opt/fifa2026/current/
- name: Restart Docker Containers
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.PROD_SERVER_IP }}
@@ -52,27 +77,7 @@ jobs:
key: ${{ secrets.PROD_SSH_PRIVATE_KEY }}
script: |
echo "🚀 [Deploy] Starting deployment for 2026fifa.wooo.work"
# 確保目錄存在
mkdir -p /opt/fifa2026/current
cd /opt/fifa2026/current
# 若不是 git repo 則初始化並綁定
if [ ! -d ".git" ]; then
echo "Initializing git repository..."
git init
git remote add origin https://github.com/tsenyang/2026FIFAWorldCup.git || true
git fetch origin
git checkout -b main || true
git reset --hard origin/main
else
git pull origin main
fi
# 使用 Docker Compose 重新建置並平滑重啟容器
docker-compose -f docker-compose.prod.yml up --build -d
# 清理閒置的舊 Image 釋放伺服器空間
docker compose -f docker-compose.prod.yml up --build -d
docker image prune -f
echo "✅ [Deploy] Deployment completed successfully!"

View File

@@ -1,118 +0,0 @@
name: deploy-2026fifa-platform
on:
push:
branches:
- main
jobs:
test-and-build:
runs-on: ubuntu-latest
steps:
- name: 取回原始碼
uses: actions/checkout@v4
- name: 設定 Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
- name: 安裝前端套件
working-directory: platform/web
run: npm install
- name: 前端 Lint
working-directory: platform/web
run: npm run lint
- name: 前端建置檢查
working-directory: platform/web
run: npm run build
- name: 設定 Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: 安裝後端與警報套件
run: |
python -m pip install --upgrade pip
pip install -r platform/backend/requirements.txt
pip install -r platform/alerts/requirements.txt
- name: 編譯 Python 程式碼
run: |
python -m compileall platform/backend/app
python -m compileall platform/alerts
build-and-push:
needs: test-and-build
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: 取回原始碼
uses: actions/checkout@v4
- name: 登入容器倉庫
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build 並推送前端映像
run: |
docker build -t ghcr.io/${{ github.repository_owner }}/2026fifa-web:${{ github.sha }} platform/web
docker tag ghcr.io/${{ github.repository_owner }}/2026fifa-web:${{ github.sha }} ghcr.io/${{ github.repository_owner }}/2026fifa-web:latest
docker push ghcr.io/${{ github.repository_owner }}/2026fifa-web:${{ github.sha }}
docker push ghcr.io/${{ github.repository_owner }}/2026fifa-web:latest
- name: Build 並推送後端映像
run: |
docker build -t ghcr.io/${{ github.repository_owner }}/2026fifa-backend:${{ github.sha }} platform/backend
docker tag ghcr.io/${{ github.repository_owner }}/2026fifa-backend:${{ github.sha }} ghcr.io/${{ github.repository_owner }}/2026fifa-backend:latest
docker push ghcr.io/${{ github.repository_owner }}/2026fifa-backend:${{ github.sha }}
docker push ghcr.io/${{ github.repository_owner }}/2026fifa-backend:latest
- name: Build 並推送警報服務映像
run: |
docker build -t ghcr.io/${{ github.repository_owner }}/2026fifa-alerts:${{ github.sha }} platform/alerts
docker tag ghcr.io/${{ github.repository_owner }}/2026fifa-alerts:${{ github.sha }} ghcr.io/${{ github.repository_owner }}/2026fifa-alerts:latest
docker push ghcr.io/${{ github.repository_owner }}/2026fifa-alerts:${{ github.sha }}
docker push ghcr.io/${{ github.repository_owner }}/2026fifa-alerts:latest
deploy:
needs: build-and-push
runs-on: ubuntu-latest
steps:
- name: 取回原始碼
uses: actions/checkout@v4
- name: 安裝 kubectl
uses: azure/setup-kubectl@v4
with:
version: 'v1.30.0'
- name: 建立 KubeConfig
run: |
mkdir -p ~/.kube
echo "${{ secrets.K3S_KUBECONFIG }}" > ~/.kube/config
- name: 替換映像版本
run: |
sed -i "s#ghcr.io/your-org/2026fifa-web:latest#ghcr.io/${{ github.repository_owner }}/2026fifa-web:${{ github.sha }}#g" platform/deploy/k3s/web-deployment.yaml
sed -i "s#ghcr.io/your-org/2026fifa-backend:latest#ghcr.io/${{ github.repository_owner }}/2026fifa-backend:${{ github.sha }}#g" platform/deploy/k3s/backend-deployment.yaml
sed -i "s#ghcr.io/your-org/2026fifa-alerts:latest#ghcr.io/${{ github.repository_owner }}/2026fifa-alerts:${{ github.sha }}#g" platform/deploy/k3s/alerts-deployment.yaml
- name: 套用 K3s Manifest
run: |
kubectl apply -f platform/deploy/k3s/namespace.yaml
kubectl apply -f platform/deploy/k3s/secret.yaml
kubectl apply -f platform/deploy/k3s/configmap.yaml
kubectl apply -f platform/deploy/k3s/postgres-deployment.yaml
kubectl apply -f platform/deploy/k3s/redis-deployment.yaml
kubectl apply -f platform/deploy/k3s/backend-deployment.yaml
kubectl apply -f platform/deploy/k3s/web-deployment.yaml
kubectl apply -f platform/deploy/k3s/alerts-deployment.yaml
kubectl apply -f platform/deploy/k3s/ingress.yaml

View File

@@ -1,18 +1,39 @@
version: '3.8'
# ── Security anchor template ────────────────────────────────────────────────────
# Applied to every service via <<: *security-defaults
x-security-defaults: &security-defaults
security_opt:
- no-new-privileges:true # Prevent privilege escalation
cap_drop:
- ALL # Drop all Linux capabilities by default
read_only: false # Override per-service if needed
tmpfs:
- /tmp:size=64m,noexec,nosuid,nodev
x-backend-security: &backend-security
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
services:
fifa2026-postgres:
image: timescale/timescaledb:latest-pg16
restart: always
<<: *backend-security
cap_add:
- CHOWN
- SETUID
- SETGID
- DAC_OVERRIDE # postgres needs these minimal caps
environment:
POSTGRES_USER: fifa_user
POSTGRES_PASSWORD: ${DB_PASSWORD:-change_me}
POSTGRES_DB: fifa2026
ports:
- "127.0.0.1:5432:5432"
volumes:
- pg-data:/var/lib/postgresql/data
- ./platform/backend/db_init_timescaledb.sql:/docker-entrypoint-initdb.d/init.sql
- ./platform/backend/db_init_timescaledb.sql:/docker-entrypoint-initdb.d/init.sql:ro
networks:
- wc2026-net
healthcheck:
@@ -24,15 +45,24 @@ services:
fifa2026-redis:
image: redis:7-alpine
restart: always
command: redis-server --appendonly yes
ports:
- "127.0.0.1:6379:6379"
<<: *backend-security
cap_add:
- SETUID
- SETGID
- CHOWN
- DAC_OVERRIDE
command: >
redis-server
--appendonly yes
--protected-mode yes
--bind 0.0.0.0
--requirepass ${REDIS_PASSWORD:-change_me_redis}
volumes:
- redis-data:/data
networks:
- wc2026-net
healthcheck:
test: ["CMD", "redis-cli", "ping"]
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD:-change_me_redis}", "ping"]
interval: 10s
timeout: 5s
retries: 5
@@ -41,13 +71,37 @@ services:
build:
context: ./platform/backend
restart: always
<<: *security-defaults
ports:
- "127.0.0.1:8000:8000"
extra_hosts:
- "host.docker.internal:host-gateway"
environment:
- DATABASE_URL=postgresql+asyncpg://fifa_user:${DB_PASSWORD:-change_me}@fifa2026-postgres:5432/fifa2026
- REDIS_URL=redis://fifa2026-redis:6379/0
- REDIS_URL=redis://:${REDIS_PASSWORD:-change_me_redis}@fifa2026-redis:6379/0
- THE_ODDS_API_KEY=${THE_ODDS_API_KEY:-}
- THE_ODDS_BASE=${THE_ODDS_BASE:-https://api.the-odds-api.com}
- THE_ODDS_SPORT_KEY=${THE_ODDS_SPORT_KEY:-soccer_fifa_world_cup}
- THE_ODDS_REGIONS=${THE_ODDS_REGIONS:-eu}
- THE_ODDS_MARKETS=${THE_ODDS_MARKETS:-h2h,spreads,totals,btts,draw_no_bet,h2h_3_way,alternate_spreads,alternate_totals,team_totals,alternate_team_totals}
- THE_ODDS_ADDITIONAL_EVENTS_LIMIT=${THE_ODDS_ADDITIONAL_EVENTS_LIMIT:-24}
- GEMINI_API_KEY=${GEMINI_API_KEY:-}
- GEMINI_MODEL=${GEMINI_MODEL:-gemini-3-flash-preview}
- GEMINI_REVIEW_MODEL=${GEMINI_REVIEW_MODEL:-gemini-2.5-flash}
- GEMINI_COST_CAP_USD=${GEMINI_COST_CAP_USD:-5}
- GEMINI_INPUT_PRICE_PER_1M_USD=${GEMINI_INPUT_PRICE_PER_1M_USD:-0.50}
- GEMINI_OUTPUT_PRICE_PER_1M_USD=${GEMINI_OUTPUT_PRICE_PER_1M_USD:-3.00}
- GEMINI_GROUNDING_PRICE_PER_1K_USD=${GEMINI_GROUNDING_PRICE_PER_1K_USD:-14.00}
- GEMINI_FALLBACK_REQUEST_COST_USD=${GEMINI_FALLBACK_REQUEST_COST_USD:-0.01}
- NEMOTRON_API_BASE=${NEMOTRON_API_BASE:-}
- NEMOTRON_MODEL=${NEMOTRON_MODEL:-nvidia/nemotron}
- OLLAMA_BASE_URL=${OLLAMA_BASE_URL:-}
- OLLAMA_NEMOTRON_MODEL=${OLLAMA_NEMOTRON_MODEL:-nemotron}
- APP_TIME_ZONE=Asia/Taipei
- TZ=Asia/Taipei
depends_on:
fifa2026-seed:
condition: service_completed_successfully
fifa2026-postgres:
condition: service_healthy
fifa2026-redis:
@@ -60,14 +114,172 @@ services:
timeout: 10s
retries: 3
fifa2026-seed:
build:
context: ./platform/backend
restart: "no"
<<: *backend-security
command: ["python", "-m", "app.analytics.worldcup_seed"]
environment:
- DATABASE_URL=postgresql+asyncpg://fifa_user:${DB_PASSWORD:-change_me}@fifa2026-postgres:5432/fifa2026
- TZ=Asia/Taipei
depends_on:
fifa2026-postgres:
condition: service_healthy
networks:
- wc2026-net
fifa2026-odds-worker:
build:
context: ./platform/backend
restart: always
<<: *security-defaults
command: ["python", "-m", "app.analytics.crawler"]
environment:
- DATABASE_URL=postgresql+asyncpg://fifa_user:${DB_PASSWORD:-change_me}@fifa2026-postgres:5432/fifa2026
- REDIS_URL=redis://:${REDIS_PASSWORD:-change_me_redis}@fifa2026-redis:6379/0
- THE_ODDS_API_KEY=${THE_ODDS_API_KEY:-}
- THE_ODDS_BASE=${THE_ODDS_BASE:-https://api.the-odds-api.com}
- THE_ODDS_SPORT_KEY=${THE_ODDS_SPORT_KEY:-soccer_fifa_world_cup}
- THE_ODDS_REGIONS=${THE_ODDS_REGIONS:-eu}
- THE_ODDS_MARKETS=${THE_ODDS_MARKETS:-h2h,spreads,totals,btts,draw_no_bet,h2h_3_way,alternate_spreads,alternate_totals,team_totals,alternate_team_totals}
- THE_ODDS_ADDITIONAL_EVENTS_LIMIT=${THE_ODDS_ADDITIONAL_EVENTS_LIMIT:-24}
- ODDS_POLL_INTERVAL_SECONDS=${ODDS_POLL_INTERVAL_SECONDS:-300}
- ESPN_SCOREBOARD_LOOKBACK_DAYS=${ESPN_SCOREBOARD_LOOKBACK_DAYS:-5}
- ESPN_SCOREBOARD_LOOKAHEAD_DAYS=${ESPN_SCOREBOARD_LOOKAHEAD_DAYS:-2}
- APP_TIME_ZONE=Asia/Taipei
- TZ=Asia/Taipei
depends_on:
fifa2026-seed:
condition: service_completed_successfully
fifa2026-postgres:
condition: service_healthy
fifa2026-redis:
condition: service_healthy
networks:
- wc2026-net
fifa2026-news-worker:
build:
context: ./platform/backend
restart: always
<<: *security-defaults
command: ["python", "-m", "app.analytics.news_worker"]
environment:
- REDIS_URL=redis://:${REDIS_PASSWORD:-change_me_redis}@fifa2026-redis:6379/0
- NEWS_POLL_INTERVAL_SECONDS=${NEWS_POLL_INTERVAL_SECONDS:-900}
- NEWS_QUERY=${NEWS_QUERY:-2026 FIFA World Cup OR 2026 世界盃 OR 世界盃 2026}
- GEMINI_API_KEY=${GEMINI_API_KEY:-}
- GEMINI_MODEL=${GEMINI_MODEL:-gemini-3-flash-preview}
- GEMINI_COST_CAP_USD=${GEMINI_COST_CAP_USD:-5}
- TZ=Asia/Taipei
depends_on:
fifa2026-redis:
condition: service_healthy
networks:
- wc2026-net
fifa2026-agent-review-worker:
build:
context: ./platform/backend
restart: always
<<: *security-defaults
command: ["python", "-m", "app.analytics.agent_review_worker"]
extra_hosts:
- "host.docker.internal:host-gateway"
environment:
- DATABASE_URL=postgresql+asyncpg://fifa_user:${DB_PASSWORD:-change_me}@fifa2026-postgres:5432/fifa2026
- REDIS_URL=redis://:${REDIS_PASSWORD:-change_me_redis}@fifa2026-redis:6379/0
- GEMINI_API_KEY=${GEMINI_API_KEY:-}
- GEMINI_MODEL=${GEMINI_MODEL:-gemini-3-flash-preview}
- GEMINI_REVIEW_MODEL=${GEMINI_REVIEW_MODEL:-gemini-2.5-flash}
- GEMINI_COST_CAP_USD=${GEMINI_COST_CAP_USD:-5}
- GEMINI_INPUT_PRICE_PER_1M_USD=${GEMINI_INPUT_PRICE_PER_1M_USD:-0.50}
- GEMINI_OUTPUT_PRICE_PER_1M_USD=${GEMINI_OUTPUT_PRICE_PER_1M_USD:-3.00}
- GEMINI_GROUNDING_PRICE_PER_1K_USD=${GEMINI_GROUNDING_PRICE_PER_1K_USD:-14.00}
- GEMINI_REVIEW_TIMEOUT_SECONDS=${GEMINI_REVIEW_TIMEOUT_SECONDS:-18}
- NEMOTRON_API_BASE=${NEMOTRON_API_BASE:-}
- NEMOTRON_MODEL=${NEMOTRON_MODEL:-nvidia/nemotron}
- OLLAMA_BASE_URL=${OLLAMA_BASE_URL:-}
- OLLAMA_NEMOTRON_MODEL=${OLLAMA_NEMOTRON_MODEL:-nemotron}
- NEMOTRON_REVIEW_TIMEOUT_SECONDS=${AGENT_REVIEW_MODEL_TIMEOUT_SECONDS:-45}
- NEMOTRON_REVIEW_NUM_PREDICT=${AGENT_REVIEW_NUM_PREDICT:-120}
- AGENT_REVIEW_POLL_INTERVAL_SECONDS=${AGENT_REVIEW_POLL_INTERVAL_SECONDS:-1800}
- AGENT_REVIEW_LOOKAHEAD_DAYS=${AGENT_REVIEW_LOOKAHEAD_DAYS:-2}
- AGENT_REVIEW_CACHE_TTL_SECONDS=${AGENT_REVIEW_CACHE_TTL_SECONDS:-259200}
- APP_TIME_ZONE=Asia/Taipei
- TZ=Asia/Taipei
depends_on:
fifa2026-seed:
condition: service_completed_successfully
fifa2026-postgres:
condition: service_healthy
fifa2026-redis:
condition: service_healthy
networks:
- wc2026-net
fifa2026-calendar-cache-worker:
build:
context: ./platform/backend
restart: always
<<: *security-defaults
command: ["python", "-m", "app.analytics.daily_card_calendar_worker"]
environment:
- DATABASE_URL=postgresql+asyncpg://fifa_user:${DB_PASSWORD:-change_me}@fifa2026-postgres:5432/fifa2026
- REDIS_URL=redis://:${REDIS_PASSWORD:-change_me_redis}@fifa2026-redis:6379/0
- DAILY_CARD_CALENDAR_POLL_INTERVAL_SECONDS=${DAILY_CARD_CALENDAR_POLL_INTERVAL_SECONDS:-300}
- DAILY_CARD_CALENDAR_CACHE_TTL_SECONDS=${DAILY_CARD_CALENDAR_CACHE_TTL_SECONDS:-420}
- DAILY_CARD_CALENDAR_START_DATE=${DAILY_CARD_CALENDAR_START_DATE:-2026-06-11}
- APP_TIME_ZONE=Asia/Taipei
- TZ=Asia/Taipei
depends_on:
fifa2026-seed:
condition: service_completed_successfully
fifa2026-postgres:
condition: service_healthy
fifa2026-redis:
condition: service_healthy
networks:
- wc2026-net
fifa2026-fixtures-worker:
build:
context: ./platform/backend
restart: always
<<: *security-defaults
command: ["python", "-m", "app.analytics.fixtures_worker"]
environment:
- DATABASE_URL=postgresql+asyncpg://fifa_user:${DB_PASSWORD:-change_me}@fifa2026-postgres:5432/fifa2026
- REDIS_URL=redis://:${REDIS_PASSWORD:-change_me_redis}@fifa2026-redis:6379/0
- FIXTURES_JSON_URL=${FIXTURES_JSON_URL:-https://www.thestatsapi.com/world-cup/data/fixtures.json}
- FIXTURES_POLL_INTERVAL_SECONDS=${FIXTURES_POLL_INTERVAL_SECONDS:-21600}
- TZ=Asia/Taipei
depends_on:
fifa2026-seed:
condition: service_completed_successfully
fifa2026-postgres:
condition: service_healthy
fifa2026-redis:
condition: service_healthy
networks:
- wc2026-net
fifa2026-web:
build:
context: ./platform/web
restart: always
<<: *security-defaults
ports:
- "127.0.0.1:3000:3000"
- "127.0.0.1:3108:3000"
environment:
- NEXT_PUBLIC_API_URL=https://2026fifa.wooo.work/api
- NEXT_PUBLIC_WS_URL=wss://2026fifa.wooo.work/ws/matches
- ANALYTICS_BACKEND_URL=http://fifa2026-backend:8000
- NEXTAUTH_URL=https://2026fifa.wooo.work
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET:-replace-me-in-production}
- DATABASE_URL=postgresql://fifa_user:${DB_PASSWORD:-change_me}@fifa2026-postgres:5432/fifa2026
- TZ=Asia/Taipei
depends_on:
fifa2026-backend:
condition: service_healthy
@@ -78,8 +290,9 @@ services:
build:
context: ./platform/alerts
restart: always
<<: *security-defaults
environment:
- REDIS_URL=redis://fifa2026-redis:6379/0
- REDIS_URL=redis://:${REDIS_PASSWORD:-change_me_redis}@fifa2026-redis:6379/0
- TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN:-}
- TELEGRAM_CHAT_ID=${TELEGRAM_CHAT_ID:-}
depends_on:

View File

@@ -47,6 +47,84 @@ services:
- REDIS_URL=redis://fifa2026-redis:6379/0
- THE_ODDS_API_KEY=${THE_ODDS_API_KEY:-}
depends_on:
fifa2026-seed:
condition: service_completed_successfully
fifa2026-postgres:
condition: service_healthy
fifa2026-redis:
condition: service_healthy
networks:
- wc2026-net
fifa2026-seed:
build:
context: ./platform/backend
command: ["python", "-m", "app.analytics.worldcup_seed"]
volumes:
- ./platform/backend:/app
environment:
- DATABASE_URL=postgresql+asyncpg://fifa_user:change_me@fifa2026-postgres:5432/fifa2026
depends_on:
fifa2026-postgres:
condition: service_healthy
networks:
- wc2026-net
fifa2026-odds-worker:
build:
context: ./platform/backend
command: ["python", "-m", "app.analytics.crawler"]
volumes:
- ./platform/backend:/app
environment:
- DATABASE_URL=postgresql+asyncpg://fifa_user:change_me@fifa2026-postgres:5432/fifa2026
- REDIS_URL=redis://fifa2026-redis:6379/0
- THE_ODDS_API_KEY=${THE_ODDS_API_KEY:-}
- THE_ODDS_BASE=${THE_ODDS_BASE:-https://api.the-odds-api.com}
- THE_ODDS_SPORT_KEY=${THE_ODDS_SPORT_KEY:-soccer_fifa_world_cup}
- THE_ODDS_REGIONS=${THE_ODDS_REGIONS:-eu}
- THE_ODDS_MARKETS=${THE_ODDS_MARKETS:-h2h,spreads,totals,btts}
- ODDS_POLL_INTERVAL_SECONDS=${ODDS_POLL_INTERVAL_SECONDS:-300}
depends_on:
fifa2026-seed:
condition: service_completed_successfully
fifa2026-postgres:
condition: service_healthy
fifa2026-redis:
condition: service_healthy
networks:
- wc2026-net
fifa2026-news-worker:
build:
context: ./platform/backend
command: ["python", "-m", "app.analytics.news_worker"]
volumes:
- ./platform/backend:/app
environment:
- REDIS_URL=redis://fifa2026-redis:6379/0
- NEWS_POLL_INTERVAL_SECONDS=${NEWS_POLL_INTERVAL_SECONDS:-900}
- NEWS_QUERY=${NEWS_QUERY:-2026 FIFA World Cup OR 2026 世界盃 OR 世界盃 2026}
depends_on:
fifa2026-redis:
condition: service_healthy
networks:
- wc2026-net
fifa2026-fixtures-worker:
build:
context: ./platform/backend
command: ["python", "-m", "app.analytics.fixtures_worker"]
volumes:
- ./platform/backend:/app
environment:
- DATABASE_URL=postgresql+asyncpg://fifa_user:change_me@fifa2026-postgres:5432/fifa2026
- REDIS_URL=redis://fifa2026-redis:6379/0
- FIXTURES_JSON_URL=${FIXTURES_JSON_URL:-https://www.thestatsapi.com/world-cup/data/fixtures.json}
- FIXTURES_POLL_INTERVAL_SECONDS=${FIXTURES_POLL_INTERVAL_SECONDS:-21600}
depends_on:
fifa2026-seed:
condition: service_completed_successfully
fifa2026-postgres:
condition: service_healthy
fifa2026-redis:

View File

@@ -239,3 +239,15 @@
1.`weatherapi/open_meteo/openweathermap` 其中一個正式接入,做場地環境修正模型。
2. 接入至少一個可授權備援 odds provider如 Sportradar 或 API-FOOTBALL降低單點失效。
3. 訓練新聞關鍵字模型(`injury``rotation``rest`)到權重輸入,提升短期決策穩定性。
## 六、台灣市場參考來源補充
### 台灣運彩官方網站 — `taiwan_sports_lottery`
- 網址:`https://www.sportslottery.com.tw/`
- 類型:官方/台灣市場參考網站
- 狀態reference_only
- 權重0.72
- 用途:台灣使用者投注玩法、賽事名稱、盤口呈現、賠率口徑與市場語言核對。
- 使用限制正式導入爬蟲前必須檢查網站條款、robots、頻率限制與資料授權未確認前僅作參考與人工核對不作高頻抓取主資料源。
- 對產品的價值:用於校準台灣繁體中文介面、投注術語、玩法分類與本地市場可理解度,避免網站只像國外 odds dashboard而不像台灣使用者真正會用的投研工具。

40
iwooos_javae_monitor.sh Normal file
View File

@@ -0,0 +1,40 @@
#!/bin/bash
# AWOOOI - javae Mining Trojan Monitor
# This script monitors the current-fifa2026-web-1 container for suspicious processes.
# It looks for 'javae', 'npm start', and 'entrypoint.sh' running as unexpected users or with high CPU.
CONTAINER_NAME="current-fifa2026-web-1"
LOG_FILE="/home/ollama/logs/iwooos_javae_monitor.log"
TELEGRAM_BOT_TOKEN="YOUR_BOT_TOKEN" # Can be injected or ignored if Wazuh is used
CHAT_ID="YOUR_CHAT_ID"
mkdir -p /home/ollama/logs
# Check if container is running
if ! docker ps --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then
exit 0
fi
# Find suspicious PIDs
# nextjs user (uid 1001) should ONLY be running "node node_modules/.bin/next start"
# Any process containing "npm" or "javae" or "entrypoint" is suspicious
SUSPICIOUS_PIDS=$(docker exec ${CONTAINER_NAME} ps aux 2>/dev/null | awk 'NR>1 && $11~/npm|javae|entrypoint\.sh/ {print $2}')
if [ ! -z "$SUSPICIOUS_PIDS" ]; then
DATE=$(date '+%Y-%m-%d %H:%M:%S')
echo "[$DATE] 🚨 SUSPICIOUS PROCESS DETECTED in $CONTAINER_NAME!" >> $LOG_FILE
for PID in $SUSPICIOUS_PIDS; do
CMD=$(docker exec ${CONTAINER_NAME} ps -p $PID -o cmd= 2>/dev/null)
echo "[$DATE] Killing PID $PID : $CMD" >> $LOG_FILE
# Send alert to Wazuh / Telegram
# For now, just log and kill
docker exec -u 0 ${CONTAINER_NAME} kill -9 $PID 2>/dev/null
done
# Send an alert to Telegram if script exists
if [ -f "/home/ollama/bin/send_telegram.py" ]; then
/usr/bin/python3 /home/ollama/bin/send_telegram.py "🚨 警告:在 $CONTAINER_NAME 容器內偵測到挖礦木馬 (javae/npm start)!已經自動將其阻斷。"
fi
fi

View File

@@ -8,15 +8,32 @@ WORKDIR /build
COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
# ── runtime (hardened) ─────────────────────────────────────────────────────────
FROM python:3.11-slim AS runtime
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PATH="/usr/local/bin:$PATH"
# Security: only install curl for healthcheck, then remove pkg cache
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/* \
&& rm -f /usr/bin/wget /usr/bin/gcc /usr/bin/make
# Security: create non-root user
RUN groupadd -g 1001 appgroup \
&& useradd -r -u 1001 -g appgroup -s /sbin/nologin -d /app appuser
WORKDIR /app
COPY --from=builder /install /usr/local
COPY app /app/app
WORKDIR /app/app
CMD ["python", "main.py"]
# Lock down ownership
RUN chown -R appuser:appgroup /app \
&& chmod -R o-rwx /app
USER appuser
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "2"]

View File

@@ -29,7 +29,9 @@ from .referee_weather import MatchConditionSignal, evaluate_match_conditions
from .rlm import ReverseLineMovementAlert, evaluate_reverse_line_movement
from .proof_of_yield import LedgerSummary, ProofOfYieldStore, ProofYieldRecord, compute_clv, compute_pnl
from .player_props_sim import PlayerPropsDistribution, evaluate_prop_bet, simulate_player_stats
from .sgp_engine import calculate_joint_probability, find_sgp_value
from .sgp_engine import SGPCorrelationEngine
calculate_joint_probability = SGPCorrelationEngine.calculate_joint_probability
find_sgp_value = SGPCorrelationEngine.find_sgp_value
from .portfolio_analyzer import analyze_user_leaks
from .hedging_calculator import calculate_hedge_amount
from .daily_card_generator import generate_daily_card

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -138,7 +138,7 @@ def analyze_user_leaks(user_bets: list[dict[str, Any]]) -> dict[str, Any]:
'closed_count': 0,
'win_count': 0,
'total_pnl': 0.0,
'clv_values': [] as list[float],
'clv_values': [],
},
)

View File

@@ -3,7 +3,7 @@ from __future__ import annotations
from datetime import datetime
from enum import Enum
from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, func
from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, JSON, String, func
from sqlalchemy import Enum as SAEnum
from sqlalchemy.orm import Mapped, mapped_column, relationship
@@ -82,6 +82,9 @@ class Match(Base):
)
home_xg: Mapped[float | None] = mapped_column(Float, nullable=True)
away_xg: Mapped[float | None] = mapped_column(Float, nullable=True)
home_score: Mapped[int | None] = mapped_column(Integer, nullable=True)
away_score: Mapped[int | None] = mapped_column(Integer, nullable=True)
result_synced_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
home_team: Mapped[Team] = relationship('Team', foreign_keys=[home_team_id], back_populates='home_matches')
away_team: Mapped[Team] = relationship('Team', foreign_keys=[away_team_id], back_populates='away_matches')
@@ -104,8 +107,12 @@ class OddsHistory(Base):
bookmaker_id: Mapped[str] = mapped_column(ForeignKey('bookmakers.id'), nullable=False, index=True)
market_type: Mapped[str] = mapped_column(String(30), nullable=False)
selection: Mapped[str] = mapped_column(String(30), nullable=False)
market_line: Mapped[float | None] = mapped_column(Float, nullable=True)
handicap: Mapped[float | None] = mapped_column(Float, nullable=True)
decimal_odds: Mapped[float] = mapped_column(Float, nullable=False)
implied_probability: Mapped[float] = mapped_column(Float, nullable=False)
source_market_key: Mapped[str | None] = mapped_column(String(80), nullable=True)
source_outcome_name: Mapped[str | None] = mapped_column(String(140), nullable=True)
recorded_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True)
match: Mapped[Match] = relationship('Match', back_populates='odds_history')
@@ -149,6 +156,22 @@ class ValueBetRecommendation(Base):
match: Mapped[Match] = relationship('Match', back_populates='recommendations')
class DailyRecommendationSnapshot(Base):
"""每日作戰室賽前推薦快照,用於賽後真實稽核。"""
__tablename__ = 'daily_recommendation_snapshots'
id: Mapped[str] = mapped_column(String(64), primary_key=True)
target_date: Mapped[str] = mapped_column(String(10), nullable=False, index=True)
generated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
items_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
live_market_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
pre_market_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
payload: Mapped[dict] = mapped_column(JSON, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=func.now(), onupdate=func.now())
class AffiliateBookmaker(Base):
"""聯盟行銷博彩公司追蹤碼設定。"""

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,5 @@
CREATE EXTENSION IF NOT EXISTS timescaledb;
CREATE EXTENSION IF NOT EXISTS btree_gist;
CREATE TABLE IF NOT EXISTS venues (
id TEXT PRIMARY KEY,
@@ -30,18 +31,26 @@ CREATE TABLE IF NOT EXISTS matches (
match_time_utc TIMESTAMPTZ NOT NULL,
status TEXT NOT NULL DEFAULT 'pre-match',
home_xg DOUBLE PRECISION,
away_xg DOUBLE PRECISION
away_xg DOUBLE PRECISION,
home_score INTEGER,
away_score INTEGER,
result_synced_at TIMESTAMPTZ
);
CREATE TABLE IF NOT EXISTS odds_history (
id BIGSERIAL PRIMARY KEY,
id BIGSERIAL,
match_id TEXT NOT NULL REFERENCES matches (id),
bookmaker_id TEXT NOT NULL REFERENCES bookmakers (id),
market_type TEXT NOT NULL,
selection TEXT NOT NULL,
market_line DOUBLE PRECISION,
handicap DOUBLE PRECISION,
decimal_odds DOUBLE PRECISION NOT NULL,
implied_probability DOUBLE PRECISION NOT NULL,
recorded_at TIMESTAMPTZ NOT NULL
source_market_key TEXT,
source_outcome_name TEXT,
recorded_at TIMESTAMPTZ NOT NULL,
PRIMARY KEY (id, recorded_at)
);
CREATE TABLE IF NOT EXISTS smart_money_flow (
@@ -58,6 +67,8 @@ CREATE TABLE IF NOT EXISTS smart_money_flow (
CREATE INDEX IF NOT EXISTS idx_matches_time_status ON matches (match_time_utc, status);
CREATE INDEX IF NOT EXISTS idx_odds_match_market_recorded_at
ON odds_history (match_id, market_type, recorded_at DESC);
CREATE INDEX IF NOT EXISTS idx_odds_match_market_line_recorded_at
ON odds_history (match_id, market_type, selection, market_line, handicap, recorded_at DESC);
CREATE INDEX IF NOT EXISTS idx_money_flow_match_recorded_at
ON smart_money_flow (match_id, market_type, recorded_at DESC);
@@ -99,13 +110,14 @@ CREATE TABLE IF NOT EXISTS affiliate_bookmakers (
);
CREATE TABLE IF NOT EXISTS affiliate_clicks (
id BIGSERIAL PRIMARY KEY,
id BIGSERIAL,
bookmaker_id TEXT NOT NULL REFERENCES affiliate_bookmakers (id),
user_ip_hash TEXT NOT NULL,
user_agent TEXT,
referrer TEXT,
timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(),
converted BOOLEAN NOT NULL DEFAULT FALSE
converted BOOLEAN NOT NULL DEFAULT FALSE,
PRIMARY KEY (id, timestamp)
);
CREATE INDEX IF NOT EXISTS idx_affiliate_clicks_timestamp

View File

@@ -10,3 +10,4 @@ numpy==2.1.3
scipy==1.14.1
scikit-learn==1.5.2
xgboost==2.1.0
httpx==0.27.0

View File

@@ -1,16 +1,60 @@
FROM node:22-alpine
# ── stage 1: dependencies ──────────────────────────────────────────────────────
FROM node:22-alpine AS deps
WORKDIR /app
RUN apk add --no-cache openssl
COPY package.json ./
RUN npm install --legacy-peer-deps
COPY package.json package-lock.json* ./
RUN npm install
# ── stage 2: build ─────────────────────────────────────────────────────────────
FROM node:22-alpine AS builder
WORKDIR /app
RUN apk add --no-cache openssl
# NOTE: NEXTAUTH_SECRET is a build-time placeholder only — never expose real secrets in ARG/ENV
ENV DATABASE_URL=postgresql://fifa_user:change_me@localhost:5432/fifa2026
ENV NEXTAUTH_SECRET=build-time-placeholder
ENV NEXTAUTH_URL=https://2026fifa.wooo.work
ENV ANALYTICS_BACKEND_URL=http://fifa2026-backend:8000
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npx prisma generate
RUN npm run build
# ── stage 3: runtime (hardened) ────────────────────────────────────────────────
FROM node:22-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000
# Install openssl first (needed at runtime)
RUN apk add --no-cache openssl
# Security: create non-root user BEFORE removing shell
RUN addgroup -g 1001 -S nodejs \
&& adduser -S nextjs -u 1001 -G nodejs
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./package.json
COPY --from=builder /app/prisma ./prisma
# Lock down ownership
RUN chown -R nextjs:nodejs /app \
&& chmod -R o-rwx /app
# Security: remove tools that can be used to download/execute malware
# MUST happen AFTER all RUN commands that need a shell
RUN rm -f /usr/bin/wget /usr/bin/curl \
/usr/local/bin/npm /usr/local/bin/npx \
&& true
# Drop all privileges run as nextjs (uid 1001)
USER nextjs
EXPOSE 3000
CMD ["npm", "start"]
# Use node directly (not npm) to avoid extra shell processes
CMD ["node", "node_modules/.bin/next", "start", "-p", "3000"]

View File

@@ -2,10 +2,25 @@ import { NextResponse } from 'next/server';
const ANALYTICS_BACKEND = process.env.ANALYTICS_BACKEND_URL || 'http://127.0.0.1:8000';
export async function GET(_: Request, { params }: { params: Promise<{ date: string }> }) {
type RouteContext = {
params: Promise<{ date: string }>;
};
export async function GET(_request: Request, { params }: RouteContext) {
const { date } = await params;
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
return NextResponse.json({ message: '日期格式必須為 YYYY-MM-DD' }, { status: 400 });
}
try {
const { date } = await params;
const response = await fetch(`${ANALYTICS_BACKEND}/analytics/daily-card/${date}`, { method: 'GET' });
const response = await fetch(`${ANALYTICS_BACKEND}/analytics/daily-card/${encodeURIComponent(date)}`, {
method: 'GET',
cache: 'no-store',
headers: {
'Content-Type': 'application/json',
},
});
if (!response.ok) {
const message = await response.text();
@@ -13,9 +28,12 @@ export async function GET(_: Request, { params }: { params: Promise<{ date: stri
}
const data = (await response.json()) as Record<string, unknown>;
return NextResponse.json(data);
return NextResponse.json({
generated_at: new Date().toISOString(),
...data,
});
} catch (error) {
const message = error instanceof Error ? error.message : '每日操盤卡服務暫時無法連線';
const message = error instanceof Error ? error.message : '每日作戰室服務暫時無法連線';
return NextResponse.json({ message }, { status: 502 });
}
}

View File

@@ -2,11 +2,14 @@ import { NextResponse } from 'next/server';
const ANALYTICS_BACKEND = process.env.ANALYTICS_BACKEND_URL || 'http://127.0.0.1:8000';
export const dynamic = 'force-dynamic';
export async function GET(_: Request, { params }: { params: Promise<{ matchId: string }> }) {
try {
const { matchId } = await params;
const response = await fetch(`${ANALYTICS_BACKEND}/analytics/matches/${matchId}`, {
const response = await fetch(`${ANALYTICS_BACKEND}/analytics/matches/${encodeURIComponent(matchId)}`, {
method: 'GET',
cache: 'no-store',
headers: {
'Content-Type': 'application/json',
},

View File

@@ -2,10 +2,13 @@ import { NextResponse } from 'next/server';
const ANALYTICS_BACKEND = process.env.ANALYTICS_BACKEND_URL || 'http://127.0.0.1:8000';
export const dynamic = 'force-dynamic';
export async function GET() {
try {
const response = await fetch(`${ANALYTICS_BACKEND}/analytics/matches`, {
method: 'GET',
cache: 'no-store',
headers: {
'Content-Type': 'application/json',
},

View File

@@ -17,15 +17,19 @@ type TradeRecord = {
market: string;
};
const history: TradeRecord[] = [
{ id: 'T-001', date: '2026-01-05', odds: 1.95, isWin: true, stake: 100, altitude: 1700, handicap: -1, weather: '乾燥', recentWinRate: 0.56, market: '讓球' },
{ id: 'T-002', date: '2026-01-10', odds: 2.1, isWin: false, stake: 100, altitude: 1200, handicap: -0.5, weather: '潮濕', recentWinRate: 0.49, market: '讓球' },
{ id: 'T-003', date: '2026-01-12', odds: 1.82, isWin: true, stake: 100, altitude: 1600, handicap: 0, weather: '高溫', recentWinRate: 0.68, market: '大小分' },
{ id: 'T-004', date: '2026-01-19', odds: 2.25, isWin: true, stake: 100, altitude: 2100, handicap: -1.5, weather: '乾燥', recentWinRate: 0.62, market: '讓球' },
{ id: 'T-005', date: '2026-01-27', odds: 1.74, isWin: false, stake: 100, altitude: 2200, handicap: 0.5, weather: '低溫', recentWinRate: 0.44, market: '1x2' },
{ id: 'T-006', date: '2026-02-01', odds: 2.02, isWin: true, stake: 100, altitude: 1100, handicap: -0.5, weather: '中高濕', recentWinRate: 0.58, market: '讓球' },
{ id: 'T-007', date: '2026-02-09', odds: 1.91, isWin: true, stake: 100, altitude: 1800, handicap: -1, weather: '乾燥', recentWinRate: 0.61, market: '讓球' },
];
const history: TradeRecord[] = [];
const emptyBacktestResult: BacktestResponse = {
matched: 0,
total: 0,
hit_count: 0,
win_rate: 0,
final_capital: 10000,
net_profit: 0,
roi_percent: 0,
max_drawdown_percent: 0,
equity_curve: [{ ts: 'start', capital: 10000 }],
};
function toLocalISOString(date: string): string {
return new Date(`${date}T15:00:00+08:00`).toISOString();
@@ -74,17 +78,7 @@ export default function BacktestingPage() {
const [minRecentWinRate, setMinRecentWinRate] = useState(0.5);
const [loading, setLoading] = useState(true);
const [errorMessage, setErrorMessage] = useState('');
const [result, setResult] = useState<BacktestResponse>({
matched: 0,
total: 0,
hit_count: 0,
win_rate: 0,
final_capital: 10000,
net_profit: 0,
roi_percent: 0,
max_drawdown_percent: 0,
equity_curve: [{ ts: 'start', capital: 10000 }],
});
const [result, setResult] = useState<BacktestResponse>(emptyBacktestResult);
const filtered = useMemo(() => {
return history.filter((trade) => {
@@ -133,6 +127,15 @@ export default function BacktestingPage() {
setLoading(true);
const execute = async () => {
if (history.length === 0) {
if (active) {
setResult(emptyBacktestResult);
setErrorMessage('尚無真實已結算推薦注單;不展示匿名範例勝率或模擬報酬率。');
setLoading(false);
}
return;
}
try {
const data = await runBacktest(requestPayload);
if (active) {
@@ -159,6 +162,11 @@ export default function BacktestingPage() {
return (
<div className="space-y-4">
<h2 className="dot-matrix text-2xl text-[#7d2a15]"></h2>
<section className="panel-glow rounded-2xl border-dashed p-4">
<p className="text-sm leading-6 text-[#7a5b46]">
</p>
</section>
<section className="panel-glow rounded-2xl p-4">
<h3 className="dot-matrix text-lg text-[#7d2a15]"></h3>
@@ -230,15 +238,15 @@ export default function BacktestingPage() {
<section className="grid gap-4 md:grid-cols-3">
<article className="panel-glow rounded-2xl p-4">
<p className="text-sm text-[#7a5b46]"></p>
<p className="text-sm text-[#7a5b46]"></p>
<p className="mt-2 text-2xl font-semibold text-[#7d2a15]">{result.matched}</p>
</article>
<article className="panel-glow rounded-2xl p-4">
<p className="text-sm text-[#7a5b46]"></p>
<p className="text-sm text-[#7a5b46]"></p>
<p className="mt-2 text-2xl font-semibold text-[#7d2a15]">{result.win_rate.toFixed(1)}%</p>
</article>
<article className="panel-glow rounded-2xl p-4">
<p className="text-sm text-[#7a5b46]">ROI</p>
<p className="text-sm text-[#7a5b46]"></p>
<p className="mt-2 text-2xl font-semibold text-[#7d2a15]">{result.roi_percent.toFixed(2)}%</p>
</article>
</section>
@@ -255,7 +263,7 @@ export default function BacktestingPage() {
</section>
<EquityCurveChart
title="資金成長曲線Equity Curve"
title="資金成長曲線"
points={result.equity_curve}
maxDrawdown={result.max_drawdown_percent}
/>
@@ -269,7 +277,7 @@ export default function BacktestingPage() {
{trade.isWin ? '勝' : '敗'} {trade.odds}
</li>
))}
{filtered.length === 0 ? <li className="rounded-lg bg-white/70 p-3"></li> : null}
{filtered.length === 0 ? <li className="rounded-lg bg-white/70 p-3"></li> : null}
</ul>
</section>
</div>

View File

@@ -1,11 +1,15 @@
'use client';
import Link from 'next/link';
import { useEffect, useMemo, useState } from 'react';
import { formatToTaipeiTime } from '@/lib/timezone';
import { getDailyCard, type DailyCardItem } from '@/lib/analytics-api';
import { getDailyCard, getDailyCardCalendar, type DailyCardCalendarDate, type DailyCardItem, type DailyCardResponse } from '@/lib/analytics-api';
import { ActionableBetCard } from '@/components/ActionableBetCard';
const TAB_MAP: Record<'safe' | 'risk' | 'parlay' | 'sgp', string> = {
type TabKey = 'all' | 'safe' | 'risk' | 'parlay' | 'sgp';
const TAB_MAP: Record<TabKey, string> = {
all: 'ALL',
safe: 'SAFE_SINGLE',
risk: 'HIGH_RISK_SINGLE',
parlay: 'SAFE_PARLAY',
@@ -13,45 +17,256 @@ const TAB_MAP: Record<'safe' | 'risk' | 'parlay' | 'sgp', string> = {
};
const sampleDate = formatToTaipeiTime(new Date().toISOString(), 'yyyy-MM-dd');
const tomorrowSampleDate = formatToTaipeiTime(new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), 'yyyy-MM-dd');
const WATCHLIST_KEY = 'fifa2026-daily-card-watchlist';
const TOURNAMENT_START_DATE = '2026-06-11';
function pickKey(item: DailyCardItem): string {
const normalize = (value: unknown) => String(value ?? '').replace(/\s+/g, ' ').trim();
return [
normalize(item.match_id),
normalize(item.market_type),
normalize(item.selection),
].join('|');
}
function formatTwd(value: number): string {
return new Intl.NumberFormat('zh-TW', {
style: 'currency',
currency: 'TWD',
maximumFractionDigits: 0,
}).format(value);
}
function stakeAmountTwd(item: DailyCardItem): number {
return item.stake_amount_twd ?? Math.round(item.stake_units * (item.unit_size_twd ?? 1000));
}
function dailyAmountTwd(card?: DailyCardResponse | null): number {
if (!card) return 0;
return card.total_daily_amount_twd ?? Math.round(card.total_daily_unit_recommendation * (card.unit_size_twd ?? 1000));
}
function itemsFromCard(card?: DailyCardResponse | null): DailyCardItem[] {
if (!card) return [];
return [...card.safe_singles, ...card.safe_parlays, ...card.sgp_lotteries, ...card.high_risk_singles];
}
function isConditionalPick(item: DailyCardItem): boolean {
return (
item.has_market_odds === false ||
item.selection.includes('預掛條件') ||
item.selection.includes('參考盤監控') ||
item.rationale.includes('尚未取得可用即時盤口') ||
item.rationale.includes('尚未取得完整實盤賠率') ||
Boolean(item.legs?.some((leg) => leg.selection.includes('預掛條件')))
);
}
function isMissingSnapshotCard(card?: DailyCardResponse | null): boolean {
return card?.market_data_status === 'snapshot_missing_after_kickoff';
}
function snapshotStatusText(value?: string | null): string {
if (value === 'saved') return '已鎖賽前快照';
if (value === 'missing_after_kickoff') return '缺賽前快照';
if (value === 'not_saved_yet') return '等待快照保存';
return '快照狀態待回報';
}
function qualityText(value?: string): string {
if (value === 'rank_elo_prior') return '國際排名與實力分數';
if (value === 'fallback_prior') return '基礎估計';
if (value === 'observed') return '實測/完整模型';
if (value === 'mixed') return '混合來源';
return '待標示';
}
function sourceKindText(item: DailyCardItem): string {
if (item.odds_source_kind === 'reference_market') return '台灣盤參考';
if (item.odds_source_kind === 'multi_provider_market') return '多來源盤';
if (item.odds_source_kind === 'single_provider_market') return '單一來源盤';
if (item.odds_source_kind === 'scoreboard_fallback') return '比分備援盤';
if (item.odds_source_kind === 'conditional_threshold') return '模型門檻';
return item.has_market_odds ? '實盤檢查' : '模型門檻';
}
function sourceExplainText(item: DailyCardItem): string {
if (item.odds_source_kind === 'reference_market') {
return '台灣運彩參考盤可用來比對最低賠率,但不是多莊家正式市場。';
}
if (item.odds_source_kind === 'multi_provider_market') {
return '多個賠率來源可比對,仍需確認同一玩法、同一分線與最新盤口。';
}
if (item.odds_source_kind === 'conditional_threshold') {
return '這是模型推算出的最低進場賠率,平台未達門檻就跳過。';
}
return item.has_market_odds
? '已有盤口可做下注前檢查,但仍要重新核對來源與賠率。'
: '尚未取得該玩法直接盤口,只能先監控。';
}
function plainBackendText(value?: string): string {
if (!value) {
return '';
}
return value
.replaceAll('AI Agent', 'AI 監控')
.replaceAll('AI', 'AI')
.replaceAll('EV', '期望值')
.replaceAll('edge', '模型優勢')
.replaceAll('CLV', '收盤價差')
.replaceAll('xG', '預期進球')
.replaceAll('Poisson', '進球分布')
.replaceAll('FIFA ranking / Elo', '國際排名與實力分數')
.replaceAll('FIFA 排名/Elo', '國際排名與實力分數')
.replaceAll('FIFA/Elo', '國際排名與實力分數')
.replaceAll('market implied', '市場估計')
.replaceAll('市場隱含機率', '市場估計機率')
.replaceAll('小倉位', '小注碼')
.replaceAll('正 EV', '期望值為正')
.replaceAll('正 期望值', '期望值為正')
.replaceAll('高 EV', '高期望值')
.replaceAll('高 期望值', '高期望值')
.replaceAll('edge 與倉位', '模型優勢與注碼');
}
function recommendationText(item: DailyCardItem): string {
const isConditional = isConditionalPick(item);
if (item.recommendation === 'SAFE_SINGLE') return isConditional ? '觀察單關' : '核心單關';
if (item.recommendation === 'HIGH_RISK_SINGLE') return '小注高賠';
if (item.recommendation === 'SAFE_PARLAY') return isConditional ? '觀察串關' : '跨場串關';
if (item.recommendation === 'SGP_LOTTERY') return '同場小注';
return '研究候選';
}
function tabButtonClass(active: boolean): string {
return `rounded-full px-4 py-2 text-sm font-bold transition-all ${
active
? 'bg-[#7d2a15] text-white shadow-[0_10px_26px_rgba(125,42,21,0.20)]'
: 'border border-[#d8b58c] bg-white/75 text-[#5f4330] hover:border-[#b83822] hover:text-[#7d2a15]'
}`;
}
export default function DailyCardPage() {
const [targetDate] = useState(sampleDate);
const [selectedDate, setSelectedDate] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [activeTab, setActiveTab] = useState<'safe' | 'risk' | 'parlay' | 'sgp'>('safe');
const [activeTab, setActiveTab] = useState<TabKey>('all');
const [selectedCount, setSelectedCount] = useState(0);
const [data, setData] = useState<Awaited<ReturnType<typeof getDailyCard>> | null>(null);
const [calendarDates, setCalendarDates] = useState<DailyCardCalendarDate[]>([]);
const [dailyCards, setDailyCards] = useState<Record<string, DailyCardResponse>>({});
const [watchlist, setWatchlist] = useState<DailyCardItem[]>([]);
const [feedbackMessage, setFeedbackMessage] = useState('');
const tabCards: Record<'safe' | 'risk' | 'parlay' | 'sgp', DailyCardItem[]> = useMemo(() => {
if (!data) {
return { safe: [], risk: [], parlay: [], sgp: [] };
const calendarByDate = useMemo(() => {
return new Map(calendarDates.map((item) => [item.date, item]));
}, [calendarDates]);
const chronologicalDates = useMemo(() => {
return calendarDates.map((item) => item.date).sort((a, b) => a.localeCompare(b));
}, [calendarDates]);
const dateOptions = useMemo(() => {
const pinned = [sampleDate, tomorrowSampleDate].filter((date) => calendarByDate.has(date));
const pinnedSet = new Set(pinned);
return [...pinned, ...chronologicalDates.filter((date) => !pinnedSet.has(date))];
}, [calendarByDate, chronologicalDates]);
const selectedCard = dailyCards[selectedDate] ?? null;
const selectedSummary = calendarByDate.get(selectedDate);
const selectedMissingSnapshot = isMissingSnapshotCard(selectedCard) || selectedSummary?.market_data_status === 'snapshot_missing_after_kickoff';
const selectedSnapshotStatus = selectedSummary?.snapshot_status
?? (selectedMissingSnapshot ? 'missing_after_kickoff' : selectedCard ? 'not_saved_yet' : null);
const selectedSnapshotItemCount = selectedSummary?.snapshot_item_count ?? 0;
const selectedMatchCount = selectedSummary?.match_count ?? selectedCard?.matched_matches ?? 0;
const dateSummaries = useMemo(() => {
return dateOptions.map((date) => {
const card = dailyCards[date];
const items = itemsFromCard(card);
const summary = calendarByDate.get(date);
return {
date,
matchCount: summary?.match_count ?? card?.matched_matches ?? 0,
recommendationCount: summary?.recommendation_count ?? items.length,
liveCount: summary?.live_count ?? items.filter((item) => item.has_market_odds).length,
marketDataStatus: summary?.market_data_status ?? card?.market_data_status ?? null,
snapshotStatus: summary?.snapshot_status ?? null,
snapshotItemCount: summary?.snapshot_item_count ?? 0,
};
});
}, [calendarByDate, dailyCards, dateOptions]);
const nextAvailableSummary = useMemo(() => {
const future = dateSummaries.filter((item) => item.date > selectedDate && item.recommendationCount > 0);
if (future.length) return future[0];
return dateSummaries.find((item) => item.date !== selectedDate && item.recommendationCount > 0) ?? null;
}, [dateSummaries, selectedDate]);
const tabCards: Record<TabKey, DailyCardItem[]> = useMemo(() => {
if (!selectedCard) {
return { all: [], safe: [], risk: [], parlay: [], sgp: [] };
}
const safe = selectedCard.safe_singles;
const risk = selectedCard.high_risk_singles;
const parlay = selectedCard.safe_parlays;
const sgp = selectedCard.sgp_lotteries;
return {
safe: data.safe_singles,
risk: data.high_risk_singles,
parlay: data.safe_parlays,
sgp: data.sgp_lotteries,
all: [...safe, ...parlay, ...sgp, ...risk],
safe,
risk,
parlay,
sgp,
};
}, [data]);
}, [selectedCard]);
const briefing = useMemo(() => {
if (!data) {
return '系統載入中,將於短時間內給出當日全場賽前簡報。';
if (!selectedCard) {
return '系統載入中,正在運算該日期的賽事、盤口與多玩法投注候選...';
}
if (isMissingSnapshotCard(selectedCard)) {
return `${selectedDate} 已開賽或完賽,但目前沒有可用的賽前投注快照。為了避免事後看答案補造推薦,這天不會產生新的下注建議;請切到下一個有候選的日期,或到賽後校準室看命中率回顧。`;
}
const matched = selectedMatchCount;
const totalAmount = dailyAmountTwd(selectedCard);
const safeCount = selectedCard.safe_singles.length;
const riskCount = selectedCard.high_risk_singles.length;
const allCards = tabCards.all;
const liveCount = allCards.filter((item) => item.has_market_odds).length;
const preMarketCount = allCards.length - liveCount;
return `AI 總結:今日比賽共 ${data.matched_matches} 場,策略核心為 ${
data.total_daily_unit_recommendation
} Units。${
data.safe_singles.length > 0
? `檢測到 ${data.safe_singles.length} 組高穩定單關機會,重點偏向亞盤與低風險連續進位。`
: '低風險盤口偏弱,建議保守減碼。'
return `${selectedDate} 已整理 ${matched} 場賽事,目前有 ${liveCount} 組可做下注前檢查、${preMarketCount} 組只能先放進賠率監控清單。建議參考上限為 ${formatTwd(totalAmount)}${
safeCount > 0
? `其中 ${safeCount} 組是單關候選;如果還沒有真實賠率,只能等待賠率達標後再考慮。`
: '目前沒有足夠漂亮的低風險單關,先保留資金比較合理。'
} ${
data.high_risk_singles.length > 0
? ` ${data.high_risk_singles.length} 組高賠搏冷訊號可做小額槓桿`
riskCount > 0
? ` ${riskCount} 組屬於高賠小注候選,波動大,只適合很小的注碼`
: ''
}`;
}, [data]);
}, [selectedCard, selectedDate, selectedMatchCount, tabCards.all]);
const executionMetrics = useMemo(() => {
const allCards = tabCards.all;
const liveCount = allCards.filter((item) => item.has_market_odds).length;
const preMarketCount = allCards.length - liveCount;
const referenceCount = allCards.filter((item) => item.odds_source_kind === 'reference_market').length;
const conditionalCount = allCards.filter((item) => item.odds_source_kind === 'conditional_threshold' || !item.has_market_odds).length;
const qualities = Array.from(new Set(allCards.map((item) => qualityText(item.data_quality))));
const sourceLabels = Array.from(new Set(allCards.map((item) => item.odds_source_label || sourceKindText(item)))).slice(0, 4);
return {
total: allCards.length,
liveCount,
preMarketCount,
referenceCount,
conditionalCount,
qualities: qualities.length ? qualities.join('、') : '尚無推薦',
sourceLabels: sourceLabels.length ? sourceLabels.join('、') : '尚無盤口來源',
refreshSeconds: selectedCard?.auto_refresh_seconds ?? 60,
};
}, [selectedCard, tabCards.all]);
useEffect(() => {
const load = async () => {
@@ -59,78 +274,396 @@ export default function DailyCardPage() {
setError('');
try {
const response = await getDailyCard(targetDate);
setData(response);
const calendar = await getDailyCardCalendar(TOURNAMENT_START_DATE);
const groupedDates = calendar.dates.map((item) => item.date).sort((a, b) => a.localeCompare(b));
const pinnedDates = [sampleDate, tomorrowSampleDate].filter((date) => groupedDates.includes(date));
const pinnedSet = new Set(pinnedDates);
const allDates = [...pinnedDates, ...groupedDates.filter((date) => !pinnedSet.has(date))];
const preferredDate = calendar.dates.find((item) => pinnedDates.includes(item.date) && item.recommendation_count > 0)?.date
?? calendar.dates.find((item) => item.date >= sampleDate && item.recommendation_count > 0)?.date
?? pinnedDates[0]
?? allDates.find((date) => date >= sampleDate)
?? allDates[0]
?? sampleDate;
setCalendarDates(calendar.dates);
setSelectedDate((current) => {
if (allDates.includes(current)) return current;
return preferredDate;
});
} catch (payloadError) {
setError(payloadError instanceof Error ? payloadError.message : '每日作戰卡暫時無法抓取');
setError(payloadError instanceof Error ? payloadError.message : '無法取得日期推薦摘要資料');
} finally {
setLoading(false);
}
};
load().catch(() => undefined);
}, [targetDate]);
const interval = window.setInterval(() => {
load().catch(() => undefined);
}, 60_000);
return () => {
window.clearInterval(interval);
};
}, []);
useEffect(() => {
if (!selectedDate) return undefined;
let mounted = true;
const loadSelectedCard = async () => {
setLoading(true);
setError('');
try {
const card = await getDailyCard(selectedDate);
if (!mounted) return;
setDailyCards((current) => ({ ...current, [selectedDate]: card }));
} catch (payloadError) {
if (!mounted) return;
setError(payloadError instanceof Error ? payloadError.message : '無法取得該日期推薦卡片');
} finally {
if (mounted) setLoading(false);
}
};
loadSelectedCard().catch(() => undefined);
const interval = window.setInterval(() => {
loadSelectedCard().catch(() => undefined);
}, 60_000);
return () => {
mounted = false;
window.clearInterval(interval);
};
}, [selectedDate]);
useEffect(() => {
try {
const raw = window.localStorage.getItem(WATCHLIST_KEY);
if (!raw) {
return;
}
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) {
const normalized = parsed.filter(Boolean).slice(0, 20);
setWatchlist(normalized);
setSelectedCount(normalized.length);
window.localStorage.setItem(WATCHLIST_KEY, JSON.stringify(normalized));
}
} catch {
window.localStorage.removeItem(WATCHLIST_KEY);
}
}, []);
const watchlistKeys = useMemo(() => new Set(watchlist.map(pickKey)), [watchlist]);
function handleAddToSlip(item: DailyCardItem) {
setSelectedCount((count) => count + 1);
// 未建立後續注單 API先保留為前端選單暫存
window.alert(`已加入注單追蹤:${item.match_label} ${item.selection}`);
const key = pickKey(item);
const exists = watchlist.some((existing) => pickKey(existing) === key);
if (exists) {
setFeedbackMessage(`這張已在監控清單中:${item.match_label} ${item.selection}`);
return;
}
const next = [item, ...watchlist].slice(0, 20);
setWatchlist(next);
setSelectedCount(next.length);
setFeedbackMessage(`已加入${isConditionalPick(item) ? '賠率監控清單' : '下注前檢查清單'}${item.match_label} ${item.selection}`);
try {
window.localStorage.setItem(WATCHLIST_KEY, JSON.stringify(next));
} catch {
setFeedbackMessage(`已加入本頁清單,但瀏覽器暫時無法永久保存:${item.match_label} ${item.selection}`);
}
}
function handleRemoveWatchItem(item: DailyCardItem) {
const next = watchlist.filter((existing) => pickKey(existing) !== pickKey(item));
setWatchlist(next);
setSelectedCount(next.length);
try {
window.localStorage.setItem(WATCHLIST_KEY, JSON.stringify(next));
} catch {
setFeedbackMessage('已從本頁清單移除;瀏覽器永久保存狀態稍後會在重新整理後同步。');
}
}
function handleClearWatchlist() {
setWatchlist([]);
setSelectedCount(0);
try {
window.localStorage.removeItem(WATCHLIST_KEY);
} catch {
setFeedbackMessage('已清空本頁清單;瀏覽器永久保存狀態稍後會在重新整理後同步。');
}
}
return (
<div className="space-y-4">
<section className="panel-glow rounded-2xl p-5">
<h2 className="dot-matrix text-2xl text-[#7d2a15]"></h2>
<p className="mt-1 text-xs text-[#8a6b58]">{data ? data.total_daily_unit_recommendation.toFixed(2) : '-'}</p>
<p className="mt-2 text-sm text-[#7a5b46]">{targetDate}</p>
<p className="mt-2 text-sm text-[#6f4f3c]">{briefing}</p>
<p className="mt-2 text-xs text-[#8c2f2f]">{selectedCount} </p>
<div className="space-y-6">
<section className="panel-glow rounded-2xl p-6">
<h2 className="dot-matrix text-2xl font-bold text-[#7d2a15]"></h2>
<p className="mt-2 text-sm leading-6 text-[#7a5b46]">
</p>
<div className="mt-4 grid gap-3 md:grid-cols-4">
{[
{ href: '/source-health', step: '1', title: '先看資料健康', detail: '確認賽程、比分、新聞與盤口是否新鮮。' },
{ href: '/recommendation-readiness', step: '2', title: '再看推薦閘門', detail: '確認現在能不能用正式推薦語氣。' },
{ href: '/market-coverage', step: '3', title: '檢查盤口覆蓋', detail: '確認玩法是否真的有盤、有來源。' },
{ href: '/daily-card', step: '4', title: '最後挑候選', detail: '只挑賠率達標且注碼可控的卡片。' },
].map((item) => (
<Link key={item.step} href={item.href} className="rounded-2xl border border-[#eadcb9] bg-white/70 p-4 transition hover:border-[#b83822]">
<div className="flex gap-3">
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-[#7d2a15] text-sm font-black text-white">{item.step}</span>
<div>
<p className="font-bold text-[#3f2f25]">{item.title}</p>
<p className="mt-1 text-xs leading-5 text-[#7a5b46]">{item.detail}</p>
</div>
</div>
</Link>
))}
</div>
<div className="mt-5 rounded-2xl border border-[#eadcb9] bg-white/70 p-4">
<div className="flex flex-col gap-2 md:flex-row md:items-end md:justify-between">
<div>
<p className="dot-matrix text-sm font-bold text-[#7d2a15]"></p>
<p className="mt-1 text-sm leading-6 text-[#7a5b46]">
0
</p>
</div>
<p className="text-xs font-bold text-[#8a6b58]"> / </p>
</div>
<div className="mt-4 flex flex-wrap gap-2">
{dateSummaries.map((item) => {
const isPinned = item.date === sampleDate || item.date === tomorrowSampleDate;
const missingSnapshot = item.marketDataStatus === 'snapshot_missing_after_kickoff';
return (
<button
key={item.date}
type="button"
onClick={() => {
setSelectedDate(item.date);
setActiveTab('all');
}}
className={`rounded-full border px-4 py-2 text-left text-xs font-black transition ${
selectedDate === item.date
? 'border-[#7d2a15] bg-[#7d2a15] text-white shadow-[0_10px_26px_rgba(125,42,21,0.20)]'
: 'border-[#d8b58c] bg-[#fff8e6] text-[#5f4330] hover:border-[#b83822]'
}`}
>
<span>{item.date}</span>
{isPinned ? <span className="ml-1 opacity-80">{item.date === sampleDate ? '今天' : '明天'}</span> : null}
<span className="ml-2 opacity-90">{item.matchCount} / {item.recommendationCount} </span>
{missingSnapshot ? <span className="ml-2 rounded-full bg-white/25 px-2 py-0.5"></span> : null}
{!missingSnapshot && item.snapshotStatus === 'saved' ? <span className="ml-2 rounded-full bg-white/25 px-2 py-0.5"></span> : null}
{!missingSnapshot && item.snapshotStatus === 'not_saved_yet' ? <span className="ml-2 rounded-full bg-white/25 px-2 py-0.5"></span> : null}
{!missingSnapshot && item.recommendationCount > 0 && item.liveCount <= 0 ? <span className="ml-2 rounded-full bg-white/25 px-2 py-0.5"></span> : null}
{item.liveCount > 0 ? <span className="ml-2 rounded-full bg-white/25 px-2 py-0.5">{item.liveCount} </span> : null}
</button>
);
})}
</div>
</div>
<div className="mt-4 flex items-center justify-between">
<p className="text-sm text-[#7a5b46]">{selectedDate} {selectedMatchCount} {tabCards.all.length} </p>
<p className="text-sm font-semibold text-[#167a47]">
{selectedCard ? formatTwd(dailyAmountTwd(selectedCard)) : '-'}
</p>
</div>
<div className="mt-4 rounded-xl border border-[#eadcb9] bg-white/70 p-4">
<p className="text-sm leading-6 text-[#5f4330]">{briefing}</p>
{selectedCard?.summary ? <p className="mt-3 text-xs leading-5 text-[#7a5b46]">{plainBackendText(selectedCard.summary)}</p> : null}
{selectedCard?.execution_policy ? <p className="mt-3 text-xs leading-5 text-[#8a6400]">{plainBackendText(selectedCard.execution_policy)}</p> : null}
</div>
{selectedMissingSnapshot ? (
<div className="mt-4 rounded-2xl border border-[#e7a49a] bg-[#fff0e8] p-5">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
<p className="dot-matrix text-sm font-black text-[#b83822]"></p>
<h3 className="mt-2 text-xl font-black text-[#7d2a15]"></h3>
<p className="mt-2 text-sm leading-6 text-[#6f4f3c]">
</p>
</div>
<div className="flex flex-col gap-2 sm:flex-row md:flex-col">
{nextAvailableSummary ? (
<button
type="button"
className="rounded-full bg-[#d1432d] px-5 py-3 text-sm font-black text-white transition hover:bg-[#b83822]"
onClick={() => {
setSelectedDate(nextAvailableSummary.date);
setActiveTab('all');
}}
>
{nextAvailableSummary.date} {nextAvailableSummary.recommendationCount}
</button>
) : null}
<Link href="/recommendation-performance" className="rounded-full border border-[#d8b58c] bg-white/75 px-5 py-3 text-center text-sm font-bold text-[#7d2a15] transition hover:border-[#b83822]">
</Link>
</div>
</div>
</div>
) : null}
<div className="mt-4 grid gap-3 md:grid-cols-5">
{[
{ label: '實盤可檢查', value: executionMetrics.liveCount, helper: '可進入下注前檢查' },
{ label: '預掛觀察', value: executionMetrics.preMarketCount, helper: '只加入賠率監控' },
{ label: '台灣盤參考', value: executionMetrics.referenceCount, helper: '可比對最低賠率' },
{ label: '資料品質', value: executionMetrics.qualities, helper: '目前模型來源' },
{ label: '賽前快照', value: snapshotStatusText(selectedSnapshotStatus), helper: selectedSnapshotItemCount ? `已保存 ${selectedSnapshotItemCount}` : '保存狀態' },
].map((item) => (
<article key={item.label} className="rounded-xl border border-[#eadcb9] bg-white/70 p-4">
<p className="text-xs text-[#8a6b58]">{item.helper}</p>
<p className="mt-2 text-lg font-black text-[#7d2a15]">{item.value}</p>
<p className="mt-1 text-xs text-[#7a5b46]">{item.label}</p>
</article>
))}
</div>
<div className="mt-4 rounded-xl border border-[#eadcb9] bg-white/70 p-4">
<p className="dot-matrix text-sm font-bold text-[#7d2a15]"></p>
<p className="mt-2 text-sm leading-6 text-[#5f4330]">
{executionMetrics.sourceLabels}
</p>
</div>
<div className="mt-4 grid gap-3 md:grid-cols-3">
<article className="rounded-xl border border-[#e7c89b] bg-[#fff8e6] p-4">
<p className="dot-matrix text-sm font-bold text-[#7d2a15]"></p>
<p className="mt-2 text-sm leading-6 text-[#5f4330]">
</p>
</article>
<article className="rounded-xl border border-[#e7c89b] bg-[#fff8e6] p-4">
<p className="dot-matrix text-sm font-bold text-[#7d2a15]">AI </p>
<p className="mt-2 text-sm leading-6 text-[#5f4330]">
AI
</p>
</article>
<article className="rounded-xl border border-[#e7c89b] bg-[#fff8e6] p-4">
<p className="dot-matrix text-sm font-bold text-[#7d2a15]"></p>
<p className="mt-2 text-sm leading-6 text-[#5f4330]">
</p>
</article>
</div>
<div className="mt-4 flex items-center gap-2">
<div className={`status-led ${watchlist.length > 0 ? 'status-led-ok' : 'status-led-warn'}`} />
<p className="text-xs text-[#7a5b46]">{watchlist.length || selectedCount} </p>
</div>
{feedbackMessage ? (
<p role="status" className="mt-3 rounded-xl border border-[#9bd8b0] bg-[#e9f8ef] p-3 text-sm font-bold text-[#167a47]">
{feedbackMessage}
</p>
) : null}
</section>
<section className="panel-glow rounded-2xl p-4">
<p className="dot-matrix text-lg text-[#7d2a15]">Hard Filters</p>
<div className="mt-2 flex flex-wrap gap-2">
<section className="panel-glow rounded-2xl p-5">
<p className="dot-matrix text-lg font-bold text-[#7d2a15]"></p>
<p className="mt-2 text-sm text-[#7a5b46]"></p>
<div className="mt-3 flex flex-wrap gap-2">
<button
type="button"
className={`rounded-full px-3 py-2 text-sm ${
activeTab === 'safe' ? 'bg-[#7d2a15] text-white' : 'bg-white/70 text-[#5f4330]'
}`}
className={tabButtonClass(activeTab === 'all')}
onClick={() => setActiveTab('all')}
>
</button>
<button
type="button"
className={tabButtonClass(activeTab === 'safe')}
onClick={() => setActiveTab('safe')}
>
Safe Singles
</button>
<button
type="button"
className={`rounded-full px-3 py-2 text-sm ${
activeTab === 'risk' ? 'bg-[#7d2a15] text-white' : 'bg-white/70 text-[#5f4330]'
}`}
className={tabButtonClass(activeTab === 'risk')}
onClick={() => setActiveTab('risk')}
>
High-Risk Underdog
</button>
<button
type="button"
className={`rounded-full px-3 py-2 text-sm ${
activeTab === 'parlay' ? 'bg-[#7d2a15] text-white' : 'bg-white/70 text-[#5f4330]'
}`}
className={tabButtonClass(activeTab === 'parlay')}
onClick={() => setActiveTab('parlay')}
>
Safe Parlays
</button>
<button
type="button"
className={`rounded-full px-3 py-2 text-sm ${
activeTab === 'sgp' ? 'bg-[#7d2a15] text-white' : 'bg-white/70 text-[#5f4330]'
}`}
className={tabButtonClass(activeTab === 'sgp')}
onClick={() => setActiveTab('sgp')}
>
SGP
</button>
</div>
</section>
{loading ? <p className="text-sm text-[#8a6b58]">...</p> : null}
{error ? <p className="text-sm text-[#8c2f2f]">{error}</p> : null}
<section className="panel-glow rounded-2xl p-5">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<p className="dot-matrix text-lg font-bold text-[#7d2a15]"></p>
<p className="mt-2 text-sm leading-6 text-[#7a5b46]">
</p>
</div>
{watchlist.length > 0 ? (
<button
type="button"
className="rounded-full border border-[#d8b58c] bg-white/75 px-4 py-2 text-sm font-bold text-[#7d2a15] transition hover:border-[#b83822] hover:text-[#b83822]"
onClick={handleClearWatchlist}
>
</button>
) : null}
</div>
{watchlist.length ? (
<div className="mt-4 grid gap-3 md:grid-cols-2">
{watchlist.map((item) => (
<article key={pickKey(item)} className="rounded-2xl border border-[#eadcb9] bg-white/70 p-4">
<div className="flex flex-wrap items-center gap-2">
<span className={`rounded-full px-3 py-1 text-xs font-bold ${
isConditionalPick(item) ? 'bg-[#fff7d6] text-[#8a6400]' : 'bg-[#e9f8ef] text-[#167a47]'
}`}>
{isConditionalPick(item) ? '等待賠率達標' : '實盤檢查'}
</span>
<span className="rounded-full bg-[#fff0e2] px-3 py-1 text-xs text-[#7d2a15]">{recommendationText(item)}</span>
<span className="rounded-full border border-[#d8b58c] bg-white px-3 py-1 text-xs font-bold text-[#5f4330]">
{item.odds_source_label || sourceKindText(item)}
</span>
</div>
<p className="mt-3 text-base font-black text-[#3f2f25]">{item.match_label}</p>
<p className="mt-1 text-sm text-[#7a5b46]">{item.market_type}{item.selection}</p>
<p className="mt-2 rounded-xl bg-[#fff8e6] p-3 text-xs leading-5 text-[#7a5b46]">{sourceExplainText(item)}</p>
<div className="mt-3 grid gap-2 text-sm text-[#5f4330] md:grid-cols-4">
<p> <span className="font-bold text-[#b83822]">{item.target_odds.toFixed(2)}</span></p>
<p> <span className="font-bold text-[#167a47]">{item.win_prob.toFixed(2)}%</span></p>
<p> <span className="font-bold text-[#167a47]">{typeof item.confidence_score === 'number' ? item.confidence_score.toFixed(1) : '-'}</span></p>
<p> <span className="font-bold text-[#8a6400]">{formatTwd(stakeAmountTwd(item))}</span></p>
</div>
<button
type="button"
className="mt-3 text-xs font-semibold text-[#7a5b46] transition hover:text-[#b83822]"
onClick={() => handleRemoveWatchItem(item)}
>
</button>
</article>
))}
</div>
) : (
<div className="mt-4 rounded-2xl border border-dashed border-[#d8b58c] bg-white/55 p-5 text-sm leading-6 text-[#7a5b46]">
</div>
)}
</section>
{loading ? <p className="text-sm text-[#7a5b46] dot-matrix dot-matrix-loading">...</p> : null}
{error ? <p className="text-sm text-red-400">{error}</p> : null}
<section className="grid gap-4 md:grid-cols-2">
{tabCards[activeTab].map((item) => (
@@ -138,12 +671,19 @@ export default function DailyCardPage() {
key={`${item.match_id}-${item.selection}-${item.market_type}`}
item={item}
onAddToSlip={handleAddToSlip}
isInSlip={watchlistKeys.has(pickKey(item))}
className="panel-glow"
/>
))}
{!loading && tabCards[activeTab].length === 0 ? (
<p className="panel-glow rounded-2xl p-4 text-sm text-[#7a5b46]"></p>
<div className="panel-glow rounded-2xl p-6 text-center border-dashed">
<p className="text-sm text-[#7a5b46]">
{selectedMissingSnapshot
? '此日期缺少賽前快照,因此不顯示事後補造的投注推薦。請切到下一個有候選的日期,或查看賽後校準。'
: '這個分類目前沒有達標候選。沒有足夠資料或賠率不漂亮時,系統不會硬湊推薦。'}
</p>
</div>
) : null}
</section>
</div>

View File

@@ -1,25 +1,38 @@
import { QuickBetButton } from '@/components/QuickBetButton';
import Link from 'next/link';
export default function DeepBetPage() {
const matchId = 'FIFA2026-FR-PA03';
const selection = '德國勝';
return (
<div className="space-y-4">
<h2 className="dot-matrix text-2xl text-[#7d2a15]">Deep Linking</h2>
<section className="panel-glow rounded-2xl p-5">
<p className="text-sm text-[#7a5b46]">
Affiliate
<div className="space-y-5">
<section className="panel-glow rounded-2xl p-6">
<p className="dot-matrix text-xs font-bold text-[#b83822]"> / </p>
<h2 className="mt-2 text-3xl font-black text-[#3f2f25]"></h2>
<p className="mt-3 text-sm leading-7 text-[#7a5b46]">
使使
</p>
</section>
<section className="grid gap-4 md:grid-cols-3">
{[
['目前狀態', '等待授權連結來源', '尚未接入合法且可驗證的一鍵帶入連結。'],
['安全原則', '不預填假賠率', '必須同一場、同一玩法、同一選項、同一分線。'],
['替代流程', '先手動核對卡片', '每日作戰室會告訴你最低賠率與參考上限。'],
].map(([title, value, detail]) => (
<article key={title} className="panel-glow rounded-2xl p-5">
<p className="text-xs font-bold text-[#8a6b58]">{title}</p>
<p className="mt-2 text-lg font-black text-[#7d2a15]">{value}</p>
<p className="mt-2 text-sm leading-6 text-[#7a5b46]">{detail}</p>
</article>
))}
</section>
<section className="panel-glow rounded-2xl p-5">
<h3 className="dot-matrix text-lg text-[#7d2a15]"></h3>
<p className="mt-2 text-sm text-[#7a5b46]">{matchId}{selection}</p>
<h3 className="dot-matrix text-lg text-[#7d2a15]"></h3>
<p className="mt-2 text-sm leading-6 text-[#7a5b46]">
</p>
<div className="mt-4 flex flex-wrap gap-3">
<QuickBetButton bookmakerId="bet365" matchId={matchId} selection={selection} odds={1.92} />
<QuickBetButton bookmakerId="pinnacle" matchId={matchId} selection={selection} odds={1.94} />
<QuickBetButton bookmakerId="draftkings" matchId={matchId} selection={selection} odds={1.96} />
<Link href="/daily-card" className="rounded-full bg-[#7d2a15] px-4 py-2 text-sm font-bold text-white"></Link>
<Link href="/recommendation-readiness" className="rounded-full border border-[#d8b58c] bg-white/75 px-4 py-2 text-sm font-bold text-[#7d2a15]"></Link>
</div>
</section>
</div>

View File

@@ -1,12 +1,20 @@
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+TC:wght@400;600;700;900&family=VT323&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Noto+Sans+TC:wght@400;500;700;900&family=JetBrains+Mono:wght@400;700&display=swap');
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--font-body: 'Noto Sans TC';
--font-matrix: 'VT323';
--warm-bg: #f6f0e1;
--font-body: 'Inter', 'Noto Sans TC', sans-serif;
--font-matrix: 'JetBrains Mono', monospace;
--bg-color: #0B0E14;
--text-color: #E2E8F0;
--accent-color: #b83822;
--accent-glow: rgba(184, 56, 34, 0.28);
--surface-cream: #fff8e6;
--surface-cream-soft: #f6f0e1;
--surface-border: #e7c89b;
--text-coffee: #3f2f25;
--text-muted-coffee: #7a5b46;
}
html,
@@ -14,78 +22,127 @@ body {
margin: 0;
padding: 0;
min-height: 100%;
background:
radial-gradient(circle at 20% 20%, rgba(255, 255, 255, 0.55), transparent 35%),
radial-gradient(circle at 80% 15%, rgba(209, 67, 45, 0.12), transparent 35%),
#f6f0e1;
color: #2a2018;
background-color: var(--bg-color);
background-image:
radial-gradient(circle at 18% 20%, rgba(184, 56, 34, 0.10), transparent 24%),
radial-gradient(circle at 82% 28%, rgba(231, 200, 155, 0.10), transparent 26%),
linear-gradient(180deg, #080a10 0%, #111827 56%, #0b0e14 100%);
color: var(--text-color);
font-family: var(--font-body);
}
* {
box-sizing: border-box;
}
body {
font-family: var(--font-body), Arial, sans-serif;
}
.dashboard-shell {
min-height: 100vh;
background: linear-gradient(140deg, rgba(255, 255, 255, 0.65), rgba(246, 240, 225, 0.85));
position: relative;
z-index: 1;
}
/* Unified warm research-card surface */
.panel-glow {
border: 1px solid rgba(255, 255, 255, 0.5);
background: linear-gradient(170deg, rgba(255, 248, 230, 0.86), rgba(246, 240, 225, 0.88));
box-shadow: 0 20px 45px rgba(95, 53, 44, 0.1);
background: rgba(255, 248, 230, 0.94);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
border: 1px solid var(--surface-border);
box-shadow: 0 18px 50px rgba(98, 58, 34, 0.16);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.panel-glow:hover {
border-color: rgba(184, 56, 34, 0.38);
box-shadow: 0 22px 58px rgba(98, 58, 34, 0.20);
transform: translateY(-2px);
}
.panel-glow .text-slate-50,
.panel-glow .text-slate-100,
.panel-glow .text-slate-200,
.panel-glow .text-cyan-200,
.panel-glow .text-cyan-300,
.panel-glow .text-cyan-400 {
color: var(--text-coffee);
}
.panel-glow .text-slate-300,
.panel-glow .text-slate-400,
.panel-glow .text-slate-500,
.panel-glow .text-slate-600 {
color: var(--text-muted-coffee);
}
.panel-glow .bg-slate-950\/50,
.panel-glow .bg-slate-950\/60,
.panel-glow .bg-slate-900\/60,
.panel-glow .bg-slate-800 {
background-color: rgba(255, 255, 255, 0.66);
}
.panel-glow .border-slate-700,
.panel-glow .border-slate-800 {
border-color: #eadcb9;
}
.dot-matrix {
font-family: var(--font-matrix), monospace;
letter-spacing: 0.09em;
font-family: var(--font-matrix);
letter-spacing: 0.05em;
text-transform: uppercase;
}
/* Status LEDs */
.status-led {
width: 0.6rem;
height: 0.6rem;
border-radius: 9999px;
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
box-shadow: 0 0 8px currentColor;
}
.status-led-ok { background: #1a9a57; }
.status-led-warn { background: #dcb53b; }
.status-led-danger { background: #d1432d; }
.status-led-ok { background: #10B981; color: #10B981; }
.status-led-warn { background: #F59E0B; color: #F59E0B; }
.status-led-danger { background: #EF4444; color: #EF4444; }
/* Edge Glow Animation */
@keyframes top-edge-glow {
0% {
box-shadow: 0 0 0 rgba(235, 90, 40, 0.0), 0 0 0 rgba(235, 90, 40, 0.0);
border-color: rgba(188, 68, 43, 0.7);
box-shadow: 0 0 0 rgba(0, 240, 255, 0);
border-color: rgba(255, 255, 255, 0.08);
}
50% {
box-shadow: 0 0 18px rgba(235, 90, 40, 0.35), 0 0 36px rgba(235, 90, 40, 0.2);
border-color: rgba(235, 90, 40, 1);
box-shadow: 0 0 20px rgba(184, 56, 34, 0.28);
border-color: #b83822;
}
100% {
box-shadow: 0 0 0 rgba(235, 90, 40, 0), 0 0 0 rgba(235, 90, 40, 0);
border-color: rgba(188, 68, 43, 0.9);
box-shadow: 0 0 0 rgba(0, 240, 255, 0);
border-color: rgba(255, 255, 255, 0.08);
}
}
.prop-top-edge {
animation: top-edge-glow 1.8s ease-in-out infinite;
animation: top-edge-glow 2s ease-in-out infinite;
}
@keyframes dot-matrix-pulse {
0%, 100% {
opacity: 0.35;
transform: translateY(0);
}
50% {
opacity: 1;
transform: translateY(-2px);
}
0%, 100% { opacity: 0.4; }
50% { opacity: 1; text-shadow: 0 0 8px var(--accent-color); }
}
.dot-matrix-loading {
animation: dot-matrix-pulse 1s steps(1) infinite;
animation: dot-matrix-pulse 1.5s ease-in-out infinite;
}
/* Custom Scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track { background: rgba(246, 240, 225, 0.45); }
::-webkit-scrollbar-thumb {
background: rgba(184, 56, 34, 0.32);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(184, 56, 34, 0.55);
}

View File

@@ -3,12 +3,15 @@ import { BetSizingSlider } from '@/components/BetSizingSlider';
export default function KellyPage() {
return (
<div className="space-y-4">
<h2 className="dot-matrix text-2xl text-[#7d2a15]">Kelly Criterion</h2>
<h2 className="dot-matrix text-2xl text-[#7d2a15]"></h2>
<section className="panel-glow rounded-2xl p-5">
<p className="text-sm text-[#7a5b46]">
0.25x0.5x0.75x
調
</p>
<p className="mt-3 text-xs leading-5 text-[#8a6b58]">
</p>
</section>
<BetSizingSlider />
</div>

View File

@@ -1,35 +1,20 @@
import Link from 'next/link';
import './globals.css';
import type { ReactNode } from 'react';
import type { Metadata } from 'next';
import { HeaderNav } from '@/components/HeaderNav';
import { MobileBottomNav } from '@/components/MobileBottomNav';
import { PwaBootstrap } from '@/components/PwaBootstrap';
const navItems = [
{ href: '/', label: '主控總覽' },
{ href: '/matches', label: '賽事中心' },
{ href: '/odds', label: '跨平台賠率比較' },
{ href: '/sharp-money', label: '聰明錢追蹤' },
{ href: '/ml-edge', label: 'ML Ensemble' },
{ href: '/models', label: '量化模型' },
{ href: '/match-conditions', label: '裁判/天候模型' },
{ href: '/rlm', label: 'RLM 反向盤口' },
{ href: '/proof-of-yield', label: '公開收益帳本' },
{ href: '/props', label: '球員道具盤' },
{ href: '/kelly', label: '凱利下注' },
{ href: '/backtesting', label: '策略回測' },
{ href: '/deep-bet', label: '一鍵投注' },
{ href: '/daily-card', label: '每日作戰室' },
{ href: '/portfolio', label: '個人組合' },
];
export const viewport = {
themeColor: '#0B0E14',
};
export const metadata: Metadata = {
title: '2026 FIFA 專業投注研究中心',
description: '以台北時區驅動的全站即時投注資料與量化分析平台',
title: '2026 世界盃專業量化投注研究中心',
description: '以台北時區驅動的世界盃即時賽程、新聞、賠率與量化投注研究平台',
manifest: '/manifest.json',
themeColor: '#f6f0e1',
other: {
'application-name': '2026 World Cup Quantum Ops',
'application-name': '2026 世界盃專業量化投注研究中心',
},
};
@@ -40,23 +25,7 @@ export default function RootLayout({ children }: { children: ReactNode }) {
<PwaBootstrap />
<div className="dashboard-shell min-h-screen p-4 md:p-8">
<header className="mx-auto max-w-7xl rounded-2xl panel-glow px-5 py-4 md:px-8 md:py-5">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
<h1 className="dot-matrix text-2xl md:text-3xl text-[#7e2417]">2026 World Cup Quantum Ops</h1>
<p className="text-sm text-[#6d4d39]">UTC+8 | </p>
</div>
<nav className="flex flex-wrap gap-2">
{navItems.map((item) => (
<Link
key={item.href}
href={item.href}
className="rounded-full border border-[#b68a65]/70 bg-white/70 px-3 py-1.5 text-sm text-[#5f4330] transition hover:bg-white"
>
{item.label}
</Link>
))}
</nav>
</div>
<HeaderNav />
</header>
<main className="mx-auto mt-6 mb-24 max-w-7xl">{children}</main>
</div>

View File

@@ -49,6 +49,11 @@ export default function MatchConditionsPage() {
return (
<div className="space-y-4">
<h2 className="dot-matrix text-2xl text-[#7d2a15]"></h2>
<section className="panel-glow rounded-2xl border-dashed p-4">
<p className="text-sm leading-6 text-[#7a5b46]">
</p>
</section>
<section className="panel-glow rounded-2xl p-4">
<h3 className="dot-matrix text-lg text-[#7d2a15]"></h3>
<div className="mt-3 grid gap-3 md:grid-cols-2 lg:grid-cols-3">
@@ -156,4 +161,3 @@ export default function MatchConditionsPage() {
</div>
);
}

View File

@@ -11,9 +11,11 @@ import type {
MatchOddsPoint,
MatchPoisson,
} from '@/lib/analytics-api';
import { matchStatusKind, matchStatusLabel } from '@/lib/match-order';
import { formatToTaipeiTime } from '@/lib/timezone';
export const revalidate = 60;
export const dynamic = 'force-dynamic';
export const revalidate = 0;
const ANALYTICS_BACKEND = process.env.ANALYTICS_BACKEND_URL || 'http://127.0.0.1:8000';
const OPENAI_API_KEY = process.env.OPENAI_API_KEY;
@@ -22,8 +24,7 @@ async function fetchMatchList(): Promise<MatchListItem[]> {
try {
const response = await fetch(`${ANALYTICS_BACKEND}/analytics/matches`, {
headers: { 'Content-Type': 'application/json' },
cache: 'force-cache',
next: { revalidate: 60 },
cache: 'no-store',
});
if (!response.ok) {
@@ -38,10 +39,9 @@ async function fetchMatchList(): Promise<MatchListItem[]> {
async function fetchMatchDetail(matchId: string): Promise<MatchDetail | null> {
try {
const response = await fetch(`${ANALYTICS_BACKEND}/analytics/matches/${matchId}`, {
const response = await fetch(`${ANALYTICS_BACKEND}/analytics/matches/${encodeURIComponent(matchId)}`, {
headers: { 'Content-Type': 'application/json' },
cache: 'force-cache',
next: { revalidate: 60 },
cache: 'no-store',
});
if (!response.ok) {
@@ -64,7 +64,7 @@ function fallbackSummary(detail: MatchDetail): string {
return `【量化總結】
${home} vs ${away} 的 1x2 機率分佈顯示主勝 ${homeWin}%,平局 ${draw}%,客勝 ${awayWin}%。
Poisson 預測給出主隊 ${poisson.expected_home_goals.toFixed(2)} / 客隊 ${poisson.expected_away_goals.toFixed(2)} 的進球走勢
進球分布模型預測主隊 ${poisson.expected_home_goals.toFixed(2)} 球、客隊 ${poisson.expected_away_goals.toFixed(2)}
賽事在 2.5 球上下行為上Over 機率 ${overProb}%Under 機率 ${underProb}%。
條件面顯示裁判嚴厲度 ${conditions.strictness_index.toFixed(1)},熱指數 ${conditions.heat_index.toFixed(1)}
若出現高張力紅黃牌壓力,建議觀察 1x2 讓盤與 Cards/Under 結構,避免逆風追買。`;
@@ -80,8 +80,8 @@ async function buildQuantSummaryWithLLM(detail: MatchDetail): Promise<string> {
try {
const prompt = `請以中文繁體為台灣使用者生成 300 字的世界盃賽前量化總結,嚴格使用專業投注語氣,不超過 420 字。
請使用以下數據:
- 主場 ${detail.home_team}xG ${detail.home_xg.toFixed(2)}
- 客場 ${detail.away_team}xG ${detail.away_xg.toFixed(2)}
- 主場 ${detail.home_team}預期進球 ${detail.home_xg.toFixed(2)}
- 客場 ${detail.away_team}預期進球 ${detail.away_xg.toFixed(2)}
- 1x2 機率: 主勝 ${detail.poisson.one_x_two.home_win.toFixed(4)}、平 ${detail.poisson.one_x_two.draw.toFixed(4)}、客勝 ${detail.poisson.one_x_two.away_win.toFixed(4)}
- Over/Under(2.5): ${(detail.poisson.over_under_2_5.over * 100).toFixed(1)} / ${(detail.poisson.over_under_2_5.under * 100).toFixed(1)}
- 裁判嚴厲度 ${detail.conditions.strictness_index.toFixed(1)}Heat Index ${detail.conditions.heat_index.toFixed(1)},下半場主/客攻擊 ${detail.conditions.second_half_home_attack.toFixed(2)} / ${detail.conditions.second_half_away_attack.toFixed(2)}`;
@@ -138,7 +138,7 @@ export async function generateMetadata({ params }: { params: Promise<Params> }):
: `2026 世界盃賽事預測`;
const description = match
? `量化模型基於 xG、裁判與天候模型產出${match.home_team} vs ${match.away_team}完整賠率走勢與下注偏差訊號,含 2.5 球與波膽機率。`
? `模型根據預期進球、裁判尺度與天候條件,整理${match.home_team} vs ${match.away_team}賠率走勢與可能偏差,包含大小 2.5 球與比分機率。`
: '2026 世界盃賽事量化預測、聰明錢流向與賠率走勢頁。';
return {
@@ -149,7 +149,7 @@ export async function generateMetadata({ params }: { params: Promise<Params> }):
title,
description,
locale: 'zh_TW',
siteName: '2026 FIFA Quantum Ops',
siteName: '2026 世界盃量化投注研究中心',
},
};
}
@@ -210,22 +210,22 @@ function buildDatasetJsonLd(detail: MatchDetail) {
'@context': 'https://schema.org',
'@type': 'Dataset',
name: `${detail.home_team} vs ${detail.away_team} 量化推論資料集`,
description: `2026世界盃賽事模型資料。包含主客 xG、賠率時序、裁判與熱指數、波膽機率。`,
description: `2026世界盃賽事模型資料。包含主客預期進球、賠率走勢、裁判尺度、熱指數與比分機率。`,
creator: {
'@type': 'Organization',
name: '2026 FIFA Quantum Ops',
name: '2026 世界盃量化投注研究中心',
},
license: 'Proprietary',
temporalCoverage: detail.match_time_utc,
variableMeasured: [
{
'@type': 'PropertyValue',
name: 'xG',
name: '預期進球',
value: `${detail.home_xg.toFixed(2)} / ${detail.away_xg.toFixed(2)}`,
},
{
'@type': 'PropertyValue',
name: 'Poisson 1X2',
name: '勝平負機率',
value: JSON.stringify(detail.poisson.one_x_two),
},
{
@@ -249,7 +249,14 @@ export default async function MatchDetailPage({ params }: { params: Promise<Para
const oddsRows = buildOddsChartRows(detail.odds_series);
const scoreRows = buildScoreMatrixRows(detail.poisson.score_matrix);
const kickoffLocal = formatToTaipeiTime(detail.match_time_utc, 'yyyy-MM-dd HH:mm:ss');
const isFinished = detail.status === 'finished';
const statusItem = {
kickoff_utc: detail.match_time_utc,
status: detail.status,
home_score: (detail as { home_score?: number | null }).home_score ?? null,
away_score: (detail as { away_score?: number | null }).away_score ?? null,
};
const statusLabel = matchStatusLabel(statusItem);
const isFinished = matchStatusKind(statusItem) === 'finished';
const conditions: MatchConditionsReadout = detail.conditions;
@@ -274,11 +281,11 @@ export default async function MatchDetailPage({ params }: { params: Promise<Para
{kickoffLocal}{detail.venue_name}
{detail.venue_altitude_meters ? `(海拔 ${detail.venue_altitude_meters}m` : null}
</p>
<p className="mt-1 text-xs text-[#8a6b58]">{detail.status.toUpperCase()}</p>
<p className="mt-1 text-xs text-[#8a6b58]">{statusLabel}</p>
<div className="mt-3 grid gap-3 sm:grid-cols-2">
<article className="rounded-xl border border-[#e8cead] bg-white/70 p-3">
<p className="text-xs text-[#7d4d39]"> xG</p>
<p className="text-xs text-[#7d4d39]"></p>
<p className="dot-matrix text-2xl text-[#7d2a15]">
{detail.home_xg.toFixed(2)} : {detail.away_xg.toFixed(2)}
</p>
@@ -296,7 +303,7 @@ export default async function MatchDetailPage({ params }: { params: Promise<Para
<section className="grid gap-4 lg:grid-cols-2">
<div>
<OddsLineMovementChart data={oddsRows} />
<OddsLineMovementChart data={oddsRows} teamName={detail.home_team} />
</div>
<MatchConditionsCard
matchId={detail.match_id}
@@ -305,7 +312,7 @@ export default async function MatchDetailPage({ params }: { params: Promise<Para
cardsPressureAlert={conditions.cards_pressure_alert}
secondHalfHomeAttack={conditions.second_half_home_attack}
secondHalfAwayAttack={conditions.second_half_away_attack}
second_half_under_recommendation={conditions.second_half_under_recommendation}
secondHalfUnderRecommendation={conditions.second_half_under_recommendation}
attackerDirection={conditions.attacker_direction}
/>
</section>

View File

@@ -1,39 +1,40 @@
'use client';
import Link from 'next/link';
import { useEffect, useState } from 'react';
import { formatToTaipeiTime } from '@/lib/timezone';
import { LiveMatchCenter } from '@/components/LiveMatchCenter';
import { getAllMatches, type MatchListItem } from '@/lib/analytics-api';
import { matchStatusLabel, sortMatchesForProfessionalDisplay } from '@/lib/match-order';
function scoreLine(match: MatchListItem): string {
if (typeof match.home_score === 'number' && typeof match.away_score === 'number') {
return `${match.home_team} ${match.home_score} - ${match.away_score} ${match.away_team}`;
}
return `${match.home_team} vs ${match.away_team}`;
}
export default function MatchesPage() {
const [matches, setMatches] = useState<MatchListItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
async function loadMatches() {
try {
const data = await getAllMatches();
setMatches(data || []);
setMatches(sortMatchesForProfessionalDisplay(data || []));
setError(null);
} catch (error) {
console.error('Failed to load matches', error);
setError('賽事資料暫時無法同步,系統正在等待資料源回補。請先到資料健康頁確認目前狀態。');
} finally {
setLoading(false);
}
}
loadMatches();
const interval = window.setInterval(loadMatches, 60_000);
return () => window.clearInterval(interval);
}, []);
const liveTimeline = [
{ minute: 18, label: '等待即時事件源接入...' }
];
const xgData = [
{ minute: 15, xgHome: 0.1, xgAway: 0.1 }
];
const zones = [
{ zone: '等待熱區資料', pct: 100 }
];
if (loading) {
return <div className="p-8 text-[#8a6b58] dot-matrix">...</div>;
}
@@ -41,7 +42,7 @@ export default function MatchesPage() {
if (matches.length === 0) {
return (
<div className="p-8 text-[#7d2a15] dot-matrix">
THE_ODDS_API_KEY
{error || '目前沒有可顯示的賽事資料。'}
</div>
);
}
@@ -51,20 +52,32 @@ export default function MatchesPage() {
<h2 className="dot-matrix text-2xl text-[#7d2a15]"></h2>
<section className="grid gap-4">
{matches.map((match) => (
<article key={match.match_id} className="panel-glow rounded-2xl p-5">
<Link
key={match.match_id}
href={`/matches/${encodeURIComponent(match.match_id)}`}
className="panel-glow block rounded-2xl p-5 transition hover:-translate-y-0.5 hover:border-[#c58b63] hover:bg-[#fff8e6]"
>
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<p className="text-lg font-semibold text-[#6b3f2d]">{match.home_team} vs {match.away_team}</p>
<p className="text-sm text-[#8a6b58]">{formatToTaipeiTime(match.kickoff_utc)}</p>
<p className="text-lg font-semibold text-[#6b3f2d]">{scoreLine(match)}</p>
<span className="w-fit rounded-full bg-[#fff4df] px-3 py-1 text-xs font-semibold text-[#7d2a15] ring-1 ring-[#e8cead]">
{matchStatusLabel(match)}
</span>
</div>
<p className="mt-3 text-sm text-[#8a6b58]">{match.status}</p>
<p className="mt-3 text-sm text-[#8a6b58]">{formatToTaipeiTime(match.kickoff_utc)}</p>
<p className="text-sm text-[#7c5340]">
{match.venue_name || '未定'} ({match.venue_city || '未定'})
</p>
</article>
<p className="mt-3 text-xs font-semibold text-[#b83822]"> </p>
</Link>
))}
</section>
<LiveMatchCenter timeline={liveTimeline} xgSeries={xgData} heatZones={zones} />
<section className="panel-glow rounded-2xl border-dashed p-5">
<h3 className="dot-matrix text-xl text-[#7d2a15]"></h3>
<p className="mt-2 text-sm leading-6 text-[#7a5b46]">
OptaFIFA
</p>
</section>
</div>
);
}

View File

@@ -86,7 +86,12 @@ export default function MlEdgePage() {
return (
<div className="space-y-4">
<h2 className="dot-matrix text-2xl text-[#7d2a15]">ML Ensemble 15 </h2>
<h2 className="dot-matrix text-2xl text-[#7d2a15]"> 15 </h2>
<section className="panel-glow rounded-2xl border-dashed p-4">
<p className="text-sm leading-6 text-[#7a5b46]">
</p>
</section>
<section className="panel-glow rounded-2xl p-4">
<h3 className="dot-matrix text-lg text-[#7d2a15]"></h3>
<div className="mt-3 grid gap-3 md:grid-cols-2 lg:grid-cols-3">
@@ -143,7 +148,7 @@ export default function MlEdgePage() {
/>
</label>
<label className="text-sm text-[#7a5b46]">
xG
<input
className="mt-2 w-full rounded-lg border border-[#e0c6a8] bg-white/70 px-3 py-2"
type="number"
@@ -153,7 +158,7 @@ export default function MlEdgePage() {
/>
</label>
<label className="text-sm text-[#7a5b46]">
xG
<input
className="mt-2 w-full rounded-lg border border-[#e0c6a8] bg-white/70 px-3 py-2"
type="number"
@@ -201,7 +206,7 @@ export default function MlEdgePage() {
onClick={runEdge}
disabled={loading}
>
{loading ? '分析中…' : '執行 ML Edge 推論'}
{loading ? '分析中…' : '執行 機器學習優勢 推論'}
</button>
<button
className="rounded-lg bg-[#d1432d] px-4 py-2 text-white"
@@ -209,7 +214,7 @@ export default function MlEdgePage() {
onClick={trainModel}
disabled={trainLoading}
>
{trainLoading ? '訓練中…' : '快速訓練演示模型'}
{trainLoading ? '訓練中…' : '快速訓練範例模型'}
</button>
</div>
{errorMessage ? <p className="mt-3 text-xs text-[#8c2f2f]">{errorMessage}</p> : null}
@@ -223,25 +228,25 @@ export default function MlEdgePage() {
<article className="rounded-xl border border-[#e7c89b] bg-white/70 p-4">
<p className="text-sm text-[#7a5b46]"></p>
<p className="mt-1 text-sm text-[#5f4330]"> {edgeResult.model_probs.home.toFixed(2)} / {edgeResult.model_probs.draw.toFixed(2)} / {edgeResult.model_probs.away.toFixed(2)}</p>
<p className="mt-3 text-xs text-[#6f4f3c]">{edgeResult.is_fallback_model ? '規則回退' : 'ML Ensemble'}</p>
<p className="mt-3 text-xs text-[#6f4f3c]">{edgeResult.is_fallback_model ? '規則回退' : '機器學習整合模型'}</p>
</article>
<article className="rounded-xl border border-[#e7c89b] bg-white/70 p-4">
<p className="text-sm text-[#7a5b46]"></p>
<p className="mt-1 text-2xl font-semibold text-[#b83822]">{edgeResult.strongest_outcome}</p>
<p className="text-sm text-[#5f4330]">Edge{edgeResult.strongest_edge_percent.toFixed(2)}%</p>
<p className="mt-2 text-xs text-[#7a5b46]"> Strong Buy{edgeResult.strong_buy ? '是' : '否'}</p>
<p className="text-sm text-[#5f4330]">{edgeResult.strongest_edge_percent.toFixed(2)}%</p>
<p className="mt-2 text-xs text-[#7a5b46]"> {edgeResult.strong_buy ? '是' : '否'}</p>
</article>
<article className="rounded-xl border border-[#e7c89b] bg-white/70 p-4 md:col-span-2">
<p className="text-sm text-[#7a5b46]"> Edge </p>
<p className="text-sm text-[#7a5b46]"> </p>
<ul className="mt-2 space-y-1 text-sm text-[#5f4330]">
<li>{(edgeResult.edges.home.edge * 100).toFixed(2)}% {edgeResult.edges.home.strong_buy ? 'Strong Buy' : ''}</li>
<li>{(edgeResult.edges.draw.edge * 100).toFixed(2)}% {edgeResult.edges.draw.strong_buy ? 'Strong Buy' : ''}</li>
<li>{(edgeResult.edges.away.edge * 100).toFixed(2)}% {edgeResult.edges.away.strong_buy ? 'Strong Buy' : ''}</li>
<li>{(edgeResult.edges.home.edge * 100).toFixed(2)}% {edgeResult.edges.home.strong_buy ? '強烈候選' : ''}</li>
<li>{(edgeResult.edges.draw.edge * 100).toFixed(2)}% {edgeResult.edges.draw.strong_buy ? '強烈候選' : ''}</li>
<li>{(edgeResult.edges.away.edge * 100).toFixed(2)}% {edgeResult.edges.away.strong_buy ? '強烈候選' : ''}</li>
</ul>
</article>
</div>
) : (
<p className="mt-3 text-sm text-[#7a5b46]"> ML Edge </p>
<p className="mt-3 text-sm text-[#7a5b46]"> </p>
)}
</section>
@@ -259,4 +264,3 @@ export default function MlEdgePage() {
</div>
);
}

View File

@@ -1,28 +1,36 @@
import Link from 'next/link';
export default function ModelsPage() {
const modules = [
{ href: '/ml-edge', label: 'ML Ensemble 預測', desc: '自建模型 + ML Edge 比較,與莊家隱含機率對齊' },
{ href: '/match-conditions', label: '裁判/氣候影響模型', desc: '裁判嚴厲度、熱指數、海拔對 2H 下半場模型修正' },
{ href: '/rlm', label: 'RLM 反向盤口', desc: '票數/資金偏移與賠率異常走勢即時警示' },
{ href: '/proof-of-yield', label: 'Proof of Yield', desc: '交易明細公開帳本,量化 CLV 與資金曲線' },
{ href: '/props', label: '球員道具盤', desc: 'Player Props、雷達圖、Top Edge 提示' },
{ href: '/kelly', label: '凱利準則', desc: 'Kelly Fraction 與下注額建議' },
{ href: '/backtesting', label: '策略回測', desc: '動態條件、ROI 與 Equity Curve' },
{ href: '/deep-bet', label: '一鍵投注導向', desc: 'Deep Linking 進階下注導流' },
{ href: '/market-coverage', label: '盤口覆蓋率稽核', status: '正式流程', desc: '逐玩法檢查未來賽事是否真的有可驗證賠率列' },
{ href: '/kelly', label: '注碼建議', status: '可使用', desc: '依照勝率、賠率與風險偏好,估算每次最多下注多少' },
{ href: '/match-conditions', label: '裁判與天氣影響', status: '可使用', desc: '把裁判尺度、炎熱程度、海拔與下半場體能變化納入評估' },
{ href: '/ml-edge', label: '機器學習整合預測', status: '工作台', desc: '用自建模型比較市場賠率,找出可能被低估的選項' },
{ href: '/rlm', label: '反向盤口雷達', status: '資料依賴', desc: '票數/資金偏移與賠率異常走勢即時警示' },
{ href: '/props', label: '球員特殊玩法', status: '資料依賴', desc: '用球員表現資料找出可能有優勢的進球、射門與助攻玩法' },
{ href: '/proof-of-yield', label: '公開收益驗證', status: '賽後更新', desc: '公開每筆紀錄,追蹤命中率、報酬率與收盤價差' },
{ href: '/backtesting', label: '策略回測', status: '等真實樣本', desc: '用已結算推薦檢查策略是否長期有效,並觀察資金變化' },
{ href: '/deep-bet', label: '一鍵投注導向', status: '需授權', desc: '合法授權後,協助把選項帶入投注平台' },
];
return (
<div className="space-y-4">
<h2 className="dot-matrix text-2xl text-[#7d2a15]"></h2>
<h2 className="dot-matrix text-2xl text-[#7d2a15]"></h2>
<section className="panel-glow rounded-2xl p-5">
<p className="text-sm text-[#7a5b46]"> 1~14 </p>
<p className="text-sm leading-6 text-[#7a5b46]">
使
</p>
<div className="mt-4 grid gap-3 md:grid-cols-2">
{modules.map((module) => (
<article key={module.href} className="rounded-xl border border-[#dbbea0] bg-white/70 p-4">
<h3 className="text-lg font-semibold text-[#7e3b1c]">{module.label}</h3>
<p className="mt-1 text-sm text-[#7a5b46]">{module.desc}</p>
<p className="mt-2 text-xs text-[#9b5d3d]">{module.href}</p>
</article>
<Link key={module.href} href={module.href} className="rounded-xl border border-[#dbbea0] bg-white/70 p-4 transition hover:border-[#b83822] hover:bg-[#fff0e2]">
<div className="flex items-center justify-between gap-3">
<h3 className="text-lg font-semibold text-[#7e3b1c]">{module.label}</h3>
<span className="rounded-full bg-[#fff7d6] px-3 py-1 text-xs font-bold text-[#8a6400]">{module.status}</span>
</div>
<p className="mt-2 text-sm leading-6 text-[#7a5b46]">{module.desc}</p>
<p className="mt-3 text-xs font-bold text-[#b83822]"></p>
</Link>
))}
</div>
</section>
@@ -30,15 +38,15 @@ export default function ModelsPage() {
<section className="grid gap-4 md:grid-cols-3">
<article className="panel-glow rounded-2xl p-4">
<h3 className="text-lg font-semibold text-[#7e3b1c]"></h3>
<p className="mt-2 text-sm text-[#7a5b46]">/ λ</p>
<p className="mt-2 text-sm text-[#7a5b46]"></p>
</article>
<article className="panel-glow rounded-2xl p-4">
<h3 className="text-lg font-semibold text-[#7e3b1c]"></h3>
<p className="mt-2 text-sm text-[#7a5b46]"></p>
<p className="mt-2 text-sm text-[#7a5b46]"></p>
</article>
<article className="panel-glow rounded-2xl p-4">
<h3 className="text-lg font-semibold text-[#7e3b1c]">EV </h3>
<p className="mt-2 text-sm text-[#7a5b46]"></p>
<h3 className="text-lg font-semibold text-[#7e3b1c]"></h3>
<p className="mt-2 text-sm text-[#7a5b46]"></p>
</article>
</section>
</div>

View File

@@ -1,48 +1,114 @@
import { OddsLineMovementChart } from '@/components/OddsLineMovementChart';
'use client';
const samples = [
{ time: '12:00', bookmaker: 'Bet365', odds: 1.82 },
{ time: '12:30', bookmaker: 'Bet365', odds: 1.79 },
{ time: '13:00', bookmaker: 'Bet365', odds: 1.84 },
{ time: '12:00', bookmaker: 'Pinnacle', odds: 1.8 },
{ time: '12:30', bookmaker: 'Pinnacle', odds: 1.77 },
{ time: '13:00', bookmaker: 'Pinnacle', odds: 1.85 },
{ time: '12:00', bookmaker: 'DraftKings', odds: 1.83 },
{ time: '12:30', bookmaker: 'DraftKings', odds: 1.81 },
{ time: '13:00', bookmaker: 'DraftKings', odds: 1.8 },
];
import { useEffect, useState } from 'react';
import { OddsLineMovementChart } from '@/components/OddsLineMovementChart';
import { getAllMatches, getMatchById, type MatchListItem, type MatchDetail } from '@/lib/analytics-api';
export default function OddsPage() {
const [rows, setRows] = useState<{ match: MatchListItem; detail: MatchDetail | null }[]>([]);
const [selectedMatch, setSelectedMatch] = useState<MatchDetail | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function load() {
try {
const list = await getAllMatches();
const now = Date.now();
const upcoming = list
.filter((match) => new Date(match.kickoff_utc).getTime() >= now - 120 * 60 * 1000 && match.status !== '已結束')
.slice(0, 5);
const hydrated = await Promise.all(
upcoming.map(async (match) => {
try {
return { match, detail: await getMatchById(match.match_id) };
} catch {
return { match, detail: null };
}
}),
);
setRows(hydrated);
setSelectedMatch(hydrated.find((row) => row.detail?.odds_series.length)?.detail ?? hydrated[0]?.detail ?? null);
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
}
load();
const interval = window.setInterval(load, 60_000);
return () => window.clearInterval(interval);
}, []);
const chartData = selectedMatch?.odds_series.map(point => ({
time: new Date(point.recorded_at).toLocaleTimeString('zh-TW', { hour: '2-digit', minute: '2-digit' }),
bookmaker: point.bookmaker,
odds: point.decimal_odds,
})) || [];
function latestBookOdds(detail: MatchDetail | null): string[] {
if (!detail?.odds_series.length) return [];
const latest = [...detail.odds_series].sort((a, b) => new Date(b.recorded_at).getTime() - new Date(a.recorded_at).getTime());
const seen = new Set<string>();
const values: string[] = [];
for (const point of latest) {
const key = `${point.bookmaker}-${point.market_type}-${point.selection}`;
if (seen.has(key)) continue;
seen.add(key);
values.push(`${point.bookmaker}${point.market_type} ${point.selection} ${point.decimal_odds.toFixed(2)}`);
if (values.length === 3) break;
}
return values;
}
return (
<div className="space-y-4">
<h2 className="dot-matrix text-2xl text-[#7d2a15]"></h2>
<section className="panel-glow rounded-2xl p-5">
<p className="text-sm text-[#7a5b46]"> odds API Redis </p>
<div className="mt-5 overflow-auto rounded-xl border border-[#d8b58c] bg-white/65">
<table className="min-w-full text-sm">
<thead>
<tr className="bg-[#f3e3ca] text-left">
<th className="px-3 py-2"></th>
<th className="px-3 py-2">Bet365</th>
<th className="px-3 py-2">Pinnacle</th>
<th className="px-3 py-2">DraftKings</th>
<th className="px-3 py-2"></th>
</tr>
</thead>
<tbody>
<tr>
<td className="px-3 py-2"> vs 西</td>
<td className="px-3 py-2">1.92</td>
<td className="px-3 py-2">1.90</td>
<td className="px-3 py-2">1.91</td>
<td className="px-3 py-2 text-emerald-700"></td>
</tr>
</tbody>
</table>
</div>
<div className="space-y-6">
<h2 className="dot-matrix text-2xl font-bold text-cyan-400"></h2>
<section className="panel-glow rounded-2xl p-6">
<p className="text-sm text-slate-300 mb-4"></p>
{loading ? (
<p className="text-sm text-slate-400 dot-matrix dot-matrix-loading">...</p>
) : (
<div className="overflow-auto rounded-xl border border-slate-700 bg-slate-900/50">
<table className="min-w-full text-sm text-left">
<thead>
<tr className="bg-slate-800 text-slate-200">
<th className="px-4 py-3 border-b border-slate-700"></th>
<th className="px-4 py-3 border-b border-slate-700"> 1</th>
<th className="px-4 py-3 border-b border-slate-700"> 2</th>
<th className="px-4 py-3 border-b border-slate-700"> 3</th>
<th className="px-4 py-3 border-b border-slate-700"></th>
</tr>
</thead>
<tbody className="divide-y divide-slate-700">
{rows.map(({ match, detail }) => {
const odds = latestBookOdds(detail);
return (
<tr key={match.match_id} className="hover:bg-slate-800/50 transition">
<td className="px-4 py-3 text-slate-100">{match.home_team} vs {match.away_team}</td>
<td className="px-4 py-3 text-cyan-400">{odds[0] ?? '等待盤口'}</td>
<td className="px-4 py-3 text-cyan-400">{odds[1] ?? '等待盤口'}</td>
<td className="px-4 py-3 text-cyan-400">{odds[2] ?? '等待盤口'}</td>
<td className={odds.length ? 'px-4 py-3 text-emerald-400' : 'px-4 py-3 text-amber-300'}>
{odds.length ? '已有可檢查盤口' : '等待資料源'}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</section>
<OddsLineMovementChart data={samples} />
{selectedMatch && chartData.length > 0 && (
<OddsLineMovementChart data={chartData} teamName={selectedMatch.home_team} />
)}
{!loading && !rows.some((row) => row.detail?.odds_series.length) ? (
<section className="panel-glow rounded-2xl border-dashed p-5 text-sm text-slate-400">
1.90
</section>
) : null}
</div>
);
}

View File

@@ -48,7 +48,7 @@ export default function OfflinePage() {
return (
<div className="flex min-h-[70vh] items-center justify-center p-4">
<section className="max-w-xl w-full rounded-2xl border border-[#9b2e19] bg-[#1a100a] p-6 text-[#ffd5a8] shadow-2xl shadow-[#9b2e19]/35">
<p className="dot-matrix text-2xl text-[#ffb67a]">OFFLINE: CONNECTION LOST</p>
<p className="dot-matrix text-2xl text-[#ffb67a]"></p>
<h1 className="mt-2 text-3xl text-[#ffe2bd]"></h1>
<p className="mt-3 text-sm text-[#f6c48f]">
@@ -63,7 +63,7 @@ export default function OfflinePage() {
{snapshot.map((item) => (
<li key={`${item.match_id}-${item.selection}`} className="text-sm">
<span className="font-semibold text-[#ffd79b]">{item.home_team || item.match_id}</span>
{item.away_team ? ` vs ${item.away_team}` : ''} {item.market_type || 'market'}
{item.away_team ? ` vs ${item.away_team}` : ''} {item.market_type || '玩法'}
{item.selection ? ` ${item.selection}` : ''}
<span className="font-semibold text-[#ff9f4c]"> {item.odds.toFixed(2)} </span>
<span className="text-[#f4c89c]">({formatTs(item.captured_at)})</span>

View File

@@ -1,74 +1,880 @@
import Link from 'next/link';
import { formatToTaipeiTime } from '@/lib/timezone';
'use client';
type Recommendation = {
match: string;
odds: string;
model: string;
ev: string;
updatedAt: string;
import Link from 'next/link';
import { useEffect, useMemo, useState } from 'react';
import { formatToTaipeiTime } from '@/lib/timezone';
import {
getAllMatches,
getDailyCard,
getDailyCardCalendar,
getSourceHealth,
type DailyCardCalendarDate,
type DailyCardItem,
type DailyCardResponse,
type MatchListItem,
type SourceHealthResponse,
} from '@/lib/analytics-api';
type NewsItem = {
title: string;
url: string;
source: string;
publishedAt: string;
};
const sample: Recommendation[] = [
{
match: '德國 vs 西班牙',
odds: '1.90',
model: '泊松 + 蒙地卡羅',
ev: '8.2%',
updatedAt: formatToTaipeiTime(new Date().toISOString()),
},
{
match: '巴西 vs 法國',
odds: '2.08',
model: 'EV 偵測器',
ev: '6.9%',
updatedAt: formatToTaipeiTime(new Date().toISOString()),
},
{
match: '荷蘭 vs 英格蘭',
odds: '3.10',
model: '聰明錢偏移 + Sharp 濾波',
ev: '5.4%',
updatedAt: formatToTaipeiTime(new Date().toISOString()),
},
type LoadErrors = {
daily?: string;
matches?: string;
news?: string;
source?: string;
};
type GateState = {
label: string;
title: string;
detail: string;
tone: 'ready' | 'watch' | 'blocked';
primaryAction: string;
};
const WATCHLIST_KEY = 'fifa2026-daily-card-watchlist';
const TOURNAMENT_START_DATE = '2026-06-11';
const moduleLinks = [
{ href: '/daily-card', label: '每日作戰室', desc: '單關、串關、同場串關與賠率監控' },
{ href: '/live-score', label: '即時比分中心', desc: '日期切換、即時比分、完賽結果與場地資訊' },
{ href: '/schedule', label: '完整賽程表', desc: '依台北時間查看下一場與已完賽結果' },
{ href: '/battle-room', label: '對戰情報室', desc: '模型勝率、進球分布、環境條件與盤口快照' },
{ href: '/source-health', label: '資料健康總覽', desc: '檢查賽程、比分、新聞與盤口是否即時更新' },
{ href: '/recommendation-performance', label: '賽後校準室', desc: '逐玩法回看命中率並修正模型權重' },
{ href: '/agent-verification', label: 'AI 驗證室', desc: 'Gemini 費用上限、NemoTron 復核與量化閘門' },
{ href: '/news', label: '即時新聞情報', desc: '新聞、傷停與外部事件監控' },
];
export default function Home() {
function pickKey(item: DailyCardItem): string {
const normalize = (value: unknown) => String(value ?? '').replace(/\s+/g, ' ').trim();
return [normalize(item.match_id), normalize(item.market_type), normalize(item.selection)].join('|');
}
function formatTwd(value: number): string {
return new Intl.NumberFormat('zh-TW', {
style: 'currency',
currency: 'TWD',
maximumFractionDigits: 0,
}).format(value);
}
function stakeAmountTwd(item: DailyCardItem): number {
return item.stake_amount_twd ?? Math.round(item.stake_units * (item.unit_size_twd ?? 1000));
}
function itemsFromCard(card: DailyCardResponse | null): DailyCardItem[] {
if (!card) return [];
return [...card.safe_singles, ...card.safe_parlays, ...card.sgp_lotteries, ...card.high_risk_singles];
}
function matchDate(match: MatchListItem): string {
return formatToTaipeiTime(match.kickoff_utc, 'yyyy-MM-dd');
}
function isConditionalPick(item: DailyCardItem): boolean {
return (
<div className="space-y-6">
<section className="panel-glow rounded-2xl p-6">
<h2 className="dot-matrix text-2xl text-[#7d2a15]"> EV </h2>
<p className="mt-2 text-sm text-[#6d4d39]">UTC+8</p>
item.has_market_odds === false ||
item.selection.includes('預掛條件') ||
item.selection.includes('參考盤監控') ||
item.rationale.includes('尚未取得可用即時盤口') ||
item.rationale.includes('尚未取得完整實盤賠率') ||
Boolean(item.legs?.some((leg) => leg.selection.includes('預掛條件')))
);
}
function recommendationText(item: DailyCardItem): string {
const conditional = isConditionalPick(item);
if (item.recommendation === 'SAFE_SINGLE') return conditional ? '觀察單關' : '核心單關';
if (item.recommendation === 'HIGH_RISK_SINGLE') return conditional ? '高賠觀察' : '小注高賠';
if (item.recommendation === 'SAFE_PARLAY') return conditional ? '觀察串關' : '跨場串關';
if (item.recommendation === 'SGP_LOTTERY') return conditional ? '同場觀察' : '同場小注';
return '研究候選';
}
function nextStepText(item: DailyCardItem): string {
if (isConditionalPick(item) || !item.has_market_odds) {
return '現在不要下注,先加入賠率監控。等平台開盤後,只有實際賠率達到卡片門檻才考慮。';
}
if (item.odds_source_kind === 'reference_market') {
return '已有台灣運彩參考盤,可先做下注前檢查;但它不是多來源正式盤,下單前仍要確認平台最新盤口。';
}
return '可進入下注前檢查。下單前仍要確認同一場、同一玩法、同一選項與賠率門檻。';
}
function buildGateState(
sourceHealth: SourceHealthResponse | null,
errors: LoadErrors,
liveCount: number,
watchCount: number,
): GateState {
if (errors.source) {
return {
label: '資料源異常',
title: watchCount > 0 ? '正式下注先保留,預掛候選持續監控' : '資料源恢復前只保留研究觀察',
detail: watchCount > 0
? `資料源健康狀態暫時無法讀取,但仍保留 ${watchCount} 組預掛候選。這些不是立即下注單,必須等資料源恢復並達到賠率門檻。`
: '資料源健康狀態暫時無法讀取,首頁只保留資訊瀏覽,不把不完整資料包裝成高勝率下注。',
tone: watchCount > 0 ? 'watch' : 'blocked',
primaryAction: watchCount > 0 ? '查看預掛候選' : '檢查資料源',
};
}
if (!sourceHealth) {
return {
label: '同步中',
title: '正在讀取最新資料',
detail: '系統正在同步賽程、賽果、新聞與盤口狀態,完成後才會顯示候選。',
tone: 'watch',
primaryAction: '稍候刷新',
};
}
if ((sourceHealth.stale_unsettled_matches ?? 0) > 0) {
return {
label: '賽果延遲',
title: watchCount > 0 ? '正式下注暫停,預掛觀察持續更新' : '賽果追上前不硬推下注',
detail: watchCount > 0
? `仍有 ${sourceHealth.stale_unsettled_matches} 場已開賽很久但尚未回寫結果,所以不升級成正式下注推薦;但 ${watchCount} 組日期盤候選仍會持續監控賠率門檻。`
: `仍有 ${sourceHealth.stale_unsettled_matches} 場已開賽很久但尚未回寫結果。資料追上前,不會硬推下注,只保留資訊與條件監控。`,
tone: watchCount > 0 ? 'watch' : 'blocked',
primaryAction: watchCount > 0 ? '查看預掛候選' : '查看賽事中心',
};
}
if (sourceHealth.odds_coverage_status === 'full_market' && liveCount > 0) {
return {
label: '可檢查',
title: '已有實盤候選,可做下注前檢查',
detail: `目前有 ${liveCount} 組候選具備可比對盤口。仍需確認賠率、分線、傷停與注碼上限後才下單。`,
tone: 'ready',
primaryAction: '檢查推薦',
};
}
if (watchCount > 0) {
return {
label: '預掛觀察',
title: '日期盤候選持續推薦,先等賠率達標',
detail: `已找到 ${watchCount} 組模型候選,但正式盤口不足或仍是比分備援來源。這些會持續推薦為預掛觀察,不是叫你立刻下注;等平台賠率達到門檻再進下注前檢查。`,
tone: 'watch',
primaryAction: '查看預掛候選',
};
}
return {
label: '暫無候選',
title: '沒有達標候選就不硬推',
detail: '目前沒有同時通過勝率、最低賠率、資料新鮮度與風險上限的選項。保留資金比硬下注更專業。',
tone: 'blocked',
primaryAction: '等待刷新',
};
}
function gateClass(tone: GateState['tone']): string {
if (tone === 'ready') return 'border-[#7bcf9b] bg-[#e9f8ef] text-[#167a47]';
if (tone === 'blocked') return 'border-[#e7a49a] bg-[#fff0e8] text-[#b83822]';
return 'border-[#e7c462] bg-[#fff7d6] text-[#8a6400]';
}
function weekdayLabel(date: string): string {
return new Intl.DateTimeFormat('zh-TW', {
timeZone: 'Asia/Taipei',
weekday: 'short',
}).format(new Date(`${date}T00:00:00+08:00`));
}
function recommendationDateLabel(date: string, today: string, tomorrow: string): string {
if (date === today) return `今天 ${date.slice(5).replace('-', '/')} ${weekdayLabel(date)}`;
if (date === tomorrow) return `明天 ${date.slice(5).replace('-', '/')} ${weekdayLabel(date)}`;
return `${date.slice(5).replace('-', '/')} ${weekdayLabel(date)}`;
}
function recommendationDateState(date: string, today: string): string {
if (date < today) return '已完賽回看';
if (date === today) return '今日優先';
return '預售分析';
}
export default function Home() {
const [dateWindow] = useState(() => {
const now = new Date();
const tomorrow = new Date(now.getTime() + 24 * 60 * 60 * 1000);
return {
today: formatToTaipeiTime(now.toISOString(), 'yyyy-MM-dd'),
tomorrow: formatToTaipeiTime(tomorrow.toISOString(), 'yyyy-MM-dd'),
};
});
const [data, setData] = useState<DailyCardResponse | null>(null);
const [tomorrowData, setTomorrowData] = useState<DailyCardResponse | null>(null);
const [dailyCards, setDailyCards] = useState<Record<string, DailyCardResponse>>({});
const [calendarDates, setCalendarDates] = useState<DailyCardCalendarDate[]>([]);
const [selectedRecommendationDate, setSelectedRecommendationDate] = useState('');
const [matches, setMatches] = useState<MatchListItem[]>([]);
const [news, setNews] = useState<NewsItem[]>([]);
const [sourceHealth, setSourceHealth] = useState<SourceHealthResponse | null>(null);
const [watchlist, setWatchlist] = useState<DailyCardItem[]>([]);
const [feedbackMessage, setFeedbackMessage] = useState('');
const [loading, setLoading] = useState(true);
const [errors, setErrors] = useState<LoadErrors>({});
const [loadedAt, setLoadedAt] = useState('');
useEffect(() => {
try {
const raw = window.localStorage.getItem(WATCHLIST_KEY);
if (!raw) return;
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) {
setWatchlist(parsed.filter(Boolean).slice(0, 20));
}
} catch {
window.localStorage.removeItem(WATCHLIST_KEY);
}
}, []);
useEffect(() => {
let mounted = true;
async function load() {
setLoading(true);
setErrors({});
const [dailyResult, tomorrowResult, matchesResult, newsResult, sourceResult, calendarResult] = await Promise.allSettled([
getDailyCard(dateWindow.today),
getDailyCard(dateWindow.tomorrow),
getAllMatches(),
fetch('/api/news', { cache: 'no-store' }).then(async (response) => {
if (!response.ok) throw new Error(`news status ${response.status}`);
return response.json() as Promise<{ items?: NewsItem[] }>;
}),
getSourceHealth(),
getDailyCardCalendar(TOURNAMENT_START_DATE),
]);
if (!mounted) return;
const nextErrors: LoadErrors = {};
const matchPayload = matchesResult.status === 'fulfilled' ? matchesResult.value : [];
const calendarPayload = calendarResult.status === 'fulfilled' ? calendarResult.value.dates : [];
const calendarDateList = calendarPayload.map((item) => item.date).sort((a, b) => a.localeCompare(b));
const pinnedDates = [dateWindow.today, dateWindow.tomorrow].filter((date) => calendarDateList.includes(date));
const pinnedSet = new Set(pinnedDates);
const nextCards: Record<string, DailyCardResponse> = {};
const requestDates = [...pinnedDates, ...calendarDateList.filter((date) => !pinnedSet.has(date))];
const preferredDate = calendarPayload.find((item) => pinnedDates.includes(item.date) && item.recommendation_count > 0)?.date
?? calendarPayload.find((item) => item.date >= dateWindow.today && item.recommendation_count > 0)?.date
?? pinnedDates[0]
?? requestDates.find((date) => date >= dateWindow.today)
?? requestDates[0]
?? dateWindow.today;
if (!mounted) return;
if (dailyResult.status === 'fulfilled') {
setData(dailyResult.value);
nextCards[dateWindow.today] = dailyResult.value;
}
else nextErrors.daily = '每日推薦 API 尚未回應';
if (tomorrowResult.status === 'fulfilled') {
setTomorrowData(tomorrowResult.value);
nextCards[dateWindow.tomorrow] = tomorrowResult.value;
}
else nextErrors.daily = nextErrors.daily ?? '明日推薦 API 尚未回應';
if (matchesResult.status === 'fulfilled') setMatches(matchesResult.value);
else nextErrors.matches = '賽事中心 API 尚未回應';
if (newsResult.status === 'fulfilled') setNews(newsResult.value.items ?? []);
else nextErrors.news = '新聞 RSS 暫時無法更新';
if (sourceResult.status === 'fulfilled') setSourceHealth(sourceResult.value);
else nextErrors.source = '盤口資料源健康狀態暫時無法讀取';
if (calendarResult.status === 'fulfilled') setCalendarDates(calendarPayload);
else nextErrors.daily = nextErrors.daily ?? '日期推薦摘要 API 尚未回應';
setDailyCards(nextCards);
setSelectedRecommendationDate((current) => {
if (requestDates.includes(current)) return current;
return preferredDate;
});
setErrors(nextErrors);
setLoadedAt(formatToTaipeiTime(new Date().toISOString()));
setLoading(false);
}
load().catch((error) => {
if (mounted) {
setErrors({ daily: error instanceof Error ? error.message : '無法取得最新量化資料' });
setLoading(false);
}
});
const interval = window.setInterval(() => {
load().catch(() => undefined);
}, 60_000);
return () => {
mounted = false;
window.clearInterval(interval);
};
}, [dateWindow.today, dateWindow.tomorrow]);
useEffect(() => {
if (!selectedRecommendationDate || dailyCards[selectedRecommendationDate]) return undefined;
let mounted = true;
getDailyCard(selectedRecommendationDate)
.then((card) => {
if (!mounted) return;
setDailyCards((current) => ({ ...current, [selectedRecommendationDate]: card }));
})
.catch(() => {
if (!mounted) return;
setErrors((current) => ({ ...current, daily: '此日期推薦卡片暫時無法載入' }));
});
return () => {
mounted = false;
};
}, [dailyCards, selectedRecommendationDate]);
const matchesByDate = useMemo(() => {
const grouped = new Map<string, MatchListItem[]>();
matches.forEach((match) => {
const date = matchDate(match);
if (date < TOURNAMENT_START_DATE) return;
grouped.set(date, [...(grouped.get(date) ?? []), match]);
});
return grouped;
}, [matches]);
const chronologicalDates = useMemo(() => {
return calendarDates.map((item) => item.date).sort((a, b) => a.localeCompare(b));
}, [calendarDates]);
const calendarByDate = useMemo(() => {
return new Map(calendarDates.map((item) => [item.date, item]));
}, [calendarDates]);
const recommendationDateOptions = useMemo(() => {
const pinned = [dateWindow.today, dateWindow.tomorrow].filter((date) => calendarByDate.has(date));
const pinnedSet = new Set(pinned);
return [...pinned, ...chronologicalDates.filter((date) => !pinnedSet.has(date))];
}, [calendarByDate, chronologicalDates, dateWindow.today, dateWindow.tomorrow]);
const activeRecommendationDate = selectedRecommendationDate || recommendationDateOptions[0] || dateWindow.today;
const selectedCard = dailyCards[activeRecommendationDate]
?? (activeRecommendationDate === dateWindow.tomorrow ? tomorrowData : null)
?? (activeRecommendationDate === dateWindow.today ? data : null);
const recommendationItems = useMemo(() => itemsFromCard(selectedCard), [selectedCard]);
const selectedDateSummary = calendarByDate.get(activeRecommendationDate);
const selectedMatchCount = selectedDateSummary?.match_count ?? matchesByDate.get(activeRecommendationDate)?.length ?? selectedCard?.matched_matches ?? 0;
const dateSummaries = useMemo(() => {
return recommendationDateOptions.map((date) => {
const card = dailyCards[date]
?? (date === dateWindow.today ? data : null)
?? (date === dateWindow.tomorrow ? tomorrowData : null);
const items = itemsFromCard(card);
const summary = calendarByDate.get(date);
return {
date,
matchCount: summary?.match_count ?? matchesByDate.get(date)?.length ?? card?.matched_matches ?? 0,
recommendationCount: summary?.recommendation_count ?? items.length,
liveCount: summary?.live_count ?? items.filter((item) => item.has_market_odds).length,
};
});
}, [calendarByDate, dailyCards, data, dateWindow.today, dateWindow.tomorrow, matchesByDate, recommendationDateOptions, tomorrowData]);
const topPicks = useMemo<DailyCardItem[]>(() => {
const rank = (items: DailyCardItem[]) =>
[...items].sort((a, b) => {
const aLiveBonus = a.has_market_odds ? 18 : 0;
const bLiveBonus = b.has_market_odds ? 18 : 0;
const aScore = aLiveBonus + (a.confidence_score ?? a.win_prob) + (a.ev_percent * 0.22) - (a.stake_units * 0.8);
const bScore = bLiveBonus + (b.confidence_score ?? b.win_prob) + (b.ev_percent * 0.22) - (b.stake_units * 0.8);
return bScore - aScore;
});
const singles = rank(recommendationItems.filter((item) => item.recommendation === 'SAFE_SINGLE'));
const parlays = rank(recommendationItems.filter((item) => item.recommendation === 'SAFE_PARLAY'));
const sameGame = rank(recommendationItems.filter((item) => item.recommendation === 'SGP_LOTTERY'));
const highRisk = rank(recommendationItems.filter((item) => item.recommendation === 'HIGH_RISK_SINGLE'));
const balanced = [...singles.slice(0, 3), ...parlays.slice(0, 2), ...sameGame.slice(0, 1), ...highRisk.slice(0, 2)];
const used = new Set(balanced.map(pickKey));
const remaining = rank(recommendationItems).filter((item) => !used.has(pickKey(item)));
return [...balanced, ...remaining].slice(0, 10);
}, [recommendationItems]);
const categorySummary = useMemo(() => {
const count = (type: DailyCardItem['recommendation']) => recommendationItems.filter((item) => item.recommendation === type).length;
return [
{ label: '單關', value: count('SAFE_SINGLE') },
{ label: '跨場串關', value: count('SAFE_PARLAY') },
{ label: '同場串關', value: count('SGP_LOTTERY') },
{ label: '小注高賠', value: count('HIGH_RISK_SINGLE') },
];
}, [recommendationItems]);
const nextMatches = useMemo(() => {
const now = Date.now();
return matches
.filter((match) => new Date(match.kickoff_utc).getTime() >= now - 1000 * 60 * 120)
.sort((a, b) => new Date(a.kickoff_utc).getTime() - new Date(b.kickoff_utc).getTime())
.slice(0, 5);
}, [matches]);
const watchlistKeys = useMemo(() => new Set(watchlist.map(pickKey)), [watchlist]);
const liveRecommendationCount = recommendationItems.filter((item) => item.has_market_odds).length;
const watchOnlyRecommendationCount = recommendationItems.length - liveRecommendationCount;
const gate = buildGateState(sourceHealth, errors, liveRecommendationCount, watchOnlyRecommendationCount);
const latestOddsLabel = sourceHealth?.latest_odds_recorded_at ? formatToTaipeiTime(sourceHealth.latest_odds_recorded_at) : '尚無盤口時間';
const latestResultLabel = sourceHealth?.latest_result_synced_at ? formatToTaipeiTime(sourceHealth.latest_result_synced_at) : '尚無賽果時間';
const latestNewsLabel = sourceHealth?.news_status?.run_at ? formatToTaipeiTime(sourceHealth.news_status.run_at) : '尚無新聞排程';
const latestFixturesLabel = sourceHealth?.fixtures_status?.run_at ? formatToTaipeiTime(sourceHealth.fixtures_status.run_at) : '尚無賽程排程';
const oddsCoverageLabel = sourceHealth?.odds_coverage_status === 'full_market'
? '完整實盤'
: sourceHealth?.odds_coverage_status === 'reference_market'
? '台灣運彩參考盤'
: sourceHealth?.odds_coverage_status === 'limited_scoreboard_fallback'
? '比分備援,盤口不足'
: sourceHealth?.odds_coverage_status === 'no_upcoming_market'
? '未來賽事無實盤'
: '資料不足';
const activeDateLabel = recommendationDateLabel(activeRecommendationDate, dateWindow.today, dateWindow.tomorrow);
const activeDateState = recommendationDateState(activeRecommendationDate, dateWindow.today);
const activeDateRecommendationCount = selectedDateSummary?.recommendation_count ?? recommendationItems.length;
const activeDateLiveCount = selectedDateSummary?.live_count ?? liveRecommendationCount;
const activeDateWatchCount = Math.max(activeDateRecommendationCount - activeDateLiveCount, 0);
const activeDateSummaryText = `${activeDateLabel}${selectedMatchCount} 場比賽、${activeDateRecommendationCount} 筆候選,其中 ${activeDateLiveCount} 筆可做下注前檢查、${activeDateWatchCount} 筆先監控賠率。`;
const healthItems = [
{
label: '資料可用性',
ok: Boolean(sourceHealth) && (sourceHealth?.stale_unsettled_matches ?? 0) === 0 && !errors.source,
detail: errors.source ?? `${oddsCoverageLabel};賽果 ${latestResultLabel},逾時未更新 ${sourceHealth?.stale_unsettled_matches ?? 0}`,
},
{
label: '推薦狀態',
ok: liveRecommendationCount > 0,
detail: `${liveRecommendationCount} 組可檢查、${watchOnlyRecommendationCount} 組只監控;目前日期掃描 ${selectedMatchCount}`,
},
{
label: '賽程與新聞',
ok: matches.length > 0 && news.length > 0 && !errors.matches && !errors.news,
detail: `賽程 ${matches.length} 場,排程 ${latestFixturesLabel};新聞 ${news.length} 則,排程 ${latestNewsLabel}`,
},
];
function handleTrack(item: DailyCardItem) {
const key = pickKey(item);
if (watchlistKeys.has(key)) {
setFeedbackMessage(`已在清單中:${item.match_label} ${item.selection}`);
return;
}
const next = [item, ...watchlist].slice(0, 20);
setWatchlist(next);
setFeedbackMessage(`已加入${isConditionalPick(item) || !item.has_market_odds ? '賠率監控清單' : '下注前檢查清單'}${item.match_label} ${item.selection}`);
try {
window.localStorage.setItem(WATCHLIST_KEY, JSON.stringify(next));
} catch {
setFeedbackMessage(`已加入本頁清單,但瀏覽器暫時無法永久保存:${item.match_label} ${item.selection}`);
}
}
function handleRemoveTracked(item: DailyCardItem) {
const key = pickKey(item);
const next = watchlist.filter((tracked) => pickKey(tracked) !== key);
setWatchlist(next);
setFeedbackMessage(`已移除監控:${item.match_label} ${item.selection}`);
try {
window.localStorage.setItem(WATCHLIST_KEY, JSON.stringify(next));
} catch {
setFeedbackMessage('已從本頁清單移除;瀏覽器永久保存狀態稍後會在重新整理後同步。');
}
}
return (
<div className="space-y-8">
<section className="rounded-[2rem] border border-[#e7c89b] bg-[#fff8e6]/95 p-6 shadow-[0_18px_60px_rgba(125,42,21,0.12)] md:p-8">
<div className="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
<div>
<p className="dot-matrix text-xs font-semibold text-[#b83822]"> / {dateWindow.today} - {dateWindow.tomorrow}</p>
<h2 className="mt-2 text-3xl font-black text-[#3f2f25] md:text-5xl"></h2>
<p className="mt-3 max-w-3xl text-sm leading-7 text-[#6f4f3c]">
</p>
<p className="mt-3 rounded-2xl border border-[#eadcb9] bg-white/75 px-4 py-3 text-sm font-bold text-[#5f4330]">
<span className="text-[#b83822]">{activeRecommendationDate}</span>
{selectedMatchCount} {recommendationItems.length}
</p>
</div>
<div className="flex flex-wrap gap-2 text-xs font-bold">
<span className="rounded-full bg-[#e9f8ef] px-3 py-1 text-[#167a47]"> {liveRecommendationCount} </span>
<span className="rounded-full bg-[#fff7d6] px-3 py-1 text-[#8a6400]"> {watchOnlyRecommendationCount} </span>
<span className="rounded-full bg-[#fff0e2] px-3 py-1 text-[#b83822]">60 </span>
</div>
</div>
<div className="mt-4 flex flex-wrap gap-2 text-xs font-black">
{dateSummaries.map((item) => {
const pinnedLabel = item.date === dateWindow.today ? '今天' : item.date === dateWindow.tomorrow ? '明天' : '';
return (
<button
key={item.date}
type="button"
onClick={() => setSelectedRecommendationDate(item.date)}
className={`rounded-full border px-3 py-1.5 text-left transition ${
activeRecommendationDate === item.date
? 'border-[#7d2a15] bg-[#7d2a15] text-white'
: 'border-[#d8b58c] bg-white/75 text-[#5f4330] hover:border-[#b83822]'
}`}
>
{item.date}{pinnedLabel ? ` ${pinnedLabel}` : ''}{item.matchCount} {item.recommendationCount}
{item.liveCount > 0 ? `${item.liveCount} 可檢查` : ''}
</button>
);
})}
</div>
<div className="mt-4 flex flex-wrap gap-2 text-xs font-black">
{categorySummary.map((item) => (
<span key={item.label} className="rounded-full border border-[#d8b58c] bg-white/75 px-3 py-1 text-[#5f4330]">
{item.label} {item.value}
</span>
))}
</div>
{feedbackMessage ? (
<p role="status" className="mt-4 rounded-2xl border border-[#9bd8b0] bg-[#e9f8ef] p-3 text-sm font-bold text-[#167a47]">
{feedbackMessage}
</p>
) : null}
<section className="mt-5 rounded-3xl border border-[#eadcb9] bg-white/70 p-4">
<div className="flex flex-col gap-2 md:flex-row md:items-start md:justify-between">
<div>
<p className="dot-matrix text-xs font-bold text-[#b83822]"></p>
<h3 className="mt-1 text-xl font-black text-[#3f2f25]">
{watchlist.length ? `已追蹤 ${watchlist.length} 組候選` : '還沒有加入候選'}
</h3>
<p className="mt-1 text-sm leading-6 text-[#7a5b46]">
</p>
</div>
<Link href="/daily-card" className="rounded-full border border-[#d8b58c] bg-[#fff8e6] px-4 py-2 text-sm font-black text-[#7d2a15] transition hover:border-[#b83822]">
</Link>
</div>
{watchlist.length ? (
<div className="mt-4 grid gap-3 lg:grid-cols-2">
{watchlist.slice(0, 4).map((item) => {
const conditional = isConditionalPick(item) || !item.has_market_odds;
return (
<article key={`watch-${pickKey(item)}`} className="rounded-2xl border border-[#e7c89b] bg-[#fff8e6]/90 p-4">
<div className="flex flex-wrap items-center gap-2">
<span className={`rounded-full px-2 py-1 text-[11px] font-black ${conditional ? 'bg-[#fff7d6] text-[#8a6400]' : 'bg-[#e9f8ef] text-[#167a47]'}`}>
{conditional ? '先監控' : '可檢查'}
</span>
{item.odds_source_label ? (
<span className="rounded-full border border-[#d8b58c] bg-white px-2 py-1 text-[11px] font-black text-[#5f4330]">{item.odds_source_label}</span>
) : null}
</div>
<p className="mt-2 text-sm font-black text-[#3f2f25]">{item.match_label}</p>
<p className="mt-1 text-xs leading-5 text-[#7a5b46]">{item.market_type}{item.selection}</p>
<div className="mt-3 grid grid-cols-3 gap-2 text-xs text-[#7a5b46]">
<p><br /><b className="text-[#b83822]">{item.target_odds.toFixed(2)}</b></p>
<p><br /><b className="text-[#167a47]">{typeof item.confidence_score === 'number' ? item.confidence_score.toFixed(1) : item.win_prob.toFixed(1)}</b></p>
<p><br /><b className="text-[#8a6400]">{formatTwd(stakeAmountTwd(item))}</b></p>
</div>
<button
type="button"
className="mt-3 rounded-full border border-[#d8b58c] bg-white px-3 py-1.5 text-xs font-black text-[#7d2a15] transition hover:border-[#b83822]"
onClick={() => handleRemoveTracked(item)}
>
</button>
</article>
);
})}
</div>
) : (
<p className="mt-4 rounded-2xl border border-dashed border-[#d8b58c] bg-[#fff8e6]/80 p-4 text-sm leading-6 text-[#7a5b46]">
便
</p>
)}
</section>
{loading ? (
<p className="mt-6 dot-matrix dot-matrix-loading text-sm text-[#7a5b46]">...</p>
) : topPicks.length ? (
<div className="mt-6 grid gap-4 lg:grid-cols-3">
{topPicks.slice(0, 9).map((item) => {
const conditional = isConditionalPick(item) || !item.has_market_odds;
const inList = watchlistKeys.has(pickKey(item));
return (
<article key={`hero-${pickKey(item)}-${item.recommendation}`} className="rounded-3xl border border-[#e7c89b] bg-white/80 p-5 shadow-[0_14px_36px_rgba(125,42,21,0.10)]">
<div className="flex flex-wrap items-center gap-2">
<span className="dot-matrix text-xs font-bold text-[#b83822]">{recommendationText(item)}</span>
<span className={`rounded-full border px-2 py-1 text-[11px] font-black ${conditional ? 'border-[#e7c462] bg-[#fff7d6] text-[#8a6400]' : 'border-[#7bcf9b] bg-[#e9f8ef] text-[#167a47]'}`}>
{conditional ? '預掛觀察' : '下注前檢查'}
</span>
{typeof item.confidence_score === 'number' ? <span className="rounded-full bg-[#e9f8ef] px-2 py-1 text-[11px] font-black text-[#167a47]"> {item.confidence_score.toFixed(1)}</span> : null}
{item.odds_source_label ? <span className="rounded-full border border-[#d8b58c] bg-white px-2 py-1 text-[11px] font-black text-[#5f4330]">{item.odds_source_label}</span> : null}
</div>
<h3 className="mt-3 text-lg font-black text-[#3f2f25]">{item.match_label}</h3>
<p className="mt-1 text-xs font-bold text-[#7a5b46]">{activeDateLabel}</p>
<p className="mt-2 text-sm font-bold text-[#b83822]">{item.market_type}{item.selection}</p>
<p className="mt-2 text-xs leading-5 text-[#8a6b58]"></p>
<div className="mt-4 grid gap-2 text-sm text-[#5f4330]">
<p><span className="font-black text-[#b83822]">{item.target_odds.toFixed(2)}</span></p>
<p><span className="font-black text-[#167a47]">{item.win_prob.toFixed(2)}%</span></p>
<p><span className="font-black text-[#8a6400]">{formatTwd(stakeAmountTwd(item))}</span></p>
</div>
<p className="mt-3 rounded-2xl bg-[#fff8e6] p-3 text-xs leading-5 text-[#6f4f3c]">
{nextStepText(item)} {item.target_odds.toFixed(2)}
</p>
<div className="mt-4 flex flex-col gap-2 sm:flex-row">
<button
type="button"
aria-pressed={inList}
className={`rounded-full px-4 py-2 text-sm font-black text-white transition ${inList ? 'bg-[#167a47]' : 'bg-[#d1432d] hover:bg-[#b83822]'}`}
onClick={() => handleTrack(item)}
>
{inList ? '已加入清單' : conditional ? '加入賠率監控' : '加入下注前檢查'}
</button>
<Link href="/daily-card" className="rounded-full border border-[#d8b58c] bg-white px-4 py-2 text-center text-sm font-bold text-[#5f4330] transition hover:border-[#b83822] hover:text-[#7d2a15]">
</Link>
</div>
</article>
);
})}
</div>
) : (
<div className="mt-6 rounded-3xl border border-dashed border-[#d8b58c] bg-white/65 p-5">
<p className="text-sm leading-7 text-[#6f4f3c]"></p>
</div>
)}
</section>
<section className="grid gap-4 md:grid-cols-3">
{sample.map((item) => (
<article key={item.match} className="panel-glow rounded-2xl p-4">
<h3 className="text-lg font-semibold text-[#7e3b1c]">{item.match}</h3>
<p className="mt-2 text-sm text-[#7a5b46]">{item.model}</p>
<p className="mt-2 text-2xl font-semibold text-[#b83822]">EV {item.ev}</p>
<p className="mt-2 text-sm text-[#6f4f3c]">{item.odds}</p>
<p className="mt-1 text-xs text-[#8a6b58]">{item.updatedAt}</p>
<section className="relative overflow-hidden rounded-[2rem] border border-[#e7c89b] bg-[#fff8e6]/95 p-6 shadow-[0_18px_60px_rgba(125,42,21,0.12)] md:p-8">
<div className="absolute inset-y-0 right-0 w-1/2 bg-[radial-gradient(circle_at_center,rgba(209,67,45,0.16),transparent_55%)]" />
<div className="relative grid gap-8 lg:grid-cols-[1.08fr_0.92fr]">
<div>
<p className="dot-matrix text-xs font-semibold text-[#b83822]"> / {activeDateState} / {activeRecommendationDate}</p>
<h2 className="mt-4 max-w-3xl text-4xl font-black leading-tight text-[#3f2f25] md:text-6xl">
</h2>
<p className="mt-4 max-w-2xl text-base leading-7 text-[#6f4f3c]">
{activeDateSummaryText}
</p>
<div className={`mt-6 rounded-3xl border p-5 ${gateClass(gate.tone)}`}>
<div className="flex flex-wrap items-center gap-2">
<span className="rounded-full bg-white/70 px-3 py-1 text-xs font-black">{gate.label}</span>
<span className="text-xs font-semibold">{loadedAt || '同步中'}</span>
</div>
<h3 className="mt-3 text-2xl font-black">{gate.title}</h3>
<p className="mt-2 text-sm leading-6">{gate.detail}</p>
<div className="mt-4 flex flex-wrap gap-2 text-xs font-bold">
<span className="rounded-full bg-white/65 px-3 py-1">{sourceHealth?.odds_coverage_status === 'full_market' ? '已具備' : '不足'}</span>
<span className="rounded-full bg-white/65 px-3 py-1">{sourceHealth?.stale_unsettled_matches ?? 0} </span>
<span className="rounded-full bg-white/65 px-3 py-1">{activeDateLabel}</span>
<span className="rounded-full bg-white/65 px-3 py-1">{watchlist.length} </span>
</div>
</div>
<div className="mt-6 flex flex-wrap gap-3">
<Link href="/daily-card" className="rounded-full bg-[#d1432d] px-5 py-3 text-sm font-black text-white transition hover:bg-[#b83822]">
{gate.primaryAction}
</Link>
<Link href="/market-coverage" className="rounded-full border border-[#d8b58c] bg-white/70 px-5 py-3 text-sm font-semibold text-[#5f4330] transition hover:border-[#b83822] hover:text-[#7d2a15]">
</Link>
<Link href="/recommendation-readiness" className="rounded-full border border-[#d8b58c] bg-white/70 px-5 py-3 text-sm font-semibold text-[#5f4330] transition hover:border-[#1a9a57] hover:text-[#1a9a57]">
</Link>
</div>
</div>
<div className="rounded-3xl border border-[#e7c89b] bg-white/75 p-5 shadow-inner">
<div className="flex items-center justify-between">
<p className="dot-matrix text-sm font-semibold text-[#7d2a15]"></p>
<span className="rounded-full bg-[#fff0e2] px-3 py-1 text-xs text-[#7a5b46]">60 </span>
</div>
<div className="mt-4 grid gap-3">
{[
['1', '先看狀態', '若是只監控或資料延遲,就不要直接下注。'],
['2', '確認玩法', '單關、串關、同場串關要分開看,不混成同一種推薦。'],
['3', '比對賠率', '平台賠率低於最低門檻就跳過,不能硬追。'],
['4', '控制注碼', '只用卡片建議單位,尤其高賠與同場串關要小注。'],
].map(([step, title, detail]) => (
<article key={step} className="rounded-2xl border border-[#eadcb9] bg-[#fff8e6]/80 p-4">
<div className="flex gap-3">
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-[#7d2a15] text-sm font-black text-white">{step}</span>
<div>
<p className="font-bold text-[#5f4031]">{title}</p>
<p className="mt-1 text-xs leading-5 text-[#7a5b46]">{detail}</p>
</div>
</div>
</article>
))}
</div>
</div>
</div>
</section>
<section className="grid gap-4 md:grid-cols-4">
{[
{ label: '可檢查候選', value: selectedCard ? liveRecommendationCount : '-', helper: '同日實盤資料' },
{ label: '監控候選', value: selectedCard ? watchOnlyRecommendationCount : '-', helper: '同日等賠率' },
{ label: '串關玩法', value: selectedCard ? recommendationItems.filter((item) => ['SAFE_PARLAY', 'SGP_LOTTERY'].includes(item.recommendation)).length : '-', helper: '跨場/同場' },
{ label: '近期賽事', value: nextMatches.length || '-', helper: '台北時間排序' },
].map((item) => (
<article key={item.label} className="panel-glow rounded-2xl p-5">
<p className="text-xs font-semibold tracking-[0.2em] text-[#8a6b58]">{item.helper}</p>
<p className="mt-3 text-3xl font-black text-[#7d2a15]">{item.value}</p>
<p className="mt-1 text-sm text-[#6f4f3c]">{item.label}</p>
</article>
))}
</section>
<section className="rounded-2xl panel-glow p-6">
<h2 className="dot-matrix text-xl text-[#7d2a15]"></h2>
<div className="mt-4 flex flex-wrap gap-3">
<Link href="/matches" className="rounded-full border bg-white/80 px-4 py-2"></Link>
<Link href="/odds" className="rounded-full border bg-white/80 px-4 py-2"></Link>
<Link href="/sharp-money" className="rounded-full border bg-white/80 px-4 py-2"></Link>
<Link href="/models" className="rounded-full border bg-white/80 px-4 py-2"></Link>
<Link href="/ml-edge" className="rounded-full border bg-white/80 px-4 py-2">ML Ensemble</Link>
<Link href="/match-conditions" className="rounded-full border bg-white/80 px-4 py-2"></Link>
<Link href="/rlm" className="rounded-full border bg-white/80 px-4 py-2">RLM </Link>
<Link href="/proof-of-yield" className="rounded-full border bg-white/80 px-4 py-2"></Link>
<Link href="/props" className="rounded-full border bg-white/80 px-4 py-2"></Link>
<Link href="/kelly" className="rounded-full border bg-white/80 px-4 py-2"></Link>
<Link href="/backtesting" className="rounded-full border bg-white/80 px-4 py-2"></Link>
<Link href="/deep-bet" className="rounded-full border bg-white/80 px-4 py-2"></Link>
<Link href="/portfolio" className="rounded-full border bg-white/80 px-4 py-2"></Link>
<section className="grid gap-6 lg:grid-cols-[1.38fr_0.62fr]">
<div className="space-y-4">
<div className="flex flex-col gap-2 md:flex-row md:items-end md:justify-between">
<div>
<p className="dot-matrix text-xs text-[#b83822]"> / {activeDateState}</p>
<h2 className="text-2xl font-black text-[#3f2f25]">{activeDateLabel} </h2>
<p className="mt-2 text-sm text-[#7a5b46]">{activeDateSummaryText} </p>
</div>
<Link href="/daily-card" className="text-sm font-semibold text-[#b83822] hover:text-[#7d2a15]"></Link>
</div>
{feedbackMessage ? (
<p role="status" className="rounded-2xl border border-[#9bd8b0] bg-[#e9f8ef] p-3 text-sm font-bold text-[#167a47]">
{feedbackMessage}
</p>
) : null}
{loading ? (
<p className="dot-matrix dot-matrix-loading text-sm text-[#7a5b46]">...</p>
) : topPicks.length ? (
<div className="grid gap-4 md:grid-cols-2">
{topPicks.map((item) => {
const conditional = isConditionalPick(item) || !item.has_market_odds;
const inList = watchlistKeys.has(pickKey(item));
return (
<article key={`${pickKey(item)}-${item.recommendation}`} className="rounded-3xl border border-[#e7c89b] bg-[#fff8e6] p-5 shadow-[0_18px_48px_rgba(125,42,21,0.10)]">
<div className="flex flex-wrap items-center gap-2">
<span className="dot-matrix text-xs font-bold text-[#b83822]">{recommendationText(item)}</span>
<span className={`rounded-full border px-2 py-1 text-[11px] font-bold ${conditional ? 'border-[#e7c462] bg-[#fff7d6] text-[#8a6400]' : 'border-[#7bcf9b] bg-[#e9f8ef] text-[#167a47]'}`}>
{conditional ? '先監控' : '可檢查'}
</span>
{typeof item.confidence_score === 'number' ? <span className="rounded-full bg-white/70 px-2 py-1 text-[11px] font-bold text-[#167a47]"> {item.confidence_score.toFixed(1)}</span> : null}
<span className="rounded-full bg-white/70 px-2 py-1 text-[11px] font-bold text-[#7a5b46]">{activeDateLabel}</span>
{item.confidence_band ? <span className="rounded-full bg-white/70 px-2 py-1 text-[11px] font-bold text-[#7d2a15]">{item.confidence_band}</span> : null}
</div>
<h3 className="mt-3 text-xl font-black text-[#3f2f25]">{item.match_label}</h3>
<p className="mt-1 text-sm text-[#7a5b46]">{item.market_type} {item.selection}</p>
<div className="mt-4 grid gap-2 text-sm text-[#5f4330] md:grid-cols-3">
<p> <span className="font-black text-[#167a47]">{item.win_prob.toFixed(2)}%</span></p>
<p> <span className="font-black text-[#b83822]">{item.target_odds.toFixed(2)}</span></p>
<p> <span className="font-black text-[#8a6400]">{formatTwd(stakeAmountTwd(item))}</span></p>
</div>
<p className="mt-3 rounded-2xl bg-white/70 p-3 text-sm leading-6 text-[#6f4f3c]">
{nextStepText(item)} {item.target_odds.toFixed(2)}
</p>
<div className="mt-4 flex flex-col gap-2 sm:flex-row">
<button
type="button"
aria-pressed={inList}
className={`rounded-full px-4 py-2 text-sm font-black text-white transition ${inList ? 'bg-[#167a47]' : 'bg-[#d1432d] hover:bg-[#b83822]'}`}
onClick={() => handleTrack(item)}
>
{inList ? '已加入清單' : conditional ? '加入賠率監控' : '加入下注前檢查'}
</button>
<Link href="/daily-card" className="rounded-full border border-[#d8b58c] bg-white/70 px-4 py-2 text-center text-sm font-bold text-[#5f4330] transition hover:border-[#b83822] hover:text-[#7d2a15]">
</Link>
</div>
</article>
);
})}
</div>
) : (
<div className="panel-glow rounded-2xl border-dashed p-6">
<p className="text-sm leading-7 text-[#6f4f3c]"></p>
<p className="mt-2 text-xs text-[#8a6b58]">{errors.daily ?? selectedCard?.summary ?? '等待下一次資料刷新。'}</p>
</div>
)}
</div>
<aside className="space-y-4">
<section className="panel-glow rounded-2xl p-5">
<h3 className="dot-matrix text-sm font-bold text-[#7d2a15]"></h3>
<div className="mt-4 space-y-3">
{healthItems.map((item) => (
<article key={item.label} className="rounded-2xl border border-[#eadcb9] bg-white/70 p-4">
<div className="flex items-center gap-2">
<span className={`status-led ${item.ok ? 'status-led-ok' : 'status-led-warn'}`} />
<p className="text-sm font-bold text-[#3f2f25]">{item.label}</p>
</div>
<p className="mt-2 text-xs leading-5 text-[#7a5b46]">{item.detail}</p>
</article>
))}
</div>
</section>
<section className="panel-glow rounded-2xl p-5">
<div className="flex items-center justify-between">
<h3 className="dot-matrix text-sm font-bold text-[#7d2a15]"></h3>
<Link href="/schedule" className="text-xs font-bold text-[#b83822] hover:text-[#7d2a15]"></Link>
</div>
<div className="mt-4 space-y-3">
{nextMatches.length ? nextMatches.map((match) => (
<Link key={match.match_id} href={`/matches/${match.match_id}`} className="block rounded-2xl border border-[#eadcb9] bg-white/70 p-3 transition hover:border-[#b83822]">
<p className="text-sm font-bold text-[#3f2f25]">{match.home_team} vs {match.away_team}</p>
<p className="mt-1 text-xs text-[#7a5b46]">{formatToTaipeiTime(match.kickoff_utc, 'MM/dd HH:mm')} {match.status} {match.venue_city || '場地待定'}</p>
</Link>
)) : (
<p className="text-sm text-[#7a5b46]">{errors.matches ?? '尚無可顯示賽程。'}</p>
)}
</div>
</section>
<section className="panel-glow rounded-2xl p-5">
<div className="flex items-center justify-between">
<h3 className="dot-matrix text-sm font-bold text-[#7d2a15]"></h3>
<Link href="/news" className="text-xs font-bold text-[#b83822] hover:text-[#7d2a15]"></Link>
</div>
<div className="mt-4 space-y-3">
{news.slice(0, 5).map((item) => (
<a key={item.url} href={item.url} target="_blank" rel="noreferrer" className="block rounded-2xl border border-[#eadcb9] bg-white/70 p-3 transition hover:border-[#b83822]">
<p className="text-sm font-semibold leading-5 text-[#3f2f25]">{item.title}</p>
<p className="mt-2 text-xs text-[#7a5b46]">{item.source} {item.publishedAt ? formatToTaipeiTime(item.publishedAt, 'MM/dd HH:mm') : '時間待定'}</p>
</a>
))}
{!news.length ? <p className="text-sm text-[#7a5b46]">{errors.news ?? '尚無新聞資料。'}</p> : null}
</div>
</section>
</aside>
</section>
<section className="panel-glow rounded-2xl p-6">
<h2 className="dot-matrix text-xl text-[#7d2a15]"></h2>
<p className="mt-2 text-sm text-[#7a5b46]"></p>
<div className="mt-4 grid gap-3 md:grid-cols-2 lg:grid-cols-4">
{moduleLinks.map((item) => (
<Link key={item.href} href={item.href} className="rounded-2xl border border-[#eadcb9] bg-white/70 p-4 transition hover:border-[#b83822] hover:bg-[#fff0e2]">
<p className="font-bold text-[#3f2f25]">{item.label}</p>
<p className="mt-2 text-xs leading-5 text-[#7a5b46]">{item.desc}</p>
</Link>
))}
</div>
</section>
</div>

View File

@@ -1,10 +1,30 @@
import Link from 'next/link';
export default function PaywallPage() {
return (
<div className="mx-auto mt-10 max-w-xl">
<section className="panel-glow rounded-2xl p-8 text-center">
<h2 className="dot-matrix text-2xl text-[#7d2a15]"></h2>
<p className="mt-3 text-sm text-[#6d4d39]"> PRO </p>
<button type="button" className="mt-6 rounded-full bg-[#b83822] px-6 py-2 text-white"></button>
<div className="mx-auto mt-10 max-w-3xl space-y-5">
<section className="panel-glow rounded-2xl p-8">
<p className="dot-matrix text-xs font-bold text-[#b83822]"> / </p>
<h2 className="mt-2 text-3xl font-black text-[#3f2f25]"></h2>
<p className="mt-3 text-sm leading-7 text-[#6d4d39]">
</p>
<div className="mt-5 grid gap-3 md:grid-cols-3">
{[
['目前可用', '首頁推薦、每日作戰室、賽程比分、AI 成本監控'],
['暫不收費', '等待真實績效帳本與資料源穩定後才評估'],
['付費前提', '每筆推薦可追蹤、可校準、可回看命中率'],
].map(([title, detail]) => (
<article key={title} className="rounded-2xl border border-[#eadcb9] bg-white/70 p-4">
<p className="font-black text-[#7d2a15]">{title}</p>
<p className="mt-2 text-sm leading-6 text-[#7a5b46]">{detail}</p>
</article>
))}
</div>
<div className="mt-6 flex flex-wrap gap-3">
<Link href="/daily-card" className="rounded-full bg-[#b83822] px-5 py-2 text-sm font-bold text-white"></Link>
<Link href="/proof-of-yield" className="rounded-full border border-[#d8b58c] bg-white/75 px-5 py-2 text-sm font-bold text-[#7d2a15]"></Link>
</div>
</section>
</div>
);

View File

@@ -78,7 +78,7 @@ const EMPTY_REPORT: PortfolioLeaksResponse = {
};
export default function PortfolioPage() {
const [rawBetsText, setRawBetsText] = useState(JSON.stringify(DEFAULT_BETS, null, 2));
const [rawBetsText, setRawBetsText] = useState('[]');
const [report, setReport] = useState<PortfolioLeaksResponse>(EMPTY_REPORT);
const [loading, setLoading] = useState(false);
const [message, setMessage] = useState('');
@@ -98,12 +98,6 @@ export default function PortfolioPage() {
}
}
useEffect(() => {
loadBets({ user_bets: DEFAULT_BETS }).catch(() => {
// noop
});
}, []);
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
@@ -129,7 +123,7 @@ export default function PortfolioPage() {
<section className="panel-glow rounded-2xl p-4">
<h3 className="dot-matrix text-lg text-[#7d2a15]"></h3>
<p className="mt-2 text-sm text-[#7a5b46]">
/ ROI/CLV
/
</p>
<form className="mt-3" onSubmit={onSubmit}>
<label className="text-sm text-[#7a5b46]">
@@ -153,7 +147,7 @@ export default function PortfolioPage() {
className="rounded-lg bg-white/90 px-4 py-2 text-sm text-[#7d2a15]"
onClick={() => setRawBetsText(JSON.stringify(DEFAULT_BETS, null, 2))}
>
</button>
</div>
</form>

View File

@@ -50,14 +50,14 @@ export default function ProofOfYieldPage() {
{/* 標題與核心指標 */}
<div className="mb-10">
<h1 className="text-3xl font-bold text-stone-900 uppercase tracking-widest border-b-2 border-quant-orange pb-2 inline-block">
Proof of Yield <span className="text-stone-400 text-lg ml-2"></span>
<span className="text-stone-400 text-lg ml-2"></span>
</h1>
<p className="text-stone-500 mt-2">/100% </p>
<p className="text-stone-500 mt-2"></p>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-10">
<div className="bg-white p-6 rounded-lg border border-stone-200 shadow-sm">
<p className="text-sm text-stone-500 uppercase font-semibold">Total ROI ()</p>
<p className="text-sm text-stone-500 font-semibold"></p>
<p className={`text-4xl font-dotmatrix mt-2 ${summary.roi_percent >= 0 ? 'text-quant-orange' : 'text-red-500'}`}>
{summary.roi_percent > 0 ? '+' : ''}{summary.roi_percent.toFixed(2)}%
</p>
@@ -68,7 +68,7 @@ export default function ProofOfYieldPage() {
</div>
<div className="bg-white p-6 rounded-lg border border-stone-200 shadow-sm relative overflow-hidden">
<div className="absolute right-0 top-0 w-2 h-full bg-quant-red"></div>
<p className="text-sm text-stone-500 uppercase font-semibold">Avg CLV ()</p>
<p className="text-sm text-stone-500 font-semibold"></p>
<p className={`text-4xl font-dotmatrix mt-2 ${summary.avg_clv_percent >= 0 ? 'text-quant-red' : 'text-red-500'}`}>
{summary.avg_clv_percent > 0 ? '+' : ''}{summary.avg_clv_percent.toFixed(2)}%
</p>
@@ -78,9 +78,14 @@ export default function ProofOfYieldPage() {
{/* 資金成長曲線 */}
<div className="bg-white p-6 rounded-lg border border-stone-200 shadow-sm mb-10 h-96">
<h3 className="text-lg font-bold text-stone-800 mb-6">Equity Curve ()</h3>
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={equityData} margin={{ top: 10, right: 30, left: 0, bottom: 0 }}>
<h3 className="text-lg font-bold text-stone-800 mb-6"></h3>
{records.length === 0 ? (
<div className="flex h-full items-center justify-center text-sm text-stone-500">
</div>
) : (
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={equityData} margin={{ top: 10, right: 30, left: 0, bottom: 0 }}>
<defs>
<linearGradient id="colorEquity" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#ea580c" stopOpacity={0.4}/>
@@ -93,7 +98,8 @@ export default function ProofOfYieldPage() {
<Tooltip contentStyle={{ backgroundColor: '#1A1A1A', borderColor: '#ea580c', color: '#FAF6F0', fontFamily: '"DotGothic16", monospace' }} />
<Area type="monotone" dataKey="equity" stroke="#ea580c" strokeWidth={3} fillOpacity={1} fill="url(#colorEquity)" />
</AreaChart>
</ResponsiveContainer>
</ResponsiveContainer>
)}
</div>
{/* 歷史注單明細表 */}
@@ -101,11 +107,11 @@ export default function ProofOfYieldPage() {
<table className="w-full text-left border-collapse">
<thead>
<tr className="bg-stone-100 text-stone-600 text-sm uppercase tracking-wider">
<th className="p-4 font-semibold">Date</th>
<th className="p-4 font-semibold">Match / Selection</th>
<th className="p-4 font-semibold">CLV</th>
<th className="p-4 font-semibold">Result</th>
<th className="p-4 font-semibold">Profit/Loss</th>
<th className="p-4 font-semibold"></th>
<th className="p-4 font-semibold"> / </th>
<th className="p-4 font-semibold"></th>
<th className="p-4 font-semibold"></th>
<th className="p-4 font-semibold"></th>
</tr>
</thead>
<tbody className="divide-y divide-stone-200 font-dotmatrix text-lg">
@@ -118,7 +124,7 @@ export default function ProofOfYieldPage() {
<td className="p-4 text-stone-500">{bet.settled_at.split('T')[0]}</td>
<td className="p-4 text-stone-800 font-sans font-medium">{bet.match_id} <span className="text-stone-400 mx-2">|</span> <span className="text-quant-orange">{bet.selection}</span></td>
<td className="p-4 text-quant-red">{bet.clv_percent !== null ? `${bet.clv_percent.toFixed(2)}%` : '-'}</td>
<td className={`p-4 font-bold ${bet.is_win ? 'text-green-600' : 'text-stone-400'}`}>{bet.is_win ? 'WIN' : 'LOSS'}</td>
<td className={`p-4 font-bold ${bet.is_win ? 'text-green-600' : 'text-stone-400'}`}>{bet.is_win ? '命中' : '未中'}</td>
<td className={`p-4 ${bet.pnl > 0 ? 'text-green-600' : 'text-red-500'}`}>{bet.pnl > 0 ? '+' : ''}{bet.pnl.toFixed(2)}</td>
</tr>
))}

View File

@@ -29,7 +29,7 @@ const metricLabelMap: Record<Metric, string> = {
passes: '傳球',
};
export default function PropsPage() {
export default function PlayerPropsPage() {
const [playerName, setPlayerName] = useState('Kylian Mbappé');
const [opponentName, setOpponentName] = useState('墨西哥');
const [metric, setMetric] = useState<Metric>('shots');
@@ -132,7 +132,12 @@ export default function PropsPage() {
return (
<div className="space-y-4">
<h2 className="dot-matrix text-2xl text-[#7d2a15]">Player Props</h2>
<h2 className="dot-matrix text-2xl text-[#7d2a15]"></h2>
<section className="panel-glow rounded-2xl border-dashed p-4">
<p className="text-sm leading-6 text-[#7a5b46]">
</p>
</section>
<section className="panel-glow rounded-2xl p-4">
<h3 className="dot-matrix text-lg text-[#7d2a15]"></h3>

View File

@@ -28,7 +28,7 @@ export default function RlmPage() {
const data = await detectReverseLineMovement(payload);
setResult(data);
} catch (error) {
setErrorMessage(error instanceof Error ? error.message : 'RLM 偵測暫時無法使用');
setErrorMessage(error instanceof Error ? error.message : '反向盤口偵測暫時無法使用');
setResult(null);
} finally {
setLoading(false);
@@ -37,7 +37,12 @@ export default function RlmPage() {
return (
<div className="space-y-4">
<h2 className="dot-matrix text-2xl text-[#7d2a15]">RLM</h2>
<h2 className="dot-matrix text-2xl text-[#7d2a15]"></h2>
<section className="panel-glow rounded-2xl border-dashed p-4">
<p className="text-sm leading-6 text-[#7a5b46]">
</p>
</section>
<section className="panel-glow rounded-2xl p-4">
<h3 className="dot-matrix text-lg text-[#7d2a15]"></h3>
@@ -94,15 +99,22 @@ export default function RlmPage() {
onClick={runRlm}
disabled={loading}
>
{loading ? '偵測中…' : '執行 RLM 偵測'}
{loading ? '偵測中…' : '執行反向盤口偵測'}
</button>
</div>
{errorMessage ? <p className="mt-3 text-xs text-[#8c2f2f]">{errorMessage}</p> : null}
</section>
<RLMRadarBoard
matchId={matchId}
alerts={result?.alerts || []}
alerts={(result?.alerts || []).map((a) => ({
matchName: a.match_id,
market: a.market_type,
selection: a.selection,
ticketPct: a.ticket_pct,
handlePct: a.handle_pct,
openingOdds: a.opening_odds,
currentOdds: a.current_odds,
}))}
/>
<section className="panel-glow rounded-2xl p-4">
<p className="text-sm text-[#7a5b46]">

View File

@@ -1,14 +1,38 @@
import { MoneyFlowBar } from '@/components/MoneyFlowBar';
import Link from 'next/link';
export default function SharpMoneyPage() {
return (
<div className="space-y-4">
<h2 className="dot-matrix text-2xl text-[#7d2a15]"></h2>
<div className="space-y-5">
<section className="panel-glow rounded-2xl p-6">
<p className="dot-matrix text-xs font-bold text-[#b83822]"> / </p>
<h2 className="mt-2 text-3xl font-black text-[#3f2f25]"></h2>
<p className="mt-3 text-sm leading-7 text-[#7a5b46]">
</p>
</section>
<section className="grid gap-4 md:grid-cols-3">
{[
['目前狀態', '等待可驗證資金流資料源', '不顯示假票數、不顯示假資金比例。'],
['現在該看', '每日作戰室與盤口覆蓋', '先以賠率門檻、資料新鮮度與注碼上限決策。'],
['接入後會顯示', '票數、金額、異常移動時間', '只有同一場、同一玩法、同一選項才會比較。'],
].map(([title, value, detail]) => (
<article key={title} className="panel-glow rounded-2xl p-5">
<p className="text-xs font-bold text-[#8a6b58]">{title}</p>
<p className="mt-2 text-lg font-black text-[#7d2a15]">{value}</p>
<p className="mt-2 text-sm leading-6 text-[#7a5b46]">{detail}</p>
</article>
))}
</section>
<section className="panel-glow rounded-2xl p-5">
<p className="text-sm text-[#7a5b46]"> Sharp Money </p>
<div className="mt-4 grid gap-3 md:grid-cols-2">
<MoneyFlowBar label="德國 vs 西班牙" ticketPct={73} handlePct={35} />
<MoneyFlowBar label="巴西 vs 法國" ticketPct={42} handlePct={72} />
<h3 className="dot-matrix text-lg text-[#7d2a15]"></h3>
<p className="mt-2 text-sm leading-6 text-[#7a5b46]">
</p>
<div className="mt-4 flex flex-wrap gap-3">
<Link href="/daily-card" className="rounded-full bg-[#7d2a15] px-4 py-2 text-sm font-bold text-white"></Link>
<Link href="/market-coverage" className="rounded-full border border-[#d8b58c] bg-white/75 px-4 py-2 text-sm font-bold text-[#7d2a15]"></Link>
</div>
</section>
</div>

View File

@@ -2,36 +2,273 @@
import type { DailyCardItem } from '@/lib/analytics-api';
type Props = {
type ActionableBetCardProps = {
item: DailyCardItem;
onAddToSlip: (item: DailyCardItem) => void;
isInSlip?: boolean;
className?: string;
};
export function ActionableBetCard({ item, onAddToSlip, className = '' }: Props) {
function isConditionalPick(item: DailyCardItem): boolean {
return (
<article className={`group relative overflow-hidden rounded-2xl border border-[#dfc091] bg-[#fff8e6] p-4 transition ${className}`}>
<p className="dot-matrix text-sm font-semibold text-[#7d2a15]">{item.recommendation}</p>
<h3 className="mt-1 text-lg text-[#6b3f2d]">{item.match_label}</h3>
<p className="mt-1 text-sm text-[#7a5b46]">{item.market_type}</p>
<p className="mt-1 text-sm text-[#7d2a15]">{item.selection}</p>
<div className="mt-2 grid gap-1 text-sm text-[#6d4d39]">
<p><span className="font-semibold text-[#8c2f2f]">{item.target_odds.toFixed(2)}</span></p>
<p><span className="font-semibold text-[#7d2a15]">{item.win_prob.toFixed(2)}%</span></p>
<p>EV<span className="font-semibold text-[#7d2a15]">{item.ev_percent.toFixed(2)}%</span></p>
<p><span className="font-semibold text-[#7d2a15]">{item.stake_units.toFixed(2)} Units</span></p>
item.has_market_odds === false ||
item.selection.includes('預掛條件') ||
item.selection.includes('參考盤監控') ||
item.rationale.includes('尚未取得可用即時盤口') ||
item.rationale.includes('尚未取得完整實盤賠率') ||
Boolean(item.legs?.some((leg) => leg.selection.includes('預掛條件')))
);
}
function isReferenceMarket(item: DailyCardItem): boolean {
return item.odds_source_kind === 'reference_market';
}
function recommendationLabel(item: DailyCardItem): string {
const isConditional = isConditionalPick(item);
if (item.recommendation === 'SAFE_SINGLE') return isConditional ? '觀察單關' : '核心單關';
if (item.recommendation === 'HIGH_RISK_SINGLE') return '小注高賠';
if (item.recommendation === 'SAFE_PARLAY') return isConditional ? '觀察串關' : '跨場串關';
if (item.recommendation === 'SGP_LOTTERY') return '同場小注';
return '研究候選';
}
function riskLabel(value?: string): string {
if (value === 'core') return '核心候選';
if (value === 'speculative') return '小注高波動';
if (value === 'parlay') return '串關組合';
if (value === 'sgp') return '同場高波動';
return '研究候選';
}
function betMode(item: DailyCardItem): string {
if (item.recommendation === 'SAFE_PARLAY') return '到投注平台選「串關」,依下方每一腿逐一加入。';
if (item.recommendation === 'SGP_LOTTERY') return '到投注平台選「同場串關」,只用很小的注碼。';
return '到投注平台選「單關」,找到同一場比賽與同一個市場後下注。';
}
function formatTwd(value: number): string {
return new Intl.NumberFormat('zh-TW', {
style: 'currency',
currency: 'TWD',
maximumFractionDigits: 0,
}).format(value);
}
function stakeAmountTwd(item: DailyCardItem): number {
const unitSize = item.unit_size_twd ?? 1000;
return item.stake_amount_twd ?? Math.round(item.stake_units * unitSize);
}
function stakeGuide(item: DailyCardItem): string {
const unitSize = item.unit_size_twd ?? 1000;
return `建議參考上限 ${formatTwd(stakeAmountTwd(item))},約 ${item.stake_units.toFixed(2)}u系統目前以 1u 等於 ${formatTwd(unitSize)} 換算。`;
}
function oddsRule(item: DailyCardItem): string {
const source = item.odds_source_label ? `目前來源:${item.odds_source_label}` : '';
return `${source}只有在你拿到的賠率大於或等於 ${item.target_odds.toFixed(2)} 時才考慮下注;如果平台賠率低於這個數字,期望值會被吃掉,直接跳過。`;
}
function executionStatusLabel(item: DailyCardItem): string {
if (isReferenceMarket(item)) return '台灣盤參考';
return isConditionalPick(item) ? '預掛條件' : '實盤可檢查';
}
function executionStatusDetail(item: DailyCardItem): string {
if (isReferenceMarket(item)) {
return '這張已有台灣運彩公開盤作為參考價,可以用來比對最低可接受賠率;但它仍不是多莊家正式盤,下注前要再確認同一玩法、同一分線與最新盤口。';
}
if (isConditionalPick(item)) {
return '目前尚未取得完整實盤或分線盤口,這張不是叫你立刻下注,而是先設定最低賠率門檻。等平台開盤後,只有賠率達標才進場。';
}
return '這張已有可比對的盤口資料,仍需在下注前確認同一市場、同一選項、同一分線與最低可接受賠率都一致。';
}
function dataQualityLabel(item: DailyCardItem): string {
if (isReferenceMarket(item)) return '資料品質:台灣運彩參考盤,已和模型門檻比對';
if (item.data_quality === 'rank_elo_prior') return '資料品質:國際排名與實力分數估計,信心已降低';
if (item.data_quality === 'fallback_prior') return '資料品質:基礎估計,僅能觀察';
if (item.data_quality === 'mixed') return '資料品質:串關腿數混合來源';
if (isConditionalPick(item)) return '資料品質:等待實盤確認';
if (item.data_checks?.some((check) => check.includes('去水') || check.includes('莊家'))) return '資料品質:已檢查平台抽成影響';
return '資料品質:基礎盤口檢查';
}
function plainCheckLabel(check: string): string {
return check
.replaceAll('Poisson', '進球分布')
.replaceAll('xG', '預期進球')
.replaceAll('EV', '期望值')
.replaceAll('edge', '模型優勢')
.replaceAll('CLV', '收盤價差')
.replaceAll('FIFA 排名/Elo', '國際排名與實力分數')
.replaceAll('FIFA/Elo', '國際排名與實力分數')
.replaceAll('市場隱含機率', '市場估計機率')
.replaceAll('正 期望值', '期望值為正')
.replaceAll('正期望值', '期望值為正')
.replaceAll('小倉位', '小注碼')
.replaceAll('預掛總賠率門檻', '等待總賠率達標')
.replaceAll('串關 EV 重新計算', '串關期望值重新計算')
.replaceAll('高 期望值 門檻', '高期望值門檻');
}
function plainLanguage(item: DailyCardItem): string {
const implied = typeof item.market_implied_prob === 'number' ? item.market_implied_prob.toFixed(2) : null;
const edge = typeof item.edge_percent === 'number' ? item.edge_percent.toFixed(2) : null;
const isConditional = isConditionalPick(item);
if (implied && edge) {
const quality = item.data_quality && item.data_quality !== 'observed' ? ' 但此項目前資料品質不是完整即時盤,信心與倉位已被系統折扣。' : '';
const sourceNote = isReferenceMarket(item) ? ' 這裡的市場機率來自台灣運彩參考盤,不是多莊家共識。' : '';
if (isConditional) {
return `模型估這個選項有 ${item.win_prob.toFixed(2)}% 機率打出;目前還沒有完整可驗證賠率,所以 ${implied}% 代表「最低可接受賠率」換算出的門檻,不是真實市場共識。模型多看好 ${edge} 個百分點,期望值 ${item.ev_percent.toFixed(2)}% 代表長期同類型條件可能有優勢,不代表這場一定會中。${quality}`;
}
return `模型估這個選項有 ${item.win_prob.toFixed(2)}% 機率打出,市場目前大約只反映 ${implied}% 的機率,等於模型多看好 ${edge} 個百分點。期望值 ${item.ev_percent.toFixed(2)}% 代表長期同類型下注的平均報酬為正,不代表這場一定會中。${sourceNote}${quality}`;
}
return `模型估這個選項有 ${item.win_prob.toFixed(2)}% 機率打出,期望值 ${item.ev_percent.toFixed(2)}%。這是長期平均報酬判斷,不是單場保證;若資料品質不是完整即時盤,請只列入觀察。`;
}
export function ActionableBetCard({ item, onAddToSlip, isInSlip = false, className = '' }: ActionableBetCardProps) {
const confidence = typeof item.confidence_score === 'number' ? item.confidence_score : null;
const checks = item.data_checks ?? [];
const confidenceFactors = item.confidence_factors ?? [];
const isCombo = item.legs?.length;
const isConditional = isConditionalPick(item);
const referenceMarket = isReferenceMarket(item);
const actionLabel = isInSlip
? isConditional ? '已在賠率監控清單' : '已在下注前檢查清單'
: isConditional ? '加入賠率監控清單' : '加入下注前檢查清單';
return (
<article className={`group relative overflow-hidden rounded-2xl border border-[#e7c89b] bg-[#fff8e6] p-5 shadow-[0_18px_48px_rgba(125,42,21,0.12)] transition hover:border-[#d1432d] ${className}`}>
<div className="flex flex-wrap items-center gap-2">
<p className="dot-matrix text-xs font-semibold tracking-wider text-[#b83822]">{recommendationLabel(item)}</p>
<span className="rounded-full border border-[#d8b58c] bg-white/70 px-2 py-1 text-[11px] font-semibold text-[#7d2a15]">{riskLabel(item.risk_level)}</span>
<span className={`rounded-full border px-2 py-1 text-[11px] font-semibold ${
isConditional
? 'border-[#b88700]/40 bg-[#fff7d6] text-[#8a6400]'
: referenceMarket
? 'border-[#d8b58c] bg-[#fff0e2] text-[#7d2a15]'
: 'border-[#1a9a57]/30 bg-[#e9f8ef] text-[#167a47]'
}`}>
{executionStatusLabel(item)}
</span>
{item.odds_source_label ? (
<span className="rounded-full border border-[#d8b58c] bg-white/70 px-2 py-1 text-[11px] font-semibold text-[#5f4330]">
{item.odds_source_label}
</span>
) : null}
{confidence !== null ? (
<span className="rounded-full border border-[#1a9a57]/30 bg-[#e9f8ef] px-2 py-1 text-[11px] font-semibold text-[#167a47]">
{confidence.toFixed(1)}
</span>
) : null}
{item.confidence_band ? (
<span className="rounded-full border border-[#d8b58c] bg-white/70 px-2 py-1 text-[11px] font-semibold text-[#7d2a15]">
{item.confidence_band}
</span>
) : null}
</div>
<p className="mt-2 text-sm text-[#6a4f3a]">{item.rationale}</p>
<h3 className="mt-3 text-xl font-black text-[#3f2f25]">{item.match_label}</h3>
<p className="mt-1 text-sm text-[#7a5b46]">{item.market_type}</p>
<p className="mt-1 text-base font-bold text-[#b83822]">{item.selection}</p>
<section className={`mt-4 rounded-2xl border p-4 ${
isConditional ? 'border-[#e7c462] bg-[#fff7d6]' : 'border-[#9bd8b0] bg-[#e9f8ef]'
}`}>
<p className="dot-matrix text-sm font-bold text-[#7d2a15]"></p>
<p className="mt-2 text-sm leading-6 text-[#5f4330]">{executionStatusDetail(item)}</p>
<p className="mt-2 text-xs font-bold text-[#7a5b46]">{dataQualityLabel(item)}</p>
</section>
<section className="mt-4 rounded-2xl border border-[#eadcb9] bg-white/75 p-4">
<p className="dot-matrix text-sm font-bold text-[#7d2a15]"></p>
<p className="mt-2 text-sm leading-6 text-[#5f4330]">{betMode(item)}</p>
<div className="mt-3 grid gap-2 text-sm text-[#5f4330] md:grid-cols-2">
<p><span className="font-bold text-[#3f2f25]">{item.market_type}</span></p>
<p><span className="font-bold text-[#3f2f25]">{item.selection}</span></p>
<p><span className="font-bold text-[#b83822]">{item.target_odds.toFixed(2)}</span></p>
<p><span className="font-bold text-[#167a47]">{formatTwd(stakeAmountTwd(item))}</span></p>
</div>
<p className="mt-3 rounded-xl bg-[#fff0e2] p-3 text-sm leading-6 text-[#7d2a15]">{oddsRule(item)}</p>
<p className="mt-2 text-xs leading-5 text-[#8a6b58]">{stakeGuide(item)}</p>
</section>
{isCombo ? (
<section className="mt-4 rounded-2xl border border-[#eadcb9] bg-white/75 p-4">
<p className="dot-matrix text-sm font-bold text-[#7d2a15]"></p>
<div className="mt-2 space-y-2 text-sm text-[#5f4330]">
{item.legs?.map((leg, index) => (
<p key={`${leg.match_id}-${leg.selection}`}>
{index + 1} <span className="font-bold text-[#3f2f25]">{leg.selection}</span> {leg.odds.toFixed(2)}
</p>
))}
</div>
</section>
) : null}
<section className="mt-4 rounded-2xl border border-[#eadcb9] bg-white/75 p-4">
<p className="dot-matrix text-sm font-bold text-[#7d2a15]"></p>
<p className="mt-2 text-sm leading-7 text-[#5f4330]">{plainLanguage(item)}</p>
<div className="mt-3 grid gap-2 text-sm text-[#5f4330] md:grid-cols-2">
<p><span className="font-bold text-[#167a47]">{item.win_prob.toFixed(2)}%</span></p>
{typeof item.market_implied_prob === 'number' ? (
<p>{isConditional ? '賠率門檻換算機率' : '市場估計機率'}<span className="font-bold text-[#3f2f25]">{item.market_implied_prob.toFixed(2)}%</span></p>
) : null}
{typeof item.edge_percent === 'number' ? <p><span className="font-bold text-[#b88700]">{item.edge_percent.toFixed(2)} </span></p> : null}
<p><span className="font-bold text-[#167a47]">{item.ev_percent.toFixed(2)}%</span></p>
</div>
</section>
{confidenceFactors.length || checks.length ? (
<section className="mt-4 rounded-2xl border border-[#eadcb9] bg-white/75 p-4">
<p className="dot-matrix text-sm font-bold text-[#7d2a15]"></p>
<div className="mt-3 flex flex-wrap gap-2">
{confidenceFactors.slice(0, 5).map((factor) => (
<span key={factor} className="rounded-full bg-[#e9f8ef] px-3 py-1 text-xs font-semibold text-[#167a47]">
{plainCheckLabel(factor)}
</span>
))}
{checks.slice(0, 6).map((check) => (
<span key={check} className="rounded-full bg-[#fff0e2] px-3 py-1 text-xs font-semibold text-[#7a5b46]">
{plainCheckLabel(check)}
</span>
))}
</div>
</section>
) : null}
<section className="mt-4 rounded-2xl border border-[#d8b58c] bg-[#fff0e2] p-4">
<p className="dot-matrix text-sm font-bold text-[#7d2a15]"></p>
<p className="mt-2 text-sm leading-6 text-[#7a5b46]">
</p>
<p className="mt-2 text-sm leading-6 text-[#7a5b46]">
</p>
</section>
<button
type="button"
className="mt-3 inline-block rounded-full bg-[#7d2a15] px-4 py-2 text-sm text-white opacity-0 transition hover:bg-[#5f1f11] group-hover:opacity-100"
aria-pressed={isInSlip}
className={`mt-4 inline-block w-full rounded-full px-4 py-3 text-sm font-black text-white transition ${
isInSlip ? 'bg-[#167a47] hover:bg-[#11613a]' : 'bg-[#d1432d] hover:bg-[#b83822]'
}`}
onClick={() => onAddToSlip(item)}
>
Add to Slip
{actionLabel}
</button>
<div className="absolute right-[-24px] top-1/2 h-24 w-2/5 -translate-y-1/2 rotate-6 rounded-full bg-gradient-to-r from-[#d1432d]/35 to-transparent opacity-0 blur-[12px] transition group-hover:opacity-100" />
{isInSlip ? (
<p role="status" className="mt-3 rounded-xl border border-[#9bd8b0] bg-[#e9f8ef] p-3 text-center text-xs font-bold text-[#167a47]">
</p>
) : null}
</article>
);
}

View File

@@ -12,12 +12,11 @@ import {
} from 'recharts';
import type {
PortfolioHardTruth,
PortfolioLeakCluster,
PortfolioLeaksResponse,
} from '@/lib/analytics-api';
type Props = {
type BettingLeaksDashboardProps = {
data: PortfolioLeaksResponse;
};
@@ -58,7 +57,7 @@ function computeMaxDrawdown(points: { balance: number }[]) {
return Number(maxDD.toFixed(2));
}
export function BettingLeaksDashboard({ data }: Props) {
export function BettingLeaksDashboard({ data }: BettingLeaksDashboardProps) {
const [onlyPositiveAndHigh, setOnlyPositiveAndHigh] = useState(false);
const clusters = useMemo(() => {
@@ -81,7 +80,7 @@ export function BettingLeaksDashboard({ data }: Props) {
<p className="mt-2 text-2xl font-semibold text-[#7d2a15]">{data.total_bet_count}</p>
</article>
<article className="panel-glow rounded-xl p-3">
<p className="text-xs text-[#7a5b46]"> ROI</p>
<p className="text-xs text-[#7a5b46]"></p>
<p className={`mt-2 text-2xl font-semibold ${data.overall_roi_percent >= 0 ? 'text-[#1a9a57]' : 'text-[#8c2f2f]'}`}>
{data.overall_roi_percent.toFixed(2)}%
</p>
@@ -106,7 +105,7 @@ export function BettingLeaksDashboard({ data }: Props) {
onlyPositiveAndHigh ? 'bg-[#7d2a15] text-white' : 'bg-white/80 text-[#5f4330]'
}`}
>
EV
</button>
</section>
@@ -153,7 +152,7 @@ export function BettingLeaksDashboard({ data }: Props) {
<th className="py-2 text-left"></th>
<th className="py-2 text-left"></th>
<th className="py-2 text-left"></th>
<th className="py-2 text-left">ROI</th>
<th className="py-2 text-left"></th>
<th className="py-2 text-left"></th>
<th className="py-2 text-left"></th>
</tr>

View File

@@ -15,13 +15,13 @@ type Point = {
capital: number;
};
type Props = {
type = {
title: string;
points: Point[];
maxDrawdown: number;
};
export function EquityCurveChart({ title, points, maxDrawdown }: Props) {
export function EquityCurveChart({ title, points, maxDrawdown }: ) {
return (
<article className="panel-glow rounded-2xl p-4">
<h3 className="dot-matrix text-lg text-[#7d2a15]">{title}</h3>

View File

@@ -1,6 +1,6 @@
'use client';
type Props = {
type HedgeAlertProps = {
isVisible: boolean;
parlayLabel: string;
counterSelection: string;
@@ -8,7 +8,7 @@ type Props = {
lockedProfit: number;
};
export function HedgeAlert({ isVisible, parlayLabel, counterSelection, hedgeStake, lockedProfit }: Props) {
export function HedgeAlert({ isVisible, parlayLabel, counterSelection, hedgeStake, lockedProfit }: HedgeAlertProps) {
if (!isVisible) {
return null;
}

View File

@@ -34,7 +34,7 @@ export default function KellyBetSizing({ trueProb, decimalOdds }: KellyBetSizing
<div className="flex justify-between items-center border-b border-stone-200 pb-4 mb-6">
<div>
<h3 className="text-sm text-stone-500 uppercase tracking-wider font-semibold">
Kelly Criterion
</h3>
<h2 className="text-xl text-stone-900 font-bold"></h2>
</div>
@@ -49,7 +49,7 @@ export default function KellyBetSizing({ trueProb, decimalOdds }: KellyBetSizing
{/* 控制區:總資金 */}
<div className="mb-6">
<label className="block text-sm text-stone-600 font-medium mb-2">
(Bankroll): <span className="font-dotmatrix ml-2 text-lg">${bankroll.toLocaleString()}</span>
<span className="font-dotmatrix ml-2 text-lg">${bankroll.toLocaleString()}</span>
</label>
<input
type="range"
@@ -65,7 +65,7 @@ export default function KellyBetSizing({ trueProb, decimalOdds }: KellyBetSizing
{/* 控制區:風險偏好 (分數凱利) */}
<div className="mb-2">
<label className="block text-sm text-stone-600 font-medium mb-2">
(Kelly Multiplier):
<span className={`font-dotmatrix ml-2 text-lg ${isHighRisk ? 'text-quant-red' : 'text-stone-900'}`}>
{kellyFraction}x
</span>
@@ -80,9 +80,9 @@ export default function KellyBetSizing({ trueProb, decimalOdds }: KellyBetSizing
className="w-full h-2 bg-stone-200 rounded-lg appearance-none cursor-pointer accent-stone-700"
/>
<div className="flex justify-between text-xs text-stone-400 mt-2 font-dotmatrix uppercase">
<span>Conservative (0.1x)</span>
<span>Optimal (0.25x)</span>
<span className="text-quant-red">Full Risk (1.0x)</span>
<span> 0.1x</span>
<span> 0.25x</span>
<span className="text-quant-red"> 1.0x</span>
</div>
</div>
</div>

View File

@@ -2,70 +2,20 @@
import React from 'react';
// 模擬的大神資料
const leaderboardData = [
{ rank: 1, name: 'QuantKing99', clv: '+8.4%', roi: '+65.2%', isSharp: true },
{ rank: 2, name: 'RLM_Hunter', clv: '+7.1%', roi: '+52.8%', isSharp: true },
{ rank: 3, name: 'AlphaBet', clv: '+6.5%', roi: '+48.1%', isSharp: true },
{ rank: 4, name: 'NormalGuy', clv: '+1.2%', roi: '+5.4%', isSharp: false },
{ rank: 5, name: 'LuckyBettor', clv: '-2.1%', roi: '+12.5%', isSharp: false },
];
export default function LeaderboardBoard() {
const handleCopyBet = (bettorName: string) => {
alert(`[系統提示] 已啟動一鍵跟單 ${bettorName}\n系統將自動按凱利比例配置您的資金。`);
// 實務上這裡會呼叫後端建立跟單關係,並計算注碼
};
return (
<div className="w-full bg-stone-900 p-6 rounded-lg border border-stone-700 shadow-xl">
<div className="mb-6">
<h2 className="text-2xl text-stone-100 font-dotmatrix uppercase font-bold tracking-wider border-b border-stone-700 pb-2">
🏆 (Social Trading)
</h2>
<p className="text-stone-400 text-sm mt-1"> CLV () 30 ROI </p>
<p className="text-stone-400 text-sm mt-1">
</p>
</div>
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse">
<thead>
<tr className="bg-stone-800 text-stone-400 text-sm font-dotmatrix uppercase">
<th className="p-3 font-semibold">Rank</th>
<th className="p-3 font-semibold">Bettor</th>
<th className="p-3 font-semibold">Avg CLV</th>
<th className="p-3 font-semibold">30D ROI</th>
<th className="p-3 font-semibold text-right">Action</th>
</tr>
</thead>
<tbody className="divide-y divide-stone-800 text-stone-200">
{leaderboardData.map((bettor) => (
<tr key={bettor.rank} className="hover:bg-stone-800 transition-colors">
<td className="p-3 font-bold font-dotmatrix text-lg">
{bettor.rank <= 3 ? <span className="text-quant-orange">#{bettor.rank}</span> : `#${bettor.rank}`}
</td>
<td className="p-3">
<div className="flex items-center space-x-2">
<span className={`font-semibold ${bettor.isSharp ? 'text-quant-red drop-shadow-[0_0_8px_rgba(234,88,12,0.8)]' : 'text-stone-300'}`}>
{bettor.name}
</span>
{bettor.isSharp && <span className="text-xs bg-quant-orange/20 text-quant-orange px-2 py-0.5 rounded uppercase font-dotmatrix">Sharp</span>}
</div>
</td>
<td className="p-3 font-dotmatrix text-quant-orange">{bettor.clv}</td>
<td className="p-3 font-dotmatrix text-green-500">{bettor.roi}</td>
<td className="p-3 text-right">
<button
onClick={() => handleCopyBet(bettor.name)}
className="bg-stone-700 hover:bg-quant-orange hover:text-stone-900 text-stone-300 font-dotmatrix uppercase text-sm px-4 py-2 rounded transition-all font-bold"
>
Copy Bet
</button>
</td>
</tr>
))}
</tbody>
</table>
<div className="rounded-lg border border-stone-700 bg-stone-950/70 p-4 text-sm leading-6 text-stone-300">
</div>
</div>
);

View File

@@ -21,7 +21,7 @@ type ZonePoint = {
pct: number;
};
type Props = {
type = {
timeline: {
minute: number;
label: string;
@@ -30,7 +30,7 @@ type Props = {
heatZones: ZonePoint[];
};
export function LiveMatchCenter({ timeline, xgSeries, heatZones }: Props) {
export function LiveMatchCenter({ timeline, xgSeries, heatZones }: ) {
return (
<section className="space-y-4">
<article className="panel-glow rounded-2xl p-4">
@@ -45,7 +45,7 @@ export function LiveMatchCenter({ timeline, xgSeries, heatZones }: Props) {
</article>
<article className="panel-glow rounded-2xl p-4">
<h3 className="dot-matrix text-xl text-[#7d2a15]">xG </h3>
<h3 className="dot-matrix text-xl text-[#7d2a15]"></h3>
<div className="mt-2 h-72 w-full">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={xgSeries}>
@@ -56,8 +56,8 @@ export function LiveMatchCenter({ timeline, xgSeries, heatZones }: Props) {
contentStyle={{ background: '#fff8e6', borderColor: '#d8b58c' }}
itemStyle={{ color: '#5f4031' }}
/>
<Bar dataKey="xgHome" fill="#b83822" name="主隊xG" />
<Bar dataKey="xgAway" fill="#7a4a2c" name="客隊xG" />
<Bar dataKey="xgHome" fill="#b83822" name="主隊預期進球" />
<Bar dataKey="xgAway" fill="#7a4a2c" name="客隊預期進球" />
</BarChart>
</ResponsiveContainer>
</div>
@@ -82,4 +82,3 @@ export function LiveMatchCenter({ timeline, xgSeries, heatZones }: Props) {
</section>
);
}

View File

@@ -58,4 +58,3 @@ export function MatchConditionsCard({
</article>
);
}

View File

@@ -9,10 +9,11 @@ type NavItem = {
};
const navItems: NavItem[] = [
{ href: '/daily-card', label: '每日作戰室' },
{ href: '/matches', label: '賽事' },
{ href: '/portfolio', label: '投資組合' },
{ href: '/proof-of-yield', label: '帳本' },
{ href: '/', label: '首頁' },
{ href: '/daily-card', label: '作戰' },
{ href: '/live-score', label: '比分' },
{ href: '/schedule', label: '賽程' },
{ href: '/source-health', label: '健康' },
];
export function MobileBottomNav() {
@@ -22,7 +23,7 @@ export function MobileBottomNav() {
<nav className="fixed inset-x-0 bottom-0 z-40 border-t border-[#e7cfa7] bg-[#f6f0e1]/95 shadow-[0_-8px_24px_rgba(98,58,34,0.12)] backdrop-blur md:hidden">
<div className="mx-auto flex max-w-7xl justify-between px-2 py-2">
{navItems.map((item) => {
const isActive = path === item.href;
const isActive = item.href === '/' ? path === '/' : path === item.href || path.startsWith(`${item.href}/`);
return (
<Link
key={item.href}

View File

@@ -1,12 +1,12 @@
'use client';
type MoneyFlowProps = {
type MoneyFlowBarProps = {
label: string;
ticketPct: number;
handlePct: number;
};
export function MoneyFlowBar({ label, ticketPct, handlePct }: MoneyFlowProps) {
export function MoneyFlowBar({ label, ticketPct, handlePct }: MoneyFlowBarProps) {
const alert = handlePct - ticketPct >= 20;
return (
@@ -40,4 +40,3 @@ export function MoneyFlowBar({ label, ticketPct, handlePct }: MoneyFlowProps) {
</article>
);
}

View File

@@ -22,7 +22,7 @@ interface OddsLineMovementChartProps {
teamName: string;
}
export default function OddsLineMovementChart({ data, teamName }: OddsLineMovementChartProps) {
export function OddsLineMovementChart({ data, teamName }: OddsLineMovementChartProps) {
return (
<div className="w-full h-72 bg-stone-50 p-4 rounded-lg border border-stone-200 shadow-sm">
<div className="mb-4">

View File

@@ -17,12 +17,12 @@ type Point = {
pnl: number;
};
type Props = {
type PerformanceLedgerProps = {
summary: ProofOfYieldSummary;
records: ProofOfYieldRecord[];
};
export function PerformanceLedger({ summary, records }: Props) {
export function PerformanceLedger({ summary, records }: PerformanceLedgerProps) {
const sorted = useMemo(
() => [...records].sort((a, b) => new Date(a.settled_at).getTime() - new Date(b.settled_at).getTime()),
[records],
@@ -60,13 +60,13 @@ export function PerformanceLedger({ summary, records }: Props) {
<p className="mt-2 text-2xl font-semibold text-[#7d2a15]">{summary.win_rate_percent.toFixed(1)}%</p>
</article>
<article className="panel-glow rounded-xl p-3">
<p className="text-xs text-[#7a5b46]">ROI</p>
<p className="text-xs text-[#7a5b46]"></p>
<p className={`mt-2 text-2xl font-semibold ${summary.roi_percent >= 10 ? 'text-[#d1432d]' : 'text-[#7d2a15]'}`}>
{summary.roi_percent.toFixed(2)}%
</p>
</article>
<article className="panel-glow rounded-xl p-3">
<p className="text-xs text-[#7a5b46]"> CLV</p>
<p className="text-xs text-[#7a5b46]"></p>
<p className="mt-2 dot-matrix text-2xl font-semibold text-[#7d2a15]">
{summary.avg_clv_percent.toFixed(2)}%
</p>
@@ -101,7 +101,7 @@ export function PerformanceLedger({ summary, records }: Props) {
</AreaChart>
</ResponsiveContainer>
</div>
<p className="mt-2 text-xs text-[#8c2f2f]"> CLV{maxClv.toFixed(2)}%</p>
<p className="mt-2 text-xs text-[#8c2f2f]">{maxClv.toFixed(2)}%</p>
</article>
<article className="panel-glow rounded-2xl p-4">
@@ -116,7 +116,7 @@ export function PerformanceLedger({ summary, records }: Props) {
<th className="py-2 text-left"></th>
<th className="py-2 text-left"></th>
<th className="py-2 text-left"></th>
<th className="py-2 text-left">CLV</th>
<th className="py-2 text-left"></th>
<th className="py-2 text-left"></th>
<th className="py-2 text-left">P/L</th>
</tr>
@@ -153,4 +153,3 @@ export function PerformanceLedger({ summary, records }: Props) {
</section>
);
}

View File

@@ -16,7 +16,7 @@ type MetricPoint = {
opponent: number;
};
type Props = {
type = {
playerName: string;
opponentName: string;
metrics: {
@@ -29,7 +29,7 @@ type Props = {
};
};
export function PlayerMatchupRadar({ playerName, opponentName, metrics }: Props) {
export function PlayerMatchupRadar({ playerName, opponentName, metrics }: ) {
const data: MetricPoint[] = [
{ metric: '攻擊', player: metrics.攻擊, opponent: Math.max(0, 100 - metrics.) },
{ metric: '運球', player: metrics.運球, opponent: Math.max(0, 100 - metrics.) },

View File

@@ -1,6 +1,6 @@
'use client';
type Props = {
type = {
playerName: string;
metricLabel: string;
line: number;
@@ -22,7 +22,7 @@ export function PropValueCard({
impliedProb,
edgePercent,
topEdge,
}: Props) {
}: ) {
return (
<article className={`panel-glow rounded-2xl border p-4 ${topEdge ? 'prop-top-edge' : ''}`}>
<p className="dot-matrix text-sm text-[#7d2a15]">{playerName} · {metricLabel}</p>
@@ -34,7 +34,7 @@ export function PropValueCard({
</div>
{topEdge ? (
<p className="mt-3 rounded-lg bg-[#f5c6b4]/60 p-2 text-xs text-[#8a2e17] dot-matrix">
Top Edge
</p>
) : (
<p className="mt-3 text-xs text-[#6f4f3c]"></p>

View File

@@ -2,6 +2,9 @@
import { useEffect } from 'react';
const APP_VERSION = '2026-06-14-ux-realtime-v3';
const APP_VERSION_KEY = 'fifa2026-app-version';
function toUint8Array(base64: string): Uint8Array {
const padding = '='.repeat((4 - (base64.length % 4)) % 4);
const base64Safe = (base64 + padding).replace(/-/g, '+').replace(/_/g, '/');
@@ -22,12 +25,22 @@ export function PwaBootstrap() {
return;
}
const setup = async () => {
try {
const registration = await navigator.serviceWorker.register('/sw.js');
await registration.update();
const setup = async () => {
try {
const previousVersion = window.localStorage.getItem(APP_VERSION_KEY);
const shouldPurgeOldCache = previousVersion !== APP_VERSION;
if (shouldPurgeOldCache && 'caches' in window) {
const keys = await caches.keys();
await Promise.all(keys.filter((key) => key.startsWith('wc2026-') || key.includes('fifa2026')).map((key) => caches.delete(key)));
window.localStorage.setItem(APP_VERSION_KEY, APP_VERSION);
}
if (Notification.permission === 'granted') {
const registration = await navigator.serviceWorker.register(`/sw.js?v=${APP_VERSION}`);
await registration.update();
registration.active?.postMessage({ type: 'app-version', version: APP_VERSION });
registration.waiting?.postMessage({ type: 'skip-waiting' });
if (Notification.permission === 'granted') {
const vapid = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY;
if (vapid) {
const existing = await registration.pushManager.getSubscription();

View File

@@ -9,7 +9,7 @@ const logoMap: Record<BookmakerCode, string> = {
draftkings: 'DraftKings',
};
type Props = {
type = {
bookmakerId: BookmakerCode;
matchId: string;
selection: string;
@@ -17,7 +17,7 @@ type Props = {
disabled?: boolean;
};
export function QuickBetButton({ bookmakerId, matchId, selection, odds, disabled = false }: Props) {
export function QuickBetButton({ bookmakerId, matchId, selection, odds, disabled = false }: ) {
const [isRedirecting, setIsRedirecting] = useState(false);
const label = logoMap[bookmakerId];
@@ -33,9 +33,10 @@ export function QuickBetButton({ bookmakerId, matchId, selection, odds, disabled
}),
[bookmakerId, matchId, selection, odds],
);
const isUnavailable = disabled || !href;
const handleClick = () => {
if (disabled) {
if (disabled || !href) {
return;
}
setIsRedirecting(true);
@@ -47,12 +48,13 @@ export function QuickBetButton({ bookmakerId, matchId, selection, odds, disabled
<button
type="button"
onClick={handleClick}
disabled={disabled}
disabled={isUnavailable}
title={href ? '開啟已驗證投注平台連結' : '尚未接入合法且可驗證的投注平台一鍵帶入連結'}
className={`group relative inline-flex min-w-40 items-center justify-center rounded-full bg-[#7d2a15] px-4 py-2 text-sm text-white transition ${
disabled ? 'cursor-not-allowed opacity-60' : 'hover:bg-[#5f1f11]'
isUnavailable ? 'cursor-not-allowed opacity-60' : 'hover:bg-[#5f1f11]'
}`}
>
<span className="dot-matrix text-sm">{isRedirecting ? '入中...' : `${label} ${oddsText}`}</span>
<span className="dot-matrix text-sm">{!href ? `${label} 待接入` : isRedirecting ? '入中...' : `${label} ${oddsText}`}</span>
{!isRedirecting ? (
<span className="absolute -top-1 -right-2 hidden rounded-full bg-[#d1432d] px-2 py-0.5 text-[10px] text-white transition group-hover:inline">{oddsText}</span>
) : null}

View File

@@ -16,7 +16,7 @@ interface RlmRadarBoardProps {
alerts: RlmAlertData[];
}
export default function RlmRadarBoard({ alerts }: RlmRadarBoardProps) {
export function RLMRadarBoard({ alerts }: RlmRadarBoardProps) {
if (!alerts || alerts.length === 0) {
return (
<div className="w-full bg-stone-900 p-6 rounded-lg border border-stone-700 shadow-xl flex items-center justify-center min-h-[200px]">
@@ -35,7 +35,7 @@ export default function RlmRadarBoard({ alerts }: RlmRadarBoardProps) {
<div className="mb-6 flex items-center justify-between">
<div>
<h2 className="text-2xl text-quant-red font-dotmatrix animate-blink uppercase font-bold tracking-wider">
🚨 (RLM)
🚨 ()
</h2>
<p className="text-stone-400 text-sm mt-1"></p>
</div>

View File

@@ -49,15 +49,17 @@ export function useLiveMatchData(matchId: string | null) {
}
if (message.eventType === 'odds') {
const payload = message.payload as OddsPayload;
setOdds((prev) => {
const next = [...prev.filter((item) => item.market !== message.payload.market), message.payload];
const next = [...prev.filter((item) => item.market !== payload.market), payload];
return next;
});
return;
}
if (message.eventType === 'event') {
setEvents((prev) => [message.payload, ...prev].slice(0, 80));
const payload = message.payload as MatchEventPayload;
setEvents((prev) => [payload, ...prev].slice(0, 80));
}
};

View File

@@ -202,7 +202,11 @@ export type RlmAlert = {
odds_change_pct: number;
smart_money_to: string;
is_triggered: boolean;
rationale: string;
confidence_score?: number;
risk_level?: 'core' | 'speculative' | 'parlay' | 'sgp' | string;
market_implied_prob?: number;
edge_percent?: number;
data_checks?: string[];
};
export type RlmResponse = {
@@ -333,15 +337,35 @@ export type DailyCardItem = {
win_prob: number;
ev_percent: number;
stake_units: number;
stake_amount_twd?: number;
unit_size_twd?: number;
recommendation: string;
rationale: string;
confidence_score?: number;
confidence_band?: string;
confidence_factors?: string[];
data_quality?: string;
has_market_odds?: boolean;
odds_source_label?: string;
odds_source_kind?: string;
risk_level?: 'core' | 'speculative' | 'parlay' | 'sgp' | string;
market_implied_prob?: number;
edge_percent?: number;
data_checks?: string[];
legs?: DailyCardLeg[];
};
export type DailyCardResponse = {
date: string;
generated_at?: string;
total_daily_unit_recommendation: number;
total_daily_amount_twd?: number;
unit_size_twd?: number;
summary: string;
market_data_status?: string | null;
data_quality_summary?: Record<string, number>;
execution_policy?: string | null;
auto_refresh_seconds?: number;
safe_singles: DailyCardItem[];
high_risk_singles: DailyCardItem[];
safe_parlays: DailyCardItem[];
@@ -350,10 +374,184 @@ export type DailyCardResponse = {
stage_distribution: Record<string, number>;
};
export type DailyCardCalendarDate = {
date: string;
match_count: number;
matched_matches: number;
recommendation_count: number;
live_count: number;
watch_count: number;
safe_single_count: number;
high_risk_single_count: number;
safe_parlay_count: number;
sgp_lottery_count: number;
total_amount_twd: number;
market_data_status?: string | null;
snapshot_status?: string | null;
snapshot_item_count?: number | null;
snapshot_preserved_count?: number | null;
summary?: string | null;
};
export type DailyCardCalendarResponse = {
generated_at: string;
start_date: string;
end_date: string;
dates: DailyCardCalendarDate[];
};
export type RecommendationPerformanceItem = {
match_id: string;
match_label: string;
market_type: string;
selection: string;
recommendation: string;
result_score: string;
outcome: 'hit' | 'miss' | 'push' | 'not_evaluable' | string;
outcome_label: string;
target_odds: number;
win_prob: number;
ev_percent: number;
stake_units: number;
stake_amount_twd?: number | null;
confidence_score?: number | null;
confidence_band?: string | null;
has_market_odds?: boolean | null;
odds_source_label?: string | null;
odds_source_kind?: string | null;
lesson: string;
};
export type RecommendationPerformanceBucket = {
market_type: string;
recommendation_count: number;
settled_count: number;
hit_count: number;
miss_count: number;
push_count: number;
hit_rate_percent: number;
};
export type RecommendationPerformanceSourceBucket = {
source_label: string;
source_kind: string;
recommendation_count: number;
settled_count: number;
hit_count: number;
miss_count: number;
push_count: number;
hit_rate_percent: number;
};
export type RecommendationPerformanceResponse = {
generated_at: string;
days_back: number;
finished_match_count: number;
rebuilt_recommendation_count: number;
settled_recommendation_count: number;
hit_count: number;
miss_count: number;
push_count: number;
hit_rate_percent: number;
summary: string;
methodology_note: string;
improvement_actions: string[];
by_market_type: RecommendationPerformanceBucket[];
by_odds_source: RecommendationPerformanceSourceBucket[];
items: RecommendationPerformanceItem[];
};
export type AgentVerificationCheck = {
agent: string;
role: string;
status: string;
status_label: string;
evidence: string[];
next_action?: string | null;
last_checked_at: string;
};
export type AgentVerificationResponse = {
generated_at: string;
overall_status: string;
overall_label: string;
production_ready: boolean;
decision_policy: string;
calibration_summary: Record<string, unknown>;
checks: AgentVerificationCheck[];
};
export type GeminiUsageResponse = {
generated_at: string;
month: string;
status: string;
status_label: string;
paused: boolean;
cap_usd: number;
estimated_cost_usd: number;
remaining_usd: number;
request_count: number;
input_tokens: number;
output_tokens: number;
grounded_query_count: number;
pricing_note: string;
next_action?: string | null;
};
export type SourceHealthResponse = {
status: string;
odds_coverage_status?: string;
upcoming_odds_matches?: number;
stale_unsettled_matches?: number;
stale_unsettled_threshold_hours?: number;
odds_rows: number;
matches: number;
finished_matches: number;
venues: number;
high_altitude_venues: number;
latest_odds_recorded_at: string | null;
latest_result_synced_at: string | null;
ingestion_status?: {
status?: string;
source?: string;
run_at?: string;
events?: number;
odds_rows?: number;
bookmakers?: number;
message?: string;
} | null;
fixtures_status?: {
status?: string;
source?: string;
run_at?: string;
fixtures?: number;
upserted?: number;
skipped?: number;
interval_seconds?: number;
message?: string;
} | null;
news_status?: {
status?: string;
source?: string;
run_at?: string;
items?: number;
interval_seconds?: number;
message?: string;
} | null;
provider_requirements?: {
primary_odds_provider?: string;
required_markets?: string[];
taiwan_sports_lottery?: string;
current_limitation?: string;
};
};
export type MatchListItem = {
match_id: string;
home_team: string;
away_team: string;
home_score: number | null;
away_score: number | null;
kickoff_utc: string;
status: string;
venue_name: string | null;
@@ -400,6 +598,8 @@ export type MatchDetail = {
match_id: string;
home_team: string;
away_team: string;
home_score: number | null;
away_score: number | null;
home_xg: number;
away_xg: number;
match_time_utc: string;
@@ -409,6 +609,8 @@ export type MatchDetail = {
venue_country: string | null;
venue_altitude_meters: number | null;
odds_series: MatchOddsPoint[];
odds_quality?: string;
xg_quality?: string;
poisson: MatchPoisson;
conditions: MatchConditionsReadout;
quant_summary: string;
@@ -416,6 +618,11 @@ export type MatchDetail = {
type ApiErrorPayload = {
message?: string;
detail?: string;
error?: {
message?: string;
detail?: string;
};
};
const ANALYTICS_API_BASE = '/api/analytics';
@@ -426,22 +633,35 @@ async function requestAnalytics<T>(
path: string,
payload: unknown,
method: HttpMethod = 'POST',
): Promise<T> {
try {
const response = await fetch(`${ANALYTICS_API_BASE}/${path}`, {
method,
headers: { 'Content-Type': 'application/json' },
body: method === 'POST' ? JSON.stringify(payload) : undefined,
});
if (response.ok) {
return response.json() as Promise<T>;
}
} catch (error) {
console.warn(`[Mock Mode] Fetch failed for ${path}, returning mock data.`, error);
): Promise<T> {
try {
const response = await fetch(`${ANALYTICS_API_BASE}/${path}`, {
method,
headers: { 'Content-Type': 'application/json' },
body: method === 'POST' ? JSON.stringify(payload) : undefined,
...(method === 'GET' ? { cache: 'no-store' as RequestCache } : {}),
});
if (response.ok) {
return response.json() as Promise<T>;
}
const contentType = response.headers.get('content-type') || '';
const errorPayload = contentType.includes('application/json')
? ((await response.json().catch(() => null)) as ApiErrorPayload | null)
: null;
const textPayload = errorPayload ? '' : await response.text().catch(() => '');
const backendMessage =
errorPayload?.error?.message ||
errorPayload?.error?.detail ||
errorPayload?.message ||
errorPayload?.detail ||
textPayload ||
'資料服務暫時無法回應';
throw new Error(`${backendMessage}(狀態碼 ${response.status}`);
} catch (error) {
console.error(`[API Error] Fetch failed for ${path}`, error);
throw error;
}
// The mock data fallback has been removed to enforce real API usage
}
export function calculatePlayerProps(payload: PlayerPropsRequestPayload): Promise<PlayerPropsResponse> {
@@ -492,6 +712,28 @@ export function getDailyCard(date: string): Promise<DailyCardResponse> {
return requestAnalytics<DailyCardResponse>(`daily-card/${date}`, {}, 'GET');
}
export function getDailyCardCalendar(startDate = '2026-06-11', endDate?: string): Promise<DailyCardCalendarResponse> {
const params = new URLSearchParams({ start_date: startDate });
if (endDate) params.set('end_date', endDate);
return requestAnalytics<DailyCardCalendarResponse>(`daily-card-calendar?${params.toString()}`, {}, 'GET');
}
export function getRecommendationPerformance(daysBack = 7): Promise<RecommendationPerformanceResponse> {
return requestAnalytics<RecommendationPerformanceResponse>(`recommendation-performance?days_back=${daysBack}`, {}, 'GET');
}
export function getAgentVerification(): Promise<AgentVerificationResponse> {
return requestAnalytics<AgentVerificationResponse>('agent-verification', {}, 'GET');
}
export function getGeminiUsage(): Promise<GeminiUsageResponse> {
return requestAnalytics<GeminiUsageResponse>('gemini-usage', {}, 'GET');
}
export function getSourceHealth(): Promise<SourceHealthResponse> {
return requestAnalytics<SourceHealthResponse>('source-health', {}, 'GET');
}
export function getAllMatches(): Promise<MatchListItem[]> {
return requestAnalytics<MatchListItem[]>('matches', {}, 'GET');
}

View File

@@ -17,8 +17,8 @@ declare module 'next-auth' {
}
}
const handlers = NextAuth({
adapter: PrismaAdapter(prisma),
export const { handlers, auth, signIn, signOut } = NextAuth({
adapter: PrismaAdapter(prisma) as any,
providers: [
Credentials({
name: 'Email',
@@ -79,6 +79,3 @@ const handlers = NextAuth({
},
secret: process.env.NEXTAUTH_SECRET,
});
export const { auth, signIn, signOut, handlers } = handlers;

View File

@@ -158,22 +158,22 @@ export function generateBetSlipUrl(params: {
selection: string;
odds: number;
trackingId?: string;
}): string {
}): string | null {
const configuredBaseUrls: Partial<Record<BookmakerCode, string | undefined>> = {
bet365: process.env.NEXT_PUBLIC_BET365_BETSLIP_URL,
pinnacle: process.env.NEXT_PUBLIC_PINNACLE_BETSLIP_URL,
draftkings: process.env.NEXT_PUBLIC_DRAFTKINGS_BETSLIP_URL,
};
const baseUrl = configuredBaseUrls[params.bookmakerId];
if (!baseUrl) {
return null;
}
const partner = encodeURIComponent(params.trackingId || process.env.NEXT_PUBLIC_AFFILIATE_ID || 'fifa2026');
const odds = encodeURIComponent(String(params.odds));
const selection = encodeURIComponent(params.selection);
const matchId = encodeURIComponent(params.matchId);
if (params.bookmakerId === 'bet365') {
// Bet365以 hash path + query 的混合方式組成,對應其「快速下單」導流機制。
return `https://www.bet365.com/#/AC/B1/C1/D13/Ep1/F2/?affiliate=${partner}&match=${matchId}&selection=${selection}&odds=${odds}`;
}
if (params.bookmakerId === 'pinnacle') {
// Pinnacle通常以 REST 風格 query string 直達預填。
return `https://www.pinnacle.com/en/betting/football/${matchId}?selection=${selection}&odds=${odds}&aff=${partner}`;
}
// fallback to DraftKings pattern
return `https://www.draftkings.com/sportsbook/odds?event=${matchId}&selection=${selection}&odds=${odds}&affid=${partner}`;
const url = new URL(baseUrl);
url.searchParams.set('affiliate', partner);
url.searchParams.set('match', params.matchId);
url.searchParams.set('selection', params.selection);
url.searchParams.set('odds', String(params.odds));
return url.toString();
}

View File

@@ -1,6 +1,6 @@
{
"name": "2026 FIFA Quantum Ops",
"short_name": "FIFA 2026 Ops",
"name": "2026 世界盃量化投注研究中心",
"short_name": "世界盃 2026",
"start_url": "/",
"scope": "/",
"display": "standalone",

View File

@@ -1,7 +1,8 @@
const CACHE_NAME = 'wc2026-quant-v1';
const CACHE_NAME = 'wc2026-quant-20260615-network-realtime-v1';
const OFFLINE_URL = '/offline';
const ASSETS = ['/', '/offline', '/manifest.json', '/manifest-icon-192.svg', '/manifest-icon-512.svg'];
const ASSETS = ['/offline', '/manifest.json', '/manifest-icon-192.svg', '/manifest-icon-512.svg'];
const ODDS_KEYS = 'wc2026:lastOddsSnapshot';
const MATCHES_API_PREFIX = '/api/analytics/matches';
self.addEventListener('install', (event) => {
event.waitUntil(caches.open(CACHE_NAME).then((cache) => cache.addAll(ASSETS)));
@@ -20,7 +21,7 @@ self.addEventListener('activate', (event) => {
function cacheSafeCopy(request, response) {
const cacheable = request.method === 'GET' &&
(request.url.includes('/api/analytics') || request.url.includes('/_next') || request.url.endsWith('.png') || request.url.endsWith('.svg') || request.url.endsWith('.css') || request.url.endsWith('.js'));
(request.url.endsWith('.png') || request.url.endsWith('.svg'));
if (!cacheable) {
return;
}
@@ -100,6 +101,41 @@ self.addEventListener('fetch', (event) => {
return;
}
if (url.pathname.startsWith(MATCHES_API_PREFIX)) {
event.respondWith(
(async () => {
try {
const response = await fetch(request, { cache: 'no-store' });
if (response.ok) {
const payload = await response.clone().json().catch(() => null);
if (payload && !Array.isArray(payload)) {
await writeOddsSnapshotFromDetail(payload).catch(() => {});
}
}
return response;
} catch {
return new Response(JSON.stringify({
message: '賽事資料暫時無法即時更新,請稍後重新整理。',
offline: true,
source: 'service-worker-network-only',
}), {
status: 503,
statusText: 'Service Unavailable',
headers: { 'Content-Type': 'application/json; charset=utf-8' },
});
}
})(),
);
return;
}
if (request.mode === 'navigate') {
event.respondWith(
fetch(request).catch(() => caches.match(OFFLINE_URL)),
);
return;
}
event.respondWith(
(async () => {
const cached = await caches.match(request);
@@ -110,14 +146,7 @@ self.addEventListener('fetch', (event) => {
return cached || Promise.reject(new TypeError('response-not-ok'));
}
if (url.pathname.startsWith('/api/analytics/matches')) {
const clone = response.clone();
cacheSafeCopy(request, response);
const payload = await clone.json().catch(() => null);
await writeOddsSnapshotFromDetail(payload).catch(() => {});
} else {
cacheSafeCopy(request, response);
}
cacheSafeCopy(request, response);
return response;
} catch {
@@ -129,6 +158,18 @@ self.addEventListener('fetch', (event) => {
return caches.match(OFFLINE_URL);
}
if (url.pathname.startsWith('/api/')) {
return new Response(JSON.stringify({
message: '資料服務暫時無法連線,請稍後重新整理。',
offline: true,
source: 'service-worker',
}), {
status: 503,
statusText: 'Service Unavailable',
headers: { 'Content-Type': 'application/json; charset=utf-8' },
});
}
return new Response('Service unavailable', {
status: 503,
statusText: 'Service Unavailable',
@@ -141,7 +182,15 @@ self.addEventListener('fetch', (event) => {
self.addEventListener('message', (event) => {
if (event.data?.type === 'clear-cache') {
caches.delete(CACHE_NAME);
caches.keys().then((keys) => {
keys
.filter((key) => key.startsWith('wc2026-') || key.startsWith('fifa2026'))
.forEach((key) => caches.delete(key));
});
}
if (event.data?.type === 'skip-waiting') {
self.skipWaiting();
}
if (event.data?.type === 'persist-odds' && event.data?.payload) {