692 lines
33 KiB
TypeScript
692 lines
33 KiB
TypeScript
'use client';
|
||
|
||
import Link from 'next/link';
|
||
import { useEffect, useMemo, useState } from 'react';
|
||
import { formatToTaipeiTime } from '@/lib/timezone';
|
||
import { getDailyCard, getDailyCardCalendar, type DailyCardCalendarDate, type DailyCardItem, type DailyCardResponse } from '@/lib/analytics-api';
|
||
import { ActionableBetCard } from '@/components/ActionableBetCard';
|
||
|
||
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',
|
||
sgp: 'SGP_LOTTERY',
|
||
};
|
||
|
||
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 [selectedDate, setSelectedDate] = useState('');
|
||
const [loading, setLoading] = useState(false);
|
||
const [error, setError] = useState('');
|
||
const [activeTab, setActiveTab] = useState<TabKey>('all');
|
||
const [selectedCount, setSelectedCount] = useState(0);
|
||
const [calendarDates, setCalendarDates] = useState<DailyCardCalendarDate[]>([]);
|
||
const [dailyCards, setDailyCards] = useState<Record<string, DailyCardResponse>>({});
|
||
const [watchlist, setWatchlist] = useState<DailyCardItem[]>([]);
|
||
const [feedbackMessage, setFeedbackMessage] = useState('');
|
||
|
||
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 {
|
||
all: [...safe, ...parlay, ...sgp, ...risk],
|
||
safe,
|
||
risk,
|
||
parlay,
|
||
sgp,
|
||
};
|
||
}, [selectedCard]);
|
||
|
||
const briefing = useMemo(() => {
|
||
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 `${selectedDate} 已整理 ${matched} 場賽事,目前有 ${liveCount} 組可做下注前檢查、${preMarketCount} 組只能先放進賠率監控清單。建議參考上限為 ${formatTwd(totalAmount)}。${
|
||
safeCount > 0
|
||
? `其中 ${safeCount} 組是單關候選;如果還沒有真實賠率,只能等待賠率達標後再考慮。`
|
||
: '目前沒有足夠漂亮的低風險單關,先保留資金比較合理。'
|
||
} ${
|
||
riskCount > 0
|
||
? `另外 ${riskCount} 組屬於高賠小注候選,波動大,只適合很小的注碼。`
|
||
: ''
|
||
}`;
|
||
}, [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 () => {
|
||
setLoading(true);
|
||
setError('');
|
||
|
||
try {
|
||
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 : '無法取得日期推薦摘要資料');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
load().catch(() => undefined);
|
||
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) {
|
||
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-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-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={tabButtonClass(activeTab === 'all')}
|
||
onClick={() => setActiveTab('all')}
|
||
>
|
||
全部候選
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={tabButtonClass(activeTab === 'safe')}
|
||
onClick={() => setActiveTab('safe')}
|
||
>
|
||
單關候選
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={tabButtonClass(activeTab === 'risk')}
|
||
onClick={() => setActiveTab('risk')}
|
||
>
|
||
小注高賠
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={tabButtonClass(activeTab === 'parlay')}
|
||
onClick={() => setActiveTab('parlay')}
|
||
>
|
||
跨場串關
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={tabButtonClass(activeTab === 'sgp')}
|
||
onClick={() => setActiveTab('sgp')}
|
||
>
|
||
同場串關
|
||
</button>
|
||
</div>
|
||
</section>
|
||
|
||
<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) => (
|
||
<ActionableBetCard
|
||
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 ? (
|
||
<div className="panel-glow rounded-2xl p-6 text-center border-dashed">
|
||
<p className="text-sm text-[#7a5b46]">
|
||
{selectedMissingSnapshot
|
||
? '此日期缺少賽前快照,因此不顯示事後補造的投注推薦。請切到下一個有候選的日期,或查看賽後校準。'
|
||
: '這個分類目前沒有達標候選。沒有足夠資料或賠率不漂亮時,系統不會硬湊推薦。'}
|
||
</p>
|
||
</div>
|
||
) : null}
|
||
</section>
|
||
</div>
|
||
);
|
||
}
|