From a1f264eee2ab979e2338d83065f21461db7ba7c1 Mon Sep 17 00:00:00 2001
From: wooo
Date: Thu, 18 Jun 2026 14:33:08 +0800
Subject: [PATCH] feat: improve homepage betting recommendation UX
---
platform/web/app/page.tsx | 52 +++++++++++++++++--
platform/web/components/ActionableBetCard.tsx | 34 +++++++++++-
2 files changed, 82 insertions(+), 4 deletions(-)
diff --git a/platform/web/app/page.tsx b/platform/web/app/page.tsx
index 244c8c6..11fe60f 100644
--- a/platform/web/app/page.tsx
+++ b/platform/web/app/page.tsx
@@ -107,6 +107,49 @@ function nextStepText(item: DailyCardItem): string {
return '可進入下注前檢查。下單前仍要確認同一場、同一玩法、同一選項與賠率門檻。';
}
+function pickMatchKey(item: DailyCardItem): string {
+ return String(item.match_id ?? item.match_label).trim().toLowerCase();
+}
+
+function pickMarketGroup(item: DailyCardItem): string {
+ if (item.recommendation === 'SAFE_PARLAY') return '跨場串關';
+ if (item.recommendation === 'SGP_LOTTERY') return '同場串關';
+ if (item.recommendation === 'HIGH_RISK_SINGLE') return '小注高賠';
+ return item.market_type || '單關';
+}
+
+function diversifyPicks(items: DailyCardItem[], limit: number): DailyCardItem[] {
+ const selected: DailyCardItem[] = [];
+ const seen = new Set();
+ const matchCounts = new Map();
+ const matchMarketSeen = new Set();
+
+ const tryAdd = (item: DailyCardItem, maxPerMatch: number, enforceMarketSpread: boolean) => {
+ if (selected.length >= limit) return;
+ const key = pickKey(item);
+ if (seen.has(key)) return;
+ const matchKey = pickMatchKey(item);
+ const marketKey = `${matchKey}|${pickMarketGroup(item)}`;
+ if ((matchCounts.get(matchKey) ?? 0) >= maxPerMatch) return;
+ if (enforceMarketSpread && matchMarketSeen.has(marketKey)) return;
+
+ selected.push(item);
+ seen.add(key);
+ matchCounts.set(matchKey, (matchCounts.get(matchKey) ?? 0) + 1);
+ matchMarketSeen.add(marketKey);
+ };
+
+ items.forEach((item) => tryAdd(item, 2, true));
+ items.forEach((item) => tryAdd(item, 3, false));
+ return selected.slice(0, limit);
+}
+
+function shortBetInstruction(item: DailyCardItem): string {
+ if (item.recommendation === 'SAFE_PARLAY') return '到投注平台選「串關」,把每一腿分開加入;任一腿賠率不達標就整組跳過。';
+ if (item.recommendation === 'SGP_LOTTERY') return '到投注平台選「同場串關」,只用小注碼;若平台沒有同樣分線就不要替代。';
+ return '到投注平台選「單關」,確認同一場、同一市場、同一選項,再比對最低可接受賠率。';
+}
+
function buildGateState(
sourceHealth: SourceHealthResponse | null,
errors: LoadErrors,
@@ -407,7 +450,7 @@ export default function Home() {
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);
+ return diversifyPicks([...balanced, ...remaining], 10);
}, [recommendationItems]);
const categorySummary = useMemo(() => {
@@ -478,7 +521,7 @@ export default function Home() {
}
const next = [item, ...watchlist].slice(0, 20);
setWatchlist(next);
- setFeedbackMessage(`已加入${isConditionalPick(item) || !item.has_market_odds ? '賠率監控清單' : '下注前檢查清單'}:${item.match_label} | ${item.selection}`);
+ setFeedbackMessage(`已加入${isConditionalPick(item) || !item.has_market_odds ? '賠率監控清單' : '下注前檢查清單'}:${item.match_label} | ${item.selection}。最低賠率 ${item.target_odds.toFixed(2)},參考上限 ${formatTwd(stakeAmountTwd(item))}。`);
try {
window.localStorage.setItem(WATCHLIST_KEY, JSON.stringify(next));
} catch {
@@ -506,7 +549,7 @@ export default function Home() {
首頁第一屏 / {dateWindow.today} - {dateWindow.tomorrow}
下一批可檢查投注候選
- 一打開首頁先看這裡:系統會先看今天,若今天已無可下注賽事,就自動切到明天有資料的場次。若資料不足,卡片會標成「預掛觀察」,只代表先盯賠率門檻,不是叫你立刻下注。
+ 一打開首頁先看這裡:系統會優先顯示今天與明天,再依日期列出後續可分析賽事。清單會自動分散場次與玩法,避免同一場比賽洗版;若資料不足,卡片會標成「預掛觀察」,只代表先盯賠率門檻,不是叫你立刻下注。
目前主顯示日期:{activeRecommendationDate}
@@ -517,6 +560,7 @@ export default function Home() {
可檢查 {liveRecommendationCount} 組
預掛監控 {watchOnlyRecommendationCount} 組
60 秒刷新
+ 首頁最多每場優先顯示 2 組
@@ -630,6 +674,7 @@ export default function Home() {
台北日期:{activeDateLabel}
{item.market_type}|{item.selection}
同一場比賽可能有不同玩法;不同玩法的賠率不能互相比大小,只能和該玩法自己的最低門檻比較。
+ {shortBetInstruction(item)}
最低可接受賠率:{item.target_odds.toFixed(2)}
模型勝率:{item.win_prob.toFixed(2)}%
@@ -779,6 +824,7 @@ export default function Home() {
{item.match_label}
{item.market_type} | {item.selection}
+ {shortBetInstruction(item)}
模型勝率 {item.win_prob.toFixed(2)}%
最低賠率 {item.target_odds.toFixed(2)}
diff --git a/platform/web/components/ActionableBetCard.tsx b/platform/web/components/ActionableBetCard.tsx
index 40b4373..f569180 100644
--- a/platform/web/components/ActionableBetCard.tsx
+++ b/platform/web/components/ActionableBetCard.tsx
@@ -48,6 +48,30 @@ function betMode(item: DailyCardItem): string {
return '到投注平台選「單關」,找到同一場比賽與同一個市場後下注。';
}
+function executionSteps(item: DailyCardItem): string[] {
+ if (item.recommendation === 'SAFE_PARLAY') {
+ return [
+ '在投注平台選擇「串關」,不要用單關賠率硬湊。',
+ '逐一加入下方每一腿,任一腿的場次、玩法、分線或選項不同就整組放棄。',
+ `總賠率與每腿賠率都要達到門檻,參考上限不要超過 ${formatTwd(stakeAmountTwd(item))}。`,
+ ];
+ }
+
+ if (item.recommendation === 'SGP_LOTTERY') {
+ return [
+ '在投注平台選擇「同場串關」,確認所有選項都來自同一場比賽。',
+ '平台若沒有完全相同的同場組合或分線,不要用相似選項替代。',
+ `同場串關波動較高,只能小注,參考上限不要超過 ${formatTwd(stakeAmountTwd(item))}。`,
+ ];
+ }
+
+ return [
+ '在投注平台選擇「單關」,找到同一場比賽。',
+ `選擇「${item.market_type}」裡的「${item.selection}」,並確認分線一致。`,
+ `只有賠率大於或等於 ${item.target_odds.toFixed(2)} 才考慮,參考上限不要超過 ${formatTwd(stakeAmountTwd(item))}。`,
+ ];
+}
+
function formatTwd(value: number): string {
return new Intl.NumberFormat('zh-TW', {
style: 'currency',
@@ -196,6 +220,14 @@ export function ActionableBetCard({ item, onAddToSlip, isInSlip = false, classNa
最低可接受賠率:{item.target_odds.toFixed(2)}
建議上限:{formatTwd(stakeAmountTwd(item))}
+
+
實際操作三步驟
+
+ {executionSteps(item).map((step, index) => (
+ - 第 {index + 1} 步:{step}
+ ))}
+
+
{oddsRule(item)}
{stakeGuide(item)}
@@ -250,7 +282,7 @@ export function ActionableBetCard({ item, onAddToSlip, isInSlip = false, classNa
這是研究候選,不是結果承諾。若賠率低於最低可接受賠率、先發名單異常、傷停新聞改變、或資料源延遲,就不要下注,等待下一次刷新。
- 下注前最後確認:同一場比賽、同一市場、同一分線、同一選項、賠率達標、倉位不超過建議單位。
+ 下注前最後確認:同一場比賽、同一市場、同一分線、同一選項、賠率達標、倉位不超過建議新台幣上限。若你找不到完全相同的玩法,就不要改買相似玩法。