feat: improve homepage betting recommendation UX
All checks were successful
2026 World Cup Quant Platform - Production Deployment / Code Quality, Security Gate & Testing (push) Successful in 4m7s
2026 World Cup Quant Platform - Production Deployment / Deploy to Production VM via Gitea CD (push) Successful in 5m7s

This commit is contained in:
wooo
2026-06-18 14:33:08 +08:00
parent f8381dd627
commit a1f264eee2
2 changed files with 82 additions and 4 deletions

View File

@@ -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<string>();
const matchCounts = new Map<string, number>();
const matchMarketSeen = new Set<string>();
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() {
<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>
@@ -517,6 +560,7 @@ export default function Home() {
<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>
<span className="rounded-full bg-white px-3 py-1 text-[#5f4330]"> 2 </span>
</div>
</div>
@@ -630,6 +674,7 @@ export default function Home() {
<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>
<p className="mt-2 rounded-2xl border border-[#eadcb9] bg-[#fff8e6] p-3 text-xs font-bold leading-5 text-[#5f4330]">{shortBetInstruction(item)}</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>
@@ -779,6 +824,7 @@ export default function Home() {
</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>
<p className="mt-2 rounded-2xl border border-[#eadcb9] bg-white/70 p-3 text-xs font-bold leading-5 text-[#5f4330]">{shortBetInstruction(item)}</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>

View File

@@ -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
<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>
<div className="mt-3 rounded-xl border border-[#eadcb9] bg-[#fff8e6] p-3">
<p className="text-xs font-black text-[#7d2a15]"></p>
<ol className="mt-2 space-y-1 text-sm leading-6 text-[#5f4330]">
{executionSteps(item).map((step, index) => (
<li key={step}> {index + 1} {step}</li>
))}
</ol>
</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>
@@ -250,7 +282,7 @@ export function ActionableBetCard({ item, onAddToSlip, isInSlip = false, classNa
</p>
<p className="mt-2 text-sm leading-6 text-[#7a5b46]">
</p>
</section>