diff --git a/package-lock.json b/package-lock.json index 2ddc091..a669e2d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,9 +12,11 @@ "axios": "^1.16.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "laravel-echo": "^2.3.4", "lucide-react": "^1.14.0", "next": "16.2.6", "next-themes": "^0.4.6", + "pusher-js": "^8.5.0", "react": "19.2.4", "react-dom": "19.2.4", "sonner": "^2.0.7", @@ -2109,6 +2111,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", + "license": "MIT", + "peer": true + }, "node_modules/@swc/helpers": { "version": "0.5.15", "resolved": "https://registry.npmmirror.com/@swc/helpers/-/helpers-0.5.15.tgz", @@ -4200,7 +4209,6 @@ "version": "4.4.3", "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -4453,6 +4461,30 @@ "node": ">= 0.8" } }, + "node_modules/engine.io-client": { + "version": "6.6.4", + "resolved": "https://registry.npmmirror.com/engine.io-client/-/engine.io-client-6.6.4.tgz", + "integrity": "sha512-+kjUJnZGwzewFDw951CDWcwj35vMNf2fcj7xQWOctq1F2i1jkDdVvdFG9kM/BEChymCH36KgjnW0NsL58JYRxw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.18.3", + "xmlhttprequest-ssl": "~2.1.1" + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.3", + "resolved": "https://registry.npmmirror.com/engine.io-parser/-/engine.io-parser-5.2.3.tgz", + "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/enhanced-resolve": { "version": "5.21.1", "resolved": "https://registry.npmmirror.com/enhanced-resolve/-/enhanced-resolve-5.21.1.tgz", @@ -6911,6 +6943,19 @@ "node": ">=0.10" } }, + "node_modules/laravel-echo": { + "version": "2.3.4", + "resolved": "https://registry.npmmirror.com/laravel-echo/-/laravel-echo-2.3.4.tgz", + "integrity": "sha512-rpALCIK1uw2SrttcK9P5JzItt5I85RcfXQKUNnkcorzhtKeXi5GS0PVFFBH8ppNo8wnbdBKuD1EtIHgTbXo9FQ==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "pusher-js": "*", + "socket.io-client": "*" + } + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmmirror.com/levn/-/levn-0.4.1.tgz", @@ -7447,7 +7492,6 @@ "version": "2.1.3", "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/msw": { @@ -8348,6 +8392,15 @@ "node": ">=6" } }, + "node_modules/pusher-js": { + "version": "8.5.0", + "resolved": "https://registry.npmmirror.com/pusher-js/-/pusher-js-8.5.0.tgz", + "integrity": "sha512-V7uzGi9bqOOOyM/6IkJdpFyjGZj7llz1v0oWnYkZKcYLvbz6VcHVLmzKqkvegjuMumpfIEKGLmWHwFb39XFCpw==", + "license": "MIT", + "dependencies": { + "tweetnacl": "^1.0.3" + } + }, "node_modules/qs": { "version": "6.15.1", "resolved": "https://registry.npmmirror.com/qs/-/qs-6.15.1.tgz", @@ -9163,6 +9216,36 @@ "dev": true, "license": "MIT" }, + "node_modules/socket.io-client": { + "version": "4.8.3", + "resolved": "https://registry.npmmirror.com/socket.io-client/-/socket.io-client-4.8.3.tgz", + "integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==", + "license": "MIT", + "peer": true, + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-client": "~6.6.1", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.6", + "resolved": "https://registry.npmmirror.com/socket.io-parser/-/socket.io-parser-4.2.6.tgz", + "integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/sonner": { "version": "2.0.7", "resolved": "https://registry.npmmirror.com/sonner/-/sonner-2.0.7.tgz", @@ -9720,6 +9803,12 @@ "url": "https://github.com/sponsors/Wombosvideo" } }, + "node_modules/tweetnacl": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/tweetnacl/-/tweetnacl-1.0.3.tgz", + "integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==", + "license": "Unlicense" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmmirror.com/type-check/-/type-check-0.4.0.tgz", @@ -10283,6 +10372,28 @@ "dev": true, "license": "ISC" }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmmirror.com/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/wsl-utils": { "version": "0.3.1", "resolved": "https://registry.npmmirror.com/wsl-utils/-/wsl-utils-0.3.1.tgz", @@ -10300,6 +10411,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/xmlhttprequest-ssl": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", + "integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==", + "peer": true, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmmirror.com/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index 17f7d14..38ec61b 100644 --- a/package.json +++ b/package.json @@ -13,9 +13,11 @@ "axios": "^1.16.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "laravel-echo": "^2.3.4", "lucide-react": "^1.14.0", "next": "16.2.6", "next-themes": "^0.4.6", + "pusher-js": "^8.5.0", "react": "19.2.4", "react-dom": "19.2.4", "sonner": "^2.0.7", diff --git a/src/api/draw.ts b/src/api/draw.ts new file mode 100644 index 0000000..cbc1b7f --- /dev/null +++ b/src/api/draw.ts @@ -0,0 +1,42 @@ +import { lotteryRequest } from "@/lib/lottery-http"; +import { API_V1_PREFIX } from "@/api/paths"; +import type { DrawCurrentPayload } from "@/types/api/draw-current"; +import type { + DrawResultDetailPayload, + DrawResultsListPayload, +} from "@/types/api/draw-results"; + +/** `GET /api/v1/draw/current`(无需登录;无当前期时 `data` 为 `null`) */ +export function getDrawCurrent(): Promise { + return lotteryRequest.get( + `${API_V1_PREFIX}/draw/current`, + ); +} + +export type GetDrawResultsParams = { + page?: number; + /** 与后端 `size` / `per_page` 对齐 */ + size?: number; + /** `YYYY-MM-DD`,按业务日过滤 */ + business_date?: string; +}; + +/** `GET /api/v1/draw/results` */ +export function getDrawResults( + params?: GetDrawResultsParams, +): Promise { + return lotteryRequest.get( + `${API_V1_PREFIX}/draw/results`, + { params: { page: params?.page, size: params?.size, business_date: params?.business_date } }, + ); +} + +/** `GET /api/v1/draw/results/{draw_no}` */ +export function getDrawResultByNo( + drawNo: string, +): Promise { + const encoded = encodeURIComponent(drawNo); + return lotteryRequest.get( + `${API_V1_PREFIX}/draw/results/${encoded}`, + ); +} diff --git a/src/api/index.ts b/src/api/index.ts index af9c46c..d89c487 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -1,6 +1,12 @@ export { API_V1_PREFIX } from "@/api/paths"; export { getHealth } from "@/api/health"; export { getPlayerPing, getPlayerMe } from "@/api/player"; +export { + getDrawCurrent, + getDrawResults, + getDrawResultByNo, + type GetDrawResultsParams, +} from "@/api/draw"; export { getWalletBalance, getWalletLogs, diff --git a/src/app/(player)/(main)/results/[drawNo]/page.tsx b/src/app/(player)/(main)/results/[drawNo]/page.tsx new file mode 100644 index 0000000..1f68e46 --- /dev/null +++ b/src/app/(player)/(main)/results/[drawNo]/page.tsx @@ -0,0 +1,20 @@ +import { DrawResultDetailScreen } from "@/features/results/draw-result-detail-screen"; + +type PageProps = { + params: Promise<{ drawNo: string }>; +}; + +export default async function DrawResultByNoPage(props: PageProps) { + const { drawNo: raw } = await props.params; + const drawNo = decodeURIComponent(raw); + + return ( +
+
+

开奖结果

+

当期明细 · 23 组分区

+
+ +
+ ); +} diff --git a/src/app/(player)/(main)/results/page.tsx b/src/app/(player)/(main)/results/page.tsx new file mode 100644 index 0000000..b0310a6 --- /dev/null +++ b/src/app/(player)/(main)/results/page.tsx @@ -0,0 +1,15 @@ +import { DrawResultsListScreen } from "@/features/results/draw-results-list-screen"; + +export default function DrawResultsHistoryPage() { + return ( +
+
+

开奖结果

+

+ 往期列表与时间以服务器 GMT 为准(界面文档 §4.6) +

+
+ +
+ ); +} diff --git a/src/components/layout/player-app-shell.tsx b/src/components/layout/player-app-shell.tsx index 7c32bcc..7c59eac 100644 --- a/src/components/layout/player-app-shell.tsx +++ b/src/components/layout/player-app-shell.tsx @@ -1,6 +1,7 @@ import Link from "next/link"; import type { ReactNode } from "react"; +import { PlayerBottomNav } from "@/components/layout/player-bottom-nav"; import { PlayerSessionBar } from "@/features/player/player-session-bar"; type PlayerAppShellProps = { @@ -8,39 +9,27 @@ type PlayerAppShellProps = { }; /** - * 玩家端业务区外壳:顶栏 + 主内容(移动端宽度上限,桌面居中)。 - * 标题 / 底部导航等后续再接 i18n、路由。 + * 玩家端外壳:顶栏(品牌 + 会话)+ 主体 + **底部 Tab 导航**(H5)。 + * 底部栏留白:{@link PlayerBottomNav} 对应 `padding-bottom`. */ export function PlayerAppShell({ children }: PlayerAppShellProps): ReactNode { return ( -
+
-
-
- - Lottery - - -
- +
+ + Lottery + +
-
+
{children}
+
); } diff --git a/src/components/layout/player-bottom-nav.tsx b/src/components/layout/player-bottom-nav.tsx new file mode 100644 index 0000000..a46fc16 --- /dev/null +++ b/src/components/layout/player-bottom-nav.tsx @@ -0,0 +1,64 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; + +import { LayoutGrid, Trophy, Wallet } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +const tabs = [ + { href: "/hall", label: "大厅", icon: LayoutGrid, match: (p: string) => p === "/hall" }, + { + href: "/wallet", + label: "钱包", + icon: Wallet, + match: (p: string) => p === "/wallet" || p.startsWith("/wallet/"), + }, + { + href: "/results", + label: "开奖", + icon: Trophy, + match: (p: string) => p === "/results" || p.startsWith("/results/"), + }, +] as const; + +/** + * 移动端 H5 主导航:底部 Tab(界面文档 §1.1 以手机为主)。 + */ +export function PlayerBottomNav() { + const pathname = usePathname() ?? ""; + + return ( + + ); +} diff --git a/src/features/draw/draw-status-meta.ts b/src/features/draw/draw-status-meta.ts new file mode 100644 index 0000000..507f2c8 --- /dev/null +++ b/src/features/draw/draw-status-meta.ts @@ -0,0 +1,35 @@ +export type DrawStatusHud = { + label: string; + /** Tailwind 颜色类:状态圆点 */ + dotClass: string; + /** 文案条(如「距封盘」) */ + countdownKind: "close" | "draw" | "cooldown" | "none"; +}; + +/** 对齐界面文档 §4.2 状态文案与 PRD 期号状态 */ +export function drawStatusHud(status: string): DrawStatusHud { + switch (status) { + case "pending": + return { label: "未开始", dotClass: "bg-muted-foreground", countdownKind: "none" }; + case "open": + return { label: "可下注", dotClass: "bg-emerald-500", countdownKind: "close" }; + case "closing": + return { label: "已封盘", dotClass: "bg-rose-500", countdownKind: "draw" }; + case "closed": + return { label: "待开奖", dotClass: "bg-amber-500", countdownKind: "draw" }; + case "drawing": + return { label: "开奖中", dotClass: "bg-sky-500", countdownKind: "none" }; + case "review": + return { label: "待审核", dotClass: "bg-violet-500", countdownKind: "none" }; + case "cooldown": + return { label: "冷静期", dotClass: "bg-cyan-500", countdownKind: "cooldown" }; + case "settling": + return { label: "结算中", dotClass: "bg-blue-600", countdownKind: "none" }; + case "settled": + return { label: "已结算", dotClass: "bg-muted-foreground", countdownKind: "none" }; + case "cancelled": + return { label: "已取消", dotClass: "bg-muted-foreground", countdownKind: "none" }; + default: + return { label: status, dotClass: "bg-muted-foreground", countdownKind: "none" }; + } +} diff --git a/src/features/hall/hall-draw-panel.tsx b/src/features/hall/hall-draw-panel.tsx new file mode 100644 index 0000000..60c9b6f --- /dev/null +++ b/src/features/hall/hall-draw-panel.tsx @@ -0,0 +1,264 @@ +"use client"; + +import Link from "next/link"; +import { useCallback, useEffect, useMemo, useState } from "react"; + +import { getDrawCurrent } from "@/api/draw"; +import { Button, buttonVariants } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; +import { drawStatusHud } from "@/features/draw/draw-status-meta"; +import { formatSecondsClock } from "@/lib/format-gmt"; +import { getLotteryEcho } from "@/lib/lottery-echo"; +import { formatLotteryInstant } from "@/lib/player-datetime"; +import { cn } from "@/lib/utils"; +import type { DrawCurrentPayload } from "@/types/api/draw-current"; + +/** 界面文档 §2.1:`draw.countdown` / `draw.status_change` / `result.published` 载荷 */ +type HallWsEnvelope = { + data: DrawCurrentPayload | null; + emitted_at_ms?: number; +}; + +/** + * 「服务器时间为准」:以载荷里的 `seconds_*` 为基准、`emitted_at_ms` 为锚点在本地推演(兜底 HTTP 或未收到秒的间隙)。 + */ +function applySnapshotDrift( + payload: DrawCurrentPayload, + emittedAtMs: number, + nowMs: number, +): DrawCurrentPayload { + const elapsed = Math.max(0, Math.floor((nowMs - emittedAtMs) / 1000)); + return { + ...payload, + seconds_to_close: Math.max(0, payload.seconds_to_close - elapsed), + seconds_to_draw: Math.max(0, payload.seconds_to_draw - elapsed), + seconds_remaining_in_cooldown: + payload.seconds_remaining_in_cooldown == null + ? null + : Math.max(0, payload.seconds_remaining_in_cooldown - elapsed), + }; +} + +function CountdownStrip({ + hud, + payload, +}: { + hud: ReturnType; + payload: DrawCurrentPayload; +}) { + if (hud.countdownKind === "close" && payload.seconds_to_close > 0) { + return ( +

+ 封盘倒计时:{" "} + + {formatSecondsClock(payload.seconds_to_close)} + +

+ ); + } + if (hud.countdownKind === "draw" && payload.seconds_to_draw > 0) { + return ( +

+ 距离开奖:{" "} + + {formatSecondsClock(payload.seconds_to_draw)} + +

+ ); + } + if (hud.countdownKind === "cooldown" && payload.seconds_remaining_in_cooldown != null) { + return ( +

+ 冷静期剩余:{" "} + + {formatSecondsClock(payload.seconds_remaining_in_cooldown)} + +

+ ); + } + return null; +} + +/** + * 界面文档 §2.1 / §2.2:WebSocket `draw.countdown`、`draw.status_change`、`result.published`; + * 降级:每 30s 轮询 `GET draw/current`。 + */ +export function HallDrawPanel() { + const [raw, setRaw] = useState(undefined); + const [emittedAtMs, setEmittedAtMs] = useState(() => Date.now()); + /** 推演用「当前毫秒」;`draw.countdown` 每秒到仍保留,避免零星丢包时停摆 */ + const [nowMs, setNowMs] = useState(() => Date.now()); + const [error, setError] = useState(null); + + const mergeFromWs = useCallback((evt: HallWsEnvelope) => { + setRaw(evt.data); + setEmittedAtMs(evt.emitted_at_ms ?? Date.now()); + }, []); + + const load = useCallback(async () => { + try { + setError(null); + const d = await getDrawCurrent(); + setRaw(d); + setEmittedAtMs(Date.now()); + } catch { + setError("加载失败,请下拉刷新"); + setRaw(undefined); + } + }, []); + + /** §2.2:WS 不可用或降级时每 30s 拉倒计时 */ + const refreshMs = useMemo(() => { + if (raw === undefined) return 10_000; + return raw ? 30_000 : 12_000; + }, [raw]); + + useEffect(() => { + const timer = window.setTimeout(() => { + void load(); + }, 0); + return () => window.clearTimeout(timer); + }, [load]); + + useEffect(() => { + const id = window.setInterval(() => { + void load(); + }, refreshMs); + return () => window.clearInterval(id); + }, [load, refreshMs]); + + useEffect(() => { + const bump = () => setNowMs(Date.now()); + bump(); + const sid = window.setInterval(bump, 1000); + const onVisibility = () => { + if (document.visibilityState === "visible") bump(); + }; + document.addEventListener("visibilitychange", onVisibility); + return () => { + window.clearInterval(sid); + document.removeEventListener("visibilitychange", onVisibility); + }; + }, []); + + useEffect(() => { + const echo = getLotteryEcho(); + if (!echo) return; + + echo + .channel("lottery-hall") + .listen(".draw.countdown", mergeFromWs) + .listen(".draw.status_change", mergeFromWs) + .listen(".result.published", mergeFromWs); + + return () => { + echo.leave("lottery-hall"); + }; + }, [mergeFromWs]); + + const display: DrawCurrentPayload | null | undefined = + raw === undefined || raw === null ? raw : applySnapshotDrift(raw, emittedAtMs, nowMs); + + if (error) { + return ( + + + 当期期号 + {error} + + + + + + ); + } + + if (raw === undefined || display === undefined) { + return ( + + + + + + + + + + ); + } + + if (raw === null || display === null) { + return ( + + + 当期期号 + 暂无可用期号,请稍后再试 + + + ); + } + + const hud = drawStatusHud(display.status); + + return ( + + +
+
+ + 第 {display.draw_no} 期 + + + + + {hud.label} + + {display.draw_time ? ( + + 计划开奖:{formatLotteryInstant(display.draw_time)} + + ) : null} + +
+ + 开奖结果 + +
+
+ + + {(display.status === "closing" || display.status === "closed") && ( +

+ 下注表格封盘置灰见实施计划 docs/06 §11.7、§13.3;当前可先前往「开奖结果」查看已发布往期。 +

+ )} + {Array.isArray(display.result_items) && display.result_items.length > 0 ? ( +
+ 本期号码已发布,完整 23 组展示见{" "} + + 当期结果 + + 。 +
+ ) : null} +
+
+ ); +} diff --git a/src/features/hall/hall-screen.tsx b/src/features/hall/hall-screen.tsx index e676fa6..67dce5a 100644 --- a/src/features/hall/hall-screen.tsx +++ b/src/features/hall/hall-screen.tsx @@ -9,24 +9,28 @@ import { } from "@/components/ui/card"; import { HallWalletStrip } from "@/features/hall/hall-wallet-strip"; +import { HallDrawPanel } from "@/features/hall/hall-draw-panel"; /** - * 下注大厅:顶部钱包条对齐高保真稿;以下为期号/表格占位。 + * 下注大厅:钱包条 §4 + 当期期号 §4.2;表格与封盘态见 docs/06 §11.7、§13.3。 */ export function HallScreen() { return (
+ - 下注大厅 + 下注表格 - Issue No.、倒计时、2D/3D/4D 表格与 Submit Bet 将按界面文档 §4.2 接续开发。 + 2D / 3D / 4D 动态列在阶段 5 接入玩法配置后按界面 §4.2 渲染(实施计划 docs/06 + §13.3「承接阶段 3」)。 - 封盘态、WebSocket 降级轮询等与 PRD §2 一致时再接入。 + 封盘整表置灰、按钮「已封盘」与 WebSocket 倒计时见 docs/06 §11.7 表、§13.3、§16.2 + 第二轮。
diff --git a/src/features/results/draw-result-detail-screen.tsx b/src/features/results/draw-result-detail-screen.tsx new file mode 100644 index 0000000..e982115 --- /dev/null +++ b/src/features/results/draw-result-detail-screen.tsx @@ -0,0 +1,130 @@ +"use client"; + +import Link from "next/link"; +import { useCallback, useEffect, useState } from "react"; + +import { getDrawResultByNo } from "@/api/draw"; +import { Button, buttonVariants } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; +import { TwentyThreeResultsGrid } from "@/features/results/twenty-three-results-grid"; +import { formatLotteryInstant } from "@/lib/player-datetime"; +import { cn } from "@/lib/utils"; +import type { DrawResultDetailPayload } from "@/types/api/draw-results"; + +type DrawResultDetailScreenProps = { + drawNo: string; +}; + +/** §4.6 开奖结果详情:23 分区 + [< >] 切换 */ +export function DrawResultDetailScreen({ drawNo }: DrawResultDetailScreenProps) { + const [data, setData] = useState(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + + const load = useCallback(async () => { + setLoading(true); + setError(null); + try { + const row = await getDrawResultByNo(drawNo); + setData(row); + } catch { + setData(null); + setError("该期开奖结果不可用或不存在"); + } finally { + setLoading(false); + } + }, [drawNo]); + + useEffect(() => { + void load(); + }, [load]); + + if (loading) { + return ( + + + + + + + + + + ); + } + + if (error || !data) { + return ( + + + 开奖结果 + {error ?? "无数据"} + + + + + 返回列表 + + + + ); + } + + return ( +
+ + +
+ {data.previous_draw_no ? ( + + ‹ 上一期 + + ) : ( + + )} + + {data.draw_no} + + {data.next_draw_no ? ( + + 下一期 › + + ) : ( + + )} +
+ + 开奖时间:{" "} + {formatLotteryInstant(data.draw_time_iso ?? data.draw_time ?? null)} + +
+ + +

+ 中奖号码高亮、「查看我的中奖情况」跳转注单并按该期筛选:见实施计划 docs/06 §11.7、§14.3「承接阶段 + 3」(界面 §4.6)。 +

+
+
+
+ ); +} diff --git a/src/features/results/draw-results-list-screen.tsx b/src/features/results/draw-results-list-screen.tsx new file mode 100644 index 0000000..4dfa65d --- /dev/null +++ b/src/features/results/draw-results-list-screen.tsx @@ -0,0 +1,137 @@ +"use client"; + +import Link from "next/link"; +import { useCallback, useEffect, useState } from "react"; + +import { getDrawResults } from "@/api/draw"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Skeleton } from "@/components/ui/skeleton"; +import { formatLotteryInstant } from "@/lib/player-datetime"; +import type { DrawResultListItem } from "@/types/api/draw-results"; + +/** §4.6 历史列表 + 默认最新一期入口 */ +export function DrawResultsListScreen() { + const [items, setItems] = useState(null); + const [error, setError] = useState(null); + const [date, setDate] = useState(""); + const [loading, setLoading] = useState(true); + + const fetchList = useCallback(async () => { + setError(null); + setLoading(true); + try { + const res = await getDrawResults({ + page: 1, + size: 30, + business_date: /^\d{4}-\d{2}-\d{2}$/.test(date) ? date : undefined, + }); + setItems(res.items); + } catch { + setError("加载失败"); + setItems(null); + } finally { + setLoading(false); + } + }, [date]); + + useEffect(() => { + void fetchList(); + }, [fetchList]); + + return ( +
+
+
+ + setDate(e.target.value)} + className="max-w-xs" + /> +
+ +
+ + {loading ? ( + + + + + + + + + + + ) : error ? ( + + + 开奖结果 + {error} + + + + + + ) : items && items.length === 0 ? ( + + + 开奖结果 + 暂无开奖结果 + + + ) : ( +
+ {items?.map((row) => ( + + +
+ {row.draw_no} + + 查看详情 → + +
+ + 开奖时间: + {formatLotteryInstant(row.draw_time_iso ?? row.draw_time ?? null)} + +
+ +
+
1st
+
{row.results["1st"]}
+
+
+
2nd
+
{row.results["2nd"]}
+
+
+
3rd
+
{row.results["3rd"]}
+
+
+
+ ))} +
+ )} +
+ ); +} diff --git a/src/features/results/twenty-three-results-grid.tsx b/src/features/results/twenty-three-results-grid.tsx new file mode 100644 index 0000000..ea7e7c4 --- /dev/null +++ b/src/features/results/twenty-three-results-grid.tsx @@ -0,0 +1,53 @@ +import type { DrawResultsNumbers } from "@/types/api/draw-results"; + +type TwentyThreeResultsGridProps = { + numbers: DrawResultsNumbers; +}; + +/** + * §4.6 开奖结果页:头/二/三奖 + Starter 10 + Consolation 10 + */ +export function TwentyThreeResultsGrid({ numbers }: TwentyThreeResultsGridProps) { + const starters = numbers.starter ?? []; + const consos = numbers.consolation ?? []; + + const cellCls = + "flex min-h-[2.75rem] items-center justify-center rounded-md border border-border bg-card font-mono text-base font-semibold tracking-wide tabular-nums"; + + return ( +
+
+ {(["1st", "2nd", "3rd"] as const).map((key) => ( +
+ + {key === "1st" ? "头奖" : key === "2nd" ? "二奖" : "三奖"} + +
{numbers[key] || "—"}
+
+ ))} +
+ +
+

特别奖 (Starter)

+
+ {Array.from({ length: 10 }).map((_, i) => ( +
+ {starters[i] ?? "—"} +
+ ))} +
+
+ +
+

安慰奖 (Consolation)

+
+ {Array.from({ length: 10 }).map((_, i) => ( +
+ {consos[i] ?? "—"} +
+ ))} +
+
+
+ ); +} diff --git a/src/lib/format-gmt.ts b/src/lib/format-gmt.ts new file mode 100644 index 0000000..e6ba7b1 --- /dev/null +++ b/src/lib/format-gmt.ts @@ -0,0 +1,8 @@ +/** 大厅倒计时等用到的 `mm:ss`,与展示用绝对时间互为补充;绝对时间见 {@link formatLotteryInstant} */ + +export function formatSecondsClock(total: number): string { + const s = Math.max(0, Math.floor(total)); + const mm = String(Math.floor(s / 60)).padStart(2, "0"); + const ss = String(s % 60).padStart(2, "0"); + return `${mm}:${ss}`; +} diff --git a/src/lib/lottery-echo.ts b/src/lib/lottery-echo.ts new file mode 100644 index 0000000..c468040 --- /dev/null +++ b/src/lib/lottery-echo.ts @@ -0,0 +1,54 @@ +import Echo from "laravel-echo"; +import Pusher from "pusher-js"; + +/** 需在浏览器挂载 Pusher(Reverb 走 pusher-js 协议) */ +function ensurePusherOnWindow(): void { + if (typeof window === "undefined") return; + ( + window as unknown as { + Pusher: typeof Pusher; + } + ).Pusher = Pusher; +} + +let echoSingleton: Echo<"reverb"> | null = null; + +/** + * NEXT_PUBLIC_REVERB_APP_KEY:与 Laravel .env `REVERB_APP_KEY` 相同 + * NEXT_PUBLIC_REVERB_HOST / PORT / SCHEME:浏览器连 Reverb WebSocket(通常 localhost:8080 + http/ws) + */ +export function getLotteryEcho(): Echo<"reverb"> | null { + if (typeof window === "undefined") return null; + + const key = process.env.NEXT_PUBLIC_REVERB_APP_KEY; + const host = process.env.NEXT_PUBLIC_REVERB_HOST; + if (!key?.length || !host?.length) { + return null; + } + + if (!echoSingleton) { + ensurePusherOnWindow(); + const port = Number(process.env.NEXT_PUBLIC_REVERB_PORT ?? 8080); + const scheme = process.env.NEXT_PUBLIC_REVERB_SCHEME ?? "http"; + const forceTLS = scheme === "https"; + + echoSingleton = new Echo({ + broadcaster: "reverb", + key, + wsHost: host, + wsPort: forceTLS ? 443 : port, + wssPort: forceTLS ? port : 443, + forceTLS, + enabledTransports: ["ws", "wss"], + }); + } + + return echoSingleton; +} + +export function disconnectLotteryEcho(): void { + if (echoSingleton) { + echoSingleton.disconnect(); + echoSingleton = null; + } +} diff --git a/src/lib/player-datetime.ts b/src/lib/player-datetime.ts new file mode 100644 index 0000000..344ed9c --- /dev/null +++ b/src/lib/player-datetime.ts @@ -0,0 +1,25 @@ +function pad2(n: number): string { + return String(n).padStart(2, "0"); +} + +/** + * 将接口 ISO 时间串格式化为 **浏览器本地时区** 下的 `YYYY-MM-DD HH:mm:ss`, + * 与后台 `lotteryadmin/src/lib/admin-datetime.ts` {@link formatAdminInstant} 行为一致。 + */ +export function formatLotteryInstant(iso: string | null | undefined): string { + if (iso == null || iso === "") { + return "—"; + } + const ms = Date.parse(iso); + if (Number.isNaN(ms)) { + return "—"; + } + const date = new Date(ms); + const y = date.getFullYear(); + const m = pad2(date.getMonth() + 1); + const d = pad2(date.getDate()); + const h = pad2(date.getHours()); + const min = pad2(date.getMinutes()); + const s = pad2(date.getSeconds()); + return `${y}-${m}-${d} ${h}:${min}:${s}`; +} diff --git a/src/types/api/draw-current.ts b/src/types/api/draw-current.ts new file mode 100644 index 0000000..ace2b24 --- /dev/null +++ b/src/types/api/draw-current.ts @@ -0,0 +1,27 @@ +/** `GET /api/v1/draw/current` 的 `data`(可能为 `null`) */ +export type DrawCurrentResultItem = { + prize_type: string; + prize_index: number; + number_4d: string; + suffix_3d: string | null; + suffix_2d: string | null; + head_digit: number | null; + tail_digit: number | null; +}; + +export type DrawCurrentPayload = { + draw_no: string; + business_date: string; + sequence_no: number; + status: string; + start_time: string | null; + close_time: string | null; + draw_time: string | null; + seconds_to_close: number; + seconds_to_draw: number; + cooling_end_time: string | null; + seconds_remaining_in_cooldown: number | null; + result_items?: DrawCurrentResultItem[]; + result_version?: number; + result_source?: string | null; +}; diff --git a/src/types/api/draw-results.ts b/src/types/api/draw-results.ts new file mode 100644 index 0000000..6329ef6 --- /dev/null +++ b/src/types/api/draw-results.ts @@ -0,0 +1,41 @@ +/** 与 PRD `results` 对象键名一致 */ +export type DrawResultsNumbers = { + "1st": string; + "2nd": string; + "3rd": string; + starter: string[]; + consolation: string[]; +}; + +export type DrawResultListItem = { + draw_id: string; + draw_no: string; + business_date: string; + draw_time: string | null; + draw_time_iso?: string | null; + result_version: number; + result_source: string | null; + results: DrawResultsNumbers; + result_items: Array<{ + prize_type: string; + prize_index: number; + number_4d: string; + suffix_3d: string | null; + suffix_2d: string | null; + head_digit: number | null; + tail_digit: number | null; + }>; +}; + +export type DrawResultsListPayload = { + items: DrawResultListItem[]; + total: number; + page: number; + per_page: number; + last_page: number; +}; + +export type DrawResultDetailPayload = DrawResultListItem & { + previous_draw_no: string | null; + next_draw_no: string | null; +};