fix: restore production build dependencies
This commit is contained in:
@@ -3,8 +3,8 @@ 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 ci --legacy-peer-deps
|
||||
|
||||
# ── stage 2: build ─────────────────────────────────────────────────────────────
|
||||
FROM node:22-alpine AS builder
|
||||
|
||||
93
platform/web/components/HeaderNav.tsx
Normal file
93
platform/web/components/HeaderNav.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
type NavItem = {
|
||||
href: string;
|
||||
label: string;
|
||||
shortLabel: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
const PRIMARY_NAV_ITEMS: NavItem[] = [
|
||||
{ href: '/', label: '主控總覽', shortLabel: '總覽', description: '首頁先看今日推薦與資料健康' },
|
||||
{ href: '/daily-card', label: '每日作戰室', shortLabel: '作戰室', description: '依日期檢查賽事與推薦' },
|
||||
{ href: '/matches', label: '完整賽程', shortLabel: '賽程', description: '所有賽事、比分與狀態' },
|
||||
{ href: '/deep-bet', label: '投注研究', shortLabel: '投注', description: '單關、串關與組合評估' },
|
||||
{ href: '/odds', label: '盤口監控', shortLabel: '盤口', description: '賠率覆蓋與即時變化' },
|
||||
{ href: '/sharp-money', label: '聰明錢追蹤', shortLabel: '聰明錢', description: '資金流向與市場偏差' },
|
||||
];
|
||||
|
||||
const RESEARCH_NAV_ITEMS: NavItem[] = [
|
||||
{ href: '/rlm', label: '反向盤口雷達', shortLabel: '反向盤', description: '少數派資金與賠率逆行' },
|
||||
{ href: '/models', label: '量化模型', shortLabel: '模型', description: '泊松、EV 與風險校準' },
|
||||
{ href: '/ml-edge', label: '機器學習邊緣', shortLabel: 'ML', description: '模型機率與市場機率差' },
|
||||
{ href: '/match-conditions', label: '裁判/天候模型', shortLabel: '天候', description: '場地、熱度與判罰風險' },
|
||||
{ href: '/props', label: '球員道具盤', shortLabel: '道具', description: '射門、傳球與球員線' },
|
||||
{ href: '/kelly', label: '凱利配置', shortLabel: '凱利', description: '注碼上限與風險控管' },
|
||||
{ href: '/backtesting', label: '策略回測', shortLabel: '回測', description: '歷史命中率與收益曲線' },
|
||||
{ href: '/proof-of-yield', label: '公開收益帳本', shortLabel: '帳本', description: '推薦賽後校準與命中率' },
|
||||
{ href: '/portfolio', label: '個人組合', shortLabel: '組合', description: '追蹤清單與部位管理' },
|
||||
];
|
||||
|
||||
function isActivePath(pathname: string, href: string): boolean {
|
||||
if (href === '/') {
|
||||
return pathname === '/';
|
||||
}
|
||||
return pathname === href || pathname.startsWith(`${href}/`);
|
||||
}
|
||||
|
||||
function NavPill({ item, active }: { item: NavItem; active: boolean }) {
|
||||
return (
|
||||
<Link
|
||||
href={item.href}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
title={item.description}
|
||||
className={[
|
||||
'rounded-full px-3.5 py-2 text-sm font-semibold transition focus:outline-none focus:ring-2 focus:ring-[#d6492f]/40',
|
||||
active
|
||||
? 'bg-[#8f2d1c] text-[#fff8e6] shadow-[0_10px_24px_rgba(143,45,28,0.28)] ring-1 ring-[#f0cfa5]'
|
||||
: 'bg-[#efe1c8] text-[#563c2d] ring-1 ring-[#cda77e] hover:-translate-y-0.5 hover:bg-[#fff4df] hover:text-[#8f2d1c]',
|
||||
].join(' ')}
|
||||
>
|
||||
<span className="hidden md:inline">{item.label}</span>
|
||||
<span className="md:hidden">{item.shortLabel}</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export function HeaderNav() {
|
||||
const pathname = usePathname() || '/';
|
||||
const allItems = [...PRIMARY_NAV_ITEMS, ...RESEARCH_NAV_ITEMS];
|
||||
const activeItem = allItems.find((item) => isActivePath(pathname, item.href)) ?? PRIMARY_NAV_ITEMS[0];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<Link href="/" className="group min-w-[220px] rounded-2xl focus:outline-none focus:ring-2 focus:ring-[#d6492f]/40">
|
||||
<p className="dot-matrix text-2xl leading-tight text-[#8f2d1c] transition group-hover:text-[#b83822] md:text-3xl">
|
||||
2026 世界盃<br />量化投注研究中心
|
||||
</p>
|
||||
<p className="mt-2 text-xs font-semibold tracking-wide text-[#b6825c]">
|
||||
台北時間 (UTC+8) | 台灣量化實戰研究版
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-[#7d6049]">
|
||||
目前頁面:<span className="font-bold text-[#8f2d1c]">{activeItem.label}</span>
|
||||
</p>
|
||||
</Link>
|
||||
|
||||
<nav className="flex-1 space-y-3" aria-label="主要功能導覽">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{PRIMARY_NAV_ITEMS.map((item) => (
|
||||
<NavPill key={item.href} item={item} active={isActivePath(pathname, item.href)} />
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 border-t border-[#2b3548] pt-3">
|
||||
{RESEARCH_NAV_ITEMS.map((item) => (
|
||||
<NavPill key={item.href} item={item} active={isActivePath(pathname, item.href)} />
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
111
platform/web/lib/match-order.ts
Normal file
111
platform/web/lib/match-order.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
export type MatchStatusKind = 'live' | 'upcoming' | 'finished' | 'postponed' | 'unknown';
|
||||
|
||||
type MatchStatusInput = {
|
||||
kickoff_utc?: string | null;
|
||||
match_time_utc?: string | null;
|
||||
match_time?: string | null;
|
||||
status?: string | null;
|
||||
home_score?: number | null;
|
||||
away_score?: number | null;
|
||||
};
|
||||
|
||||
const LIVE_WINDOW_MS = 150 * 60 * 1000;
|
||||
const RESULT_BACKFILL_GRACE_MS = 240 * 60 * 1000;
|
||||
|
||||
function kickoffTimestamp(match: MatchStatusInput): number | null {
|
||||
const raw = match.kickoff_utc || match.match_time_utc || match.match_time;
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
const timestamp = Date.parse(raw);
|
||||
return Number.isNaN(timestamp) ? null : timestamp;
|
||||
}
|
||||
|
||||
function normalizedStatus(match: MatchStatusInput): string {
|
||||
return (match.status || '').trim().toLowerCase().replace(/_/g, '-');
|
||||
}
|
||||
|
||||
function hasScore(match: MatchStatusInput): boolean {
|
||||
return typeof match.home_score === 'number' && typeof match.away_score === 'number';
|
||||
}
|
||||
|
||||
export function matchStatusKind(match: MatchStatusInput, now: Date = new Date()): MatchStatusKind {
|
||||
const status = normalizedStatus(match);
|
||||
const kickoff = kickoffTimestamp(match);
|
||||
const nowMs = now.getTime();
|
||||
|
||||
if (/postponed|delayed|suspended|cancelled|canceled|abandoned/.test(status)) {
|
||||
return 'postponed';
|
||||
}
|
||||
|
||||
if (/finished|final|full-time|fulltime|ft|completed|closed/.test(status)) {
|
||||
return 'finished';
|
||||
}
|
||||
|
||||
if (/live|in-play|inplay|playing|ongoing|1st|2nd|first-half|second-half|halftime|half-time/.test(status)) {
|
||||
return 'live';
|
||||
}
|
||||
|
||||
if (kickoff === null) {
|
||||
return hasScore(match) ? 'finished' : 'unknown';
|
||||
}
|
||||
|
||||
if (kickoff > nowMs) {
|
||||
return 'upcoming';
|
||||
}
|
||||
|
||||
const elapsed = nowMs - kickoff;
|
||||
if (elapsed <= LIVE_WINDOW_MS) {
|
||||
return 'live';
|
||||
}
|
||||
|
||||
if (hasScore(match) || elapsed > RESULT_BACKFILL_GRACE_MS) {
|
||||
return hasScore(match) ? 'finished' : 'unknown';
|
||||
}
|
||||
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
export function matchStatusLabel(match: MatchStatusInput, now: Date = new Date()): string {
|
||||
const kind = matchStatusKind(match, now);
|
||||
const score = hasScore(match) ? `${match.home_score}-${match.away_score}` : null;
|
||||
|
||||
if (kind === 'live') {
|
||||
return score ? `進行中 ${score}` : '進行中';
|
||||
}
|
||||
if (kind === 'finished') {
|
||||
return score ? `已完賽 ${score}` : '已完賽';
|
||||
}
|
||||
if (kind === 'postponed') {
|
||||
return '延期/中止';
|
||||
}
|
||||
if (kind === 'upcoming') {
|
||||
return '未開賽';
|
||||
}
|
||||
return '結果待回填';
|
||||
}
|
||||
|
||||
export function sortMatchesForProfessionalDisplay<T extends MatchStatusInput>(matches: T[], now: Date = new Date()): T[] {
|
||||
const rank: Record<MatchStatusKind, number> = {
|
||||
live: 0,
|
||||
upcoming: 1,
|
||||
unknown: 2,
|
||||
postponed: 3,
|
||||
finished: 4,
|
||||
};
|
||||
|
||||
return [...matches].sort((a, b) => {
|
||||
const kindA = matchStatusKind(a, now);
|
||||
const kindB = matchStatusKind(b, now);
|
||||
if (kindA !== kindB) {
|
||||
return rank[kindA] - rank[kindB];
|
||||
}
|
||||
|
||||
const timeA = kickoffTimestamp(a) ?? 0;
|
||||
const timeB = kickoffTimestamp(b) ?? 0;
|
||||
if (kindA === 'finished') {
|
||||
return timeB - timeA;
|
||||
}
|
||||
return timeA - timeB;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user