feat: 首页未开赛默认展示未来 3 天,支持展开更多

- 新增 showAllUpcoming 状态,默认 false,仅渲染未来 3 天(今/明/后天)的未开赛
- 列表头显示「未来3天 N / 共 M 场」计数
- 「显示后续 N 场未开赛」按钮:展开全部已加载的未开赛
- 展开后提供「收起,仅显示未来 3 天」回退
- 切换联赛/状态时自动重置为 3 天视图
- 空态区分「未来3天暂无比赛」与「暂无赛程」
- 已完赛/全部状态不受影响,展示全部

Co-Authored-By: new-provider/LongCat-2.0 <<EMAIL>>
This commit is contained in:
shangfangjian
2026-09-20 20:29:12 +08:00
co-authored by new-provider/LongCat-2.0 <
parent c586a38b3a
commit 19f17145cc
+66 -4
View File
@@ -194,6 +194,7 @@ export default function Matches() {
const [nextCursor, setNextCursor] = useState<string | null>(null) const [nextCursor, setNextCursor] = useState<string | null>(null)
const [loadingMore, setLoadingMore] = useState(false) const [loadingMore, setLoadingMore] = useState(false)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [showAllUpcoming, setShowAllUpcoming] = useState(false) // 默认仅展示未来 3 天;true 展开全部
const [predictingId, setPredictingId] = useState<number | null>(null) const [predictingId, setPredictingId] = useState<number | null>(null)
const [prediction, setPrediction] = useState<Prediction | null>(null) const [prediction, setPrediction] = useState<Prediction | null>(null)
const [predictionFor, setPredictionFor] = useState<Match | null>(null) const [predictionFor, setPredictionFor] = useState<Match | null>(null)
@@ -223,6 +224,7 @@ export default function Matches() {
const seq = ++loadSeq.current const seq = ++loadSeq.current
setLoading(true) setLoading(true)
setLoadingMore(false) setLoadingMore(false)
setShowAllUpcoming(false) // 切换筛选重置为「未来 3 天」视图
setError(null) setError(null)
try { try {
const params = new URLSearchParams({ league, status, limit: '50' }) const params = new URLSearchParams({ league, status, limit: '50' })
@@ -275,6 +277,21 @@ export default function Matches() {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}` return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
} }
/** 未来 3 天窗口:今天 00:00 → 第 3 天 00:00(即今天/明天/后天) */
function addDays(d: Date, n: number): string {
const x = new Date(d)
x.setFullYear(x.getFullYear(), x.getMonth(), x.getDate() + n)
return `${x.getFullYear()}-${String(x.getMonth() + 1).padStart(2, '0')}-${String(x.getDate()).padStart(2, '0')}`
}
const todayKey = todayStr()
const windowEnd = addDays(new Date(), 3) // 不含
/** 比赛是否在未来 3 天内(用于默认视图过滤) */
function withinNext3Days(matchDate: string): boolean {
const key = toLocalDateKey(matchDate)
return key >= todayKey && key < windowEnd
}
/** UTC ISO → 本地日期 YYYY-MM-DD(用于分组) */ /** UTC ISO → 本地日期 YYYY-MM-DD(用于分组) */
function toLocalDateKey(iso: string): string { function toLocalDateKey(iso: string): string {
const d = new Date(iso) const d = new Date(iso)
@@ -346,6 +363,14 @@ export default function Matches() {
const leagueName = LEAGUES.find(l => l.code === league)?.name ?? league const leagueName = LEAGUES.find(l => l.code === league)?.name ?? league
/** 未开赛默认仅展示未来 3 天;其余状态展示全部。showAllUpcoming=true 时展开全部。 */
const isScheduledView = status === 'scheduled'
const visibleMatches = (!isScheduledView || showAllUpcoming)
? matches
: matches.filter(m => withinNext3Days(m.match_date))
// 是否有被折叠的未开赛比赛(用于显示「展开」按钮)
const hasHiddenUpcoming = isScheduledView && !showAllUpcoming && matches.length > visibleMatches.length
/** 状态/模式一组的文字切换 */ /** 状态/模式一组的文字切换 */
const Switch = ({ value, onChange, items }: { const Switch = ({ value, onChange, items }: {
value: string value: string
@@ -420,7 +445,11 @@ export default function Matches() {
</span> </span>
<span className="ml-auto inline-flex items-center gap-3"> <span className="ml-auto inline-flex items-center gap-3">
<span className="tabular-nums"> {matches.length} </span> <span className="tabular-nums">
{isScheduledView && !showAllUpcoming && hasHiddenUpcoming
? `未来3天 ${visibleMatches.length} / 共 ${matches.length}`
: `${visibleMatches.length}`}
</span>
<button onClick={load} disabled={loading} className="btn btn-sm"> <button onClick={load} disabled={loading} className="btn btn-sm">
{loading ? (<><Spinner /> </>) : '刷新'} {loading ? (<><Spinner /> </>) : '刷新'}
</button> </button>
@@ -453,17 +482,26 @@ export default function Matches() {
<section aria-label="赛程"> <section aria-label="赛程">
{loading && <SkeletonRows n={4} />} {loading && <SkeletonRows n={4} />}
{!loading && matches.length === 0 && ( {!loading && visibleMatches.length === 0 && (
<div className="empty-state"> <div className="empty-state">
{matches.length > 0 ? (
<>
<p className="empty-state-title"> 3 {leagueName} </p>
<p className="empty-state-sub"> {matches.length} ,</p>
</>
) : (
<>
<p className="empty-state-title"></p> <p className="empty-state-title"></p>
<p className="empty-state-sub"> {leagueName} </p> <p className="empty-state-sub"> {leagueName} </p>
<a href="/admin/collection" className="empty-state-action"> <a href="/admin/collection" className="empty-state-action">
<span aria-hidden="true"></span> <span aria-hidden="true"></span>
</a> </a>
</>
)}
</div> </div>
)} )}
{!loading && groupByDate(matches).map(([dateKey, group]) => ( {!loading && groupByDate(visibleMatches).map(([dateKey, group]) => (
<div key={dateKey}> <div key={dateKey}>
{/* 日期分组头:sticky 但 z 低于弹窗 z-50 */} {/* 日期分组头:sticky 但 z 低于弹窗 z-50 */}
<div className="sticky top-0 z-20 border-b border-ink-900 bg-paper-100 px-3 py-2 text-xs font-medium tracking-wide text-ink-600"> <div className="sticky top-0 z-20 border-b border-ink-900 bg-paper-100 px-3 py-2 text-xs font-medium tracking-wide text-ink-600">
@@ -604,8 +642,32 @@ export default function Matches() {
</div> </div>
))} ))}
{/* 未开赛视图:从「未来 3 天」展开全部已加载的未开赛 */}
{!loading && hasHiddenUpcoming && (
<div className="flex justify-center pt-4">
<button
onClick={() => setShowAllUpcoming(true)}
className="btn btn-outline min-h-[44px] w-full max-w-xs sm:w-auto"
>
{matches.length - visibleMatches.length}
</button>
</div>
)}
{/* 已展开全部但未开赛:提供「收起」回未来 3 天 */}
{!loading && isScheduledView && showAllUpcoming && matches.length > 0 && (
<div className="flex justify-center pt-2">
<button
onClick={() => setShowAllUpcoming(false)}
className="text-xs text-ink-400 hover:text-ink-700 transition-colors"
>
, 3
</button>
</div>
)}
{!loading && nextCursor && ( {!loading && nextCursor && (
<div className="flex justify-center pt-5"> <div className="flex justify-center pt-4">
<button onClick={loadMore} disabled={loadingMore} className="btn min-h-[44px] w-full max-w-xs sm:w-auto"> <button onClick={loadMore} disabled={loadingMore} className="btn min-h-[44px] w-full max-w-xs sm:w-auto">
{loadingMore ? (<><Spinner /> </>) : '载入更多赛程'} {loadingMore ? (<><Spinner /> </>) : '载入更多赛程'}
</button> </button>