feat: 更新依赖与增强功能
- 在 package.json 和 package-lock.json 中新增 laravel-echo 和 pusher-js 依赖 - 在 API 模块中新增 draw 相关函数的导出 - 在 PlayerAppShell 组件中引入 PlayerBottomNav 以增强底部导航 - 在 HallScreen 组件中引入 HallDrawPanel 以展示当前期号
This commit is contained in:
124
package-lock.json
generated
124
package-lock.json
generated
@@ -12,9 +12,11 @@
|
|||||||
"axios": "^1.16.0",
|
"axios": "^1.16.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"laravel-echo": "^2.3.4",
|
||||||
"lucide-react": "^1.14.0",
|
"lucide-react": "^1.14.0",
|
||||||
"next": "16.2.6",
|
"next": "16.2.6",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
|
"pusher-js": "^8.5.0",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
@@ -2109,6 +2111,13 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/@swc/helpers": {
|
||||||
"version": "0.5.15",
|
"version": "0.5.15",
|
||||||
"resolved": "https://registry.npmmirror.com/@swc/helpers/-/helpers-0.5.15.tgz",
|
"resolved": "https://registry.npmmirror.com/@swc/helpers/-/helpers-0.5.15.tgz",
|
||||||
@@ -4200,7 +4209,6 @@
|
|||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz",
|
||||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ms": "^2.1.3"
|
"ms": "^2.1.3"
|
||||||
@@ -4453,6 +4461,30 @@
|
|||||||
"node": ">= 0.8"
|
"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": {
|
"node_modules/enhanced-resolve": {
|
||||||
"version": "5.21.1",
|
"version": "5.21.1",
|
||||||
"resolved": "https://registry.npmmirror.com/enhanced-resolve/-/enhanced-resolve-5.21.1.tgz",
|
"resolved": "https://registry.npmmirror.com/enhanced-resolve/-/enhanced-resolve-5.21.1.tgz",
|
||||||
@@ -6911,6 +6943,19 @@
|
|||||||
"node": ">=0.10"
|
"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": {
|
"node_modules/levn": {
|
||||||
"version": "0.4.1",
|
"version": "0.4.1",
|
||||||
"resolved": "https://registry.npmmirror.com/levn/-/levn-0.4.1.tgz",
|
"resolved": "https://registry.npmmirror.com/levn/-/levn-0.4.1.tgz",
|
||||||
@@ -7447,7 +7492,6 @@
|
|||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz",
|
||||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/msw": {
|
"node_modules/msw": {
|
||||||
@@ -8348,6 +8392,15 @@
|
|||||||
"node": ">=6"
|
"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": {
|
"node_modules/qs": {
|
||||||
"version": "6.15.1",
|
"version": "6.15.1",
|
||||||
"resolved": "https://registry.npmmirror.com/qs/-/qs-6.15.1.tgz",
|
"resolved": "https://registry.npmmirror.com/qs/-/qs-6.15.1.tgz",
|
||||||
@@ -9163,6 +9216,36 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/sonner": {
|
||||||
"version": "2.0.7",
|
"version": "2.0.7",
|
||||||
"resolved": "https://registry.npmmirror.com/sonner/-/sonner-2.0.7.tgz",
|
"resolved": "https://registry.npmmirror.com/sonner/-/sonner-2.0.7.tgz",
|
||||||
@@ -9720,6 +9803,12 @@
|
|||||||
"url": "https://github.com/sponsors/Wombosvideo"
|
"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": {
|
"node_modules/type-check": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
"resolved": "https://registry.npmmirror.com/type-check/-/type-check-0.4.0.tgz",
|
"resolved": "https://registry.npmmirror.com/type-check/-/type-check-0.4.0.tgz",
|
||||||
@@ -10283,6 +10372,28 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/wsl-utils": {
|
||||||
"version": "0.3.1",
|
"version": "0.3.1",
|
||||||
"resolved": "https://registry.npmmirror.com/wsl-utils/-/wsl-utils-0.3.1.tgz",
|
"resolved": "https://registry.npmmirror.com/wsl-utils/-/wsl-utils-0.3.1.tgz",
|
||||||
@@ -10300,6 +10411,15 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/y18n": {
|
||||||
"version": "5.0.8",
|
"version": "5.0.8",
|
||||||
"resolved": "https://registry.npmmirror.com/y18n/-/y18n-5.0.8.tgz",
|
"resolved": "https://registry.npmmirror.com/y18n/-/y18n-5.0.8.tgz",
|
||||||
|
|||||||
@@ -13,9 +13,11 @@
|
|||||||
"axios": "^1.16.0",
|
"axios": "^1.16.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"laravel-echo": "^2.3.4",
|
||||||
"lucide-react": "^1.14.0",
|
"lucide-react": "^1.14.0",
|
||||||
"next": "16.2.6",
|
"next": "16.2.6",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
|
"pusher-js": "^8.5.0",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
|
|||||||
42
src/api/draw.ts
Normal file
42
src/api/draw.ts
Normal file
@@ -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<DrawCurrentPayload | null> {
|
||||||
|
return lotteryRequest.get<DrawCurrentPayload | null>(
|
||||||
|
`${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<DrawResultsListPayload> {
|
||||||
|
return lotteryRequest.get<DrawResultsListPayload>(
|
||||||
|
`${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<DrawResultDetailPayload> {
|
||||||
|
const encoded = encodeURIComponent(drawNo);
|
||||||
|
return lotteryRequest.get<DrawResultDetailPayload>(
|
||||||
|
`${API_V1_PREFIX}/draw/results/${encoded}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,12 @@
|
|||||||
export { API_V1_PREFIX } from "@/api/paths";
|
export { API_V1_PREFIX } from "@/api/paths";
|
||||||
export { getHealth } from "@/api/health";
|
export { getHealth } from "@/api/health";
|
||||||
export { getPlayerPing, getPlayerMe } from "@/api/player";
|
export { getPlayerPing, getPlayerMe } from "@/api/player";
|
||||||
|
export {
|
||||||
|
getDrawCurrent,
|
||||||
|
getDrawResults,
|
||||||
|
getDrawResultByNo,
|
||||||
|
type GetDrawResultsParams,
|
||||||
|
} from "@/api/draw";
|
||||||
export {
|
export {
|
||||||
getWalletBalance,
|
getWalletBalance,
|
||||||
getWalletLogs,
|
getWalletLogs,
|
||||||
|
|||||||
20
src/app/(player)/(main)/results/[drawNo]/page.tsx
Normal file
20
src/app/(player)/(main)/results/[drawNo]/page.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-lg font-semibold tracking-tight">开奖结果</h1>
|
||||||
|
<p className="text-xs text-muted-foreground">当期明细 · 23 组分区</p>
|
||||||
|
</div>
|
||||||
|
<DrawResultDetailScreen drawNo={drawNo} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
src/app/(player)/(main)/results/page.tsx
Normal file
15
src/app/(player)/(main)/results/page.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { DrawResultsListScreen } from "@/features/results/draw-results-list-screen";
|
||||||
|
|
||||||
|
export default function DrawResultsHistoryPage() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-lg font-semibold tracking-tight">开奖结果</h1>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
往期列表与时间以服务器 GMT 为准(界面文档 §4.6)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<DrawResultsListScreen />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
import { PlayerBottomNav } from "@/components/layout/player-bottom-nav";
|
||||||
import { PlayerSessionBar } from "@/features/player/player-session-bar";
|
import { PlayerSessionBar } from "@/features/player/player-session-bar";
|
||||||
|
|
||||||
type PlayerAppShellProps = {
|
type PlayerAppShellProps = {
|
||||||
@@ -8,39 +9,27 @@ type PlayerAppShellProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 玩家端业务区外壳:顶栏 + 主内容(移动端宽度上限,桌面居中)。
|
* 玩家端外壳:顶栏(品牌 + 会话)+ 主体 + **底部 Tab 导航**(H5)。
|
||||||
* 标题 / 底部导航等后续再接 i18n、路由。
|
* 底部栏留白:{@link PlayerBottomNav} 对应 `padding-bottom`.
|
||||||
*/
|
*/
|
||||||
export function PlayerAppShell({ children }: PlayerAppShellProps): ReactNode {
|
export function PlayerAppShell({ children }: PlayerAppShellProps): ReactNode {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-full flex-col bg-background text-foreground">
|
<div className="flex min-h-dvh flex-col bg-background text-foreground">
|
||||||
<header className="sticky top-0 z-40 shrink-0 border-b border-border bg-background/95 backdrop-blur-sm supports-[backdrop-filter]:bg-background/80">
|
<header className="sticky top-0 z-40 shrink-0 border-b border-border bg-background/95 backdrop-blur-sm supports-[backdrop-filter]:bg-background/80">
|
||||||
<div className="mx-auto flex h-12 max-w-lg items-center justify-between gap-2 px-4">
|
<div className="mx-auto flex h-12 max-w-lg items-center gap-2 px-4">
|
||||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
<Link
|
||||||
<span className="shrink-0 text-sm font-semibold tracking-tight">
|
href="/hall"
|
||||||
Lottery
|
className="shrink-0 text-sm font-semibold tracking-tight text-foreground no-underline hover:opacity-90"
|
||||||
</span>
|
>
|
||||||
<PlayerSessionBar className="min-w-0 border-l border-border pl-2" />
|
Lottery
|
||||||
</div>
|
</Link>
|
||||||
<nav className="flex shrink-0 items-center gap-2.5 text-xs font-medium sm:gap-3">
|
<PlayerSessionBar className="min-w-0 flex-1 border-l border-border pl-2" />
|
||||||
<Link
|
|
||||||
href="/hall"
|
|
||||||
className="text-muted-foreground transition-colors hover:text-foreground"
|
|
||||||
>
|
|
||||||
大厅
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href="/wallet"
|
|
||||||
className="text-muted-foreground transition-colors hover:text-foreground"
|
|
||||||
>
|
|
||||||
钱包
|
|
||||||
</Link>
|
|
||||||
</nav>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main className="mx-auto flex w-full max-w-lg flex-1 flex-col gap-4 px-4 py-4">
|
<main className="mx-auto flex w-full max-w-lg flex-1 flex-col gap-4 px-4 pb-[calc(3.5rem+env(safe-area-inset-bottom,0px)+0.75rem)] pt-4">
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
|
<PlayerBottomNav />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
64
src/components/layout/player-bottom-nav.tsx
Normal file
64
src/components/layout/player-bottom-nav.tsx
Normal file
@@ -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 (
|
||||||
|
<nav
|
||||||
|
className="fixed bottom-0 left-0 right-0 z-50 border-t border-border bg-background/95 pb-[env(safe-area-inset-bottom,0px)] backdrop-blur-md supports-[backdrop-filter]:bg-background/90"
|
||||||
|
aria-label="主导航"
|
||||||
|
>
|
||||||
|
<div className="mx-auto grid h-14 max-w-lg grid-cols-3">
|
||||||
|
{tabs.map(({ href, label, icon: Icon, match }) => {
|
||||||
|
const active = match(pathname);
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={href}
|
||||||
|
href={href}
|
||||||
|
prefetch
|
||||||
|
aria-current={active ? "page" : undefined}
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col items-center justify-center gap-0.5 text-[11px] font-medium transition-colors",
|
||||||
|
active
|
||||||
|
? "text-primary"
|
||||||
|
: "text-muted-foreground hover:text-foreground active:text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
aria-hidden
|
||||||
|
className={cn("size-[22px]", active && "stroke-[2.25px]")}
|
||||||
|
/>
|
||||||
|
{label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
35
src/features/draw/draw-status-meta.ts
Normal file
35
src/features/draw/draw-status-meta.ts
Normal file
@@ -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" };
|
||||||
|
}
|
||||||
|
}
|
||||||
264
src/features/hall/hall-draw-panel.tsx
Normal file
264
src/features/hall/hall-draw-panel.tsx
Normal file
@@ -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<typeof drawStatusHud>;
|
||||||
|
payload: DrawCurrentPayload;
|
||||||
|
}) {
|
||||||
|
if (hud.countdownKind === "close" && payload.seconds_to_close > 0) {
|
||||||
|
return (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
封盘倒计时:{" "}
|
||||||
|
<span className="font-mono text-base font-semibold tabular-nums text-foreground">
|
||||||
|
{formatSecondsClock(payload.seconds_to_close)}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (hud.countdownKind === "draw" && payload.seconds_to_draw > 0) {
|
||||||
|
return (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
距离开奖:{" "}
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"font-mono text-base font-semibold tabular-nums",
|
||||||
|
payload.status === "closing" && "text-rose-600 dark:text-rose-400",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{formatSecondsClock(payload.seconds_to_draw)}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (hud.countdownKind === "cooldown" && payload.seconds_remaining_in_cooldown != null) {
|
||||||
|
return (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
冷静期剩余:{" "}
|
||||||
|
<span className="font-mono text-base font-semibold tabular-nums text-foreground">
|
||||||
|
{formatSecondsClock(payload.seconds_remaining_in_cooldown)}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
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<DrawCurrentPayload | null | undefined>(undefined);
|
||||||
|
const [emittedAtMs, setEmittedAtMs] = useState(() => Date.now());
|
||||||
|
/** 推演用「当前毫秒」;`draw.countdown` 每秒到仍保留,避免零星丢包时停摆 */
|
||||||
|
const [nowMs, setNowMs] = useState(() => Date.now());
|
||||||
|
const [error, setError] = useState<string | null>(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 (
|
||||||
|
<Card className="border-destructive/40">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-base">当期期号</CardTitle>
|
||||||
|
<CardDescription>{error}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex gap-2">
|
||||||
|
<Button type="button" variant="secondary" size="sm" onClick={() => void load()}>
|
||||||
|
重试
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (raw === undefined || display === undefined) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="space-y-2 pb-2">
|
||||||
|
<Skeleton className="h-5 w-40" />
|
||||||
|
<Skeleton className="h-4 w-52" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<Skeleton className="h-12 w-full" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (raw === null || display === null) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-base">当期期号</CardTitle>
|
||||||
|
<CardDescription>暂无可用期号,请稍后再试</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hud = drawStatusHud(display.status);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className={cn(display.status === "closing" && "border-rose-500/40")}>
|
||||||
|
<CardHeader className="space-y-1 pb-2">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<CardTitle className="text-base leading-tight">
|
||||||
|
第 {display.draw_no} 期
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<span className={cn("inline-block size-2 rounded-full", hud.dotClass)} />
|
||||||
|
<span>{hud.label}</span>
|
||||||
|
</span>
|
||||||
|
{display.draw_time ? (
|
||||||
|
<span className="text-xs opacity-90">
|
||||||
|
计划开奖:{formatLotteryInstant(display.draw_time)}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href="/results"
|
||||||
|
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "shrink-0")}
|
||||||
|
>
|
||||||
|
开奖结果
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<CountdownStrip hud={hud} payload={display} />
|
||||||
|
{(display.status === "closing" || display.status === "closed") && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
下注表格封盘置灰见实施计划 docs/06 §11.7、§13.3;当前可先前往「开奖结果」查看已发布往期。
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{Array.isArray(display.result_items) && display.result_items.length > 0 ? (
|
||||||
|
<div className="rounded-lg border border-dashed border-border bg-muted/30 p-3 text-xs text-muted-foreground">
|
||||||
|
本期号码已发布,完整 23 组展示见{" "}
|
||||||
|
<Link href={`/results/${encodeURIComponent(display.draw_no)}`} className="font-medium text-primary underline-offset-4 hover:underline">
|
||||||
|
当期结果
|
||||||
|
</Link>
|
||||||
|
。
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,24 +9,28 @@ import {
|
|||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
|
|
||||||
import { HallWalletStrip } from "@/features/hall/hall-wallet-strip";
|
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() {
|
export function HallScreen() {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-5">
|
<div className="flex flex-col gap-5">
|
||||||
<HallWalletStrip />
|
<HallWalletStrip />
|
||||||
|
<HallDrawPanel />
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-base">下注大厅</CardTitle>
|
<CardTitle className="text-base">下注表格</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Issue No.、倒计时、2D/3D/4D 表格与 Submit Bet 将按界面文档 §4.2 接续开发。
|
2D / 3D / 4D 动态列在阶段 5 接入玩法配置后按界面 §4.2 渲染(实施计划 docs/06
|
||||||
|
§13.3「承接阶段 3」)。
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="text-sm text-muted-foreground">
|
<CardContent className="text-sm text-muted-foreground">
|
||||||
封盘态、WebSocket 降级轮询等与 PRD §2 一致时再接入。
|
封盘整表置灰、按钮「已封盘」与 WebSocket 倒计时见 docs/06 §11.7 表、§13.3、§16.2
|
||||||
|
第二轮。
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
130
src/features/results/draw-result-detail-screen.tsx
Normal file
130
src/features/results/draw-result-detail-screen.tsx
Normal file
@@ -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<DrawResultDetailPayload | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(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 (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<Skeleton className="h-7 w-48" />
|
||||||
|
<Skeleton className="h-4 w-56" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<Skeleton className="h-48 w-full" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !data) {
|
||||||
|
return (
|
||||||
|
<Card className="border-destructive/40">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">开奖结果</CardTitle>
|
||||||
|
<CardDescription>{error ?? "无数据"}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-wrap gap-2">
|
||||||
|
<Button type="button" size="sm" variant="secondary" onClick={() => void load()}>
|
||||||
|
重试
|
||||||
|
</Button>
|
||||||
|
<Link href="/results" className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
|
||||||
|
返回列表
|
||||||
|
</Link>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="space-y-3 pb-2">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
{data.previous_draw_no ? (
|
||||||
|
<Link
|
||||||
|
href={`/results/${encodeURIComponent(data.previous_draw_no)}`}
|
||||||
|
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "min-w-[5rem]")}
|
||||||
|
>
|
||||||
|
‹ 上一期
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<Button type="button" variant="outline" size="sm" className="min-w-[5rem]" disabled>
|
||||||
|
‹ 上一期
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<CardTitle className="text-center font-mono text-lg">
|
||||||
|
{data.draw_no}
|
||||||
|
</CardTitle>
|
||||||
|
{data.next_draw_no ? (
|
||||||
|
<Link
|
||||||
|
href={`/results/${encodeURIComponent(data.next_draw_no)}`}
|
||||||
|
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "min-w-[5rem]")}
|
||||||
|
>
|
||||||
|
下一期 ›
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<Button type="button" variant="outline" size="sm" className="min-w-[5rem]" disabled>
|
||||||
|
下一期 ›
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<CardDescription className="text-center font-mono text-sm">
|
||||||
|
开奖时间:{" "}
|
||||||
|
{formatLotteryInstant(data.draw_time_iso ?? data.draw_time ?? null)}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-2">
|
||||||
|
<TwentyThreeResultsGrid numbers={data.results} />
|
||||||
|
<p className="mt-4 text-xs text-muted-foreground">
|
||||||
|
中奖号码高亮、「查看我的中奖情况」跳转注单并按该期筛选:见实施计划 docs/06 §11.7、§14.3「承接阶段
|
||||||
|
3」(界面 §4.6)。
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
137
src/features/results/draw-results-list-screen.tsx
Normal file
137
src/features/results/draw-results-list-screen.tsx
Normal file
@@ -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<DrawResultListItem[] | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(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 (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-end">
|
||||||
|
<div className="flex flex-1 flex-col gap-1.5">
|
||||||
|
<Label htmlFor="biz-date">按业务日筛选</Label>
|
||||||
|
<Input
|
||||||
|
id="biz-date"
|
||||||
|
type="date"
|
||||||
|
value={date}
|
||||||
|
onChange={(e) => setDate(e.target.value)}
|
||||||
|
className="max-w-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button type="button" variant="secondary" size="sm" onClick={() => void fetchList()}>
|
||||||
|
应用
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<Skeleton className="h-6 w-32" />
|
||||||
|
<Skeleton className="h-4 w-48" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<Skeleton className="h-24 w-full" />
|
||||||
|
<Skeleton className="h-24 w-full" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : error ? (
|
||||||
|
<Card className="border-destructive/40">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">开奖结果</CardTitle>
|
||||||
|
<CardDescription>{error}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Button type="button" size="sm" onClick={() => void fetchList()}>
|
||||||
|
重试
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : items && items.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">开奖结果</CardTitle>
|
||||||
|
<CardDescription>暂无开奖结果</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{items?.map((row) => (
|
||||||
|
<Card key={row.draw_no}>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<CardTitle className="font-mono text-base">{row.draw_no}</CardTitle>
|
||||||
|
<Link
|
||||||
|
href={`/results/${encodeURIComponent(row.draw_no)}`}
|
||||||
|
className="text-sm font-medium text-primary underline-offset-4 hover:underline"
|
||||||
|
>
|
||||||
|
查看详情 →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<CardDescription className="font-mono text-xs">
|
||||||
|
开奖时间:
|
||||||
|
{formatLotteryInstant(row.draw_time_iso ?? row.draw_time ?? null)}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="grid grid-cols-3 gap-2 text-center font-mono text-sm">
|
||||||
|
<div className="rounded-md border bg-card py-2">
|
||||||
|
<div className="text-[10px] uppercase text-muted-foreground">1st</div>
|
||||||
|
<div className="font-semibold">{row.results["1st"]}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md border bg-card py-2">
|
||||||
|
<div className="text-[10px] uppercase text-muted-foreground">2nd</div>
|
||||||
|
<div className="font-semibold">{row.results["2nd"]}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md border bg-card py-2">
|
||||||
|
<div className="text-[10px] uppercase text-muted-foreground">3rd</div>
|
||||||
|
<div className="font-semibold">{row.results["3rd"]}</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
53
src/features/results/twenty-three-results-grid.tsx
Normal file
53
src/features/results/twenty-three-results-grid.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
{(["1st", "2nd", "3rd"] as const).map((key) => (
|
||||||
|
<div key={key} className="flex flex-col gap-1.5 text-center">
|
||||||
|
<span className="text-xs font-medium uppercase text-muted-foreground">
|
||||||
|
{key === "1st" ? "头奖" : key === "2nd" ? "二奖" : "三奖"}
|
||||||
|
</span>
|
||||||
|
<div className={cellCls}>{numbers[key] || "—"}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm font-medium text-foreground">特别奖 (Starter)</p>
|
||||||
|
<div className="grid grid-cols-5 gap-2">
|
||||||
|
{Array.from({ length: 10 }).map((_, i) => (
|
||||||
|
<div key={`s-${i}`} className={cellCls}>
|
||||||
|
{starters[i] ?? "—"}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm font-medium text-foreground">安慰奖 (Consolation)</p>
|
||||||
|
<div className="grid grid-cols-5 gap-2">
|
||||||
|
{Array.from({ length: 10 }).map((_, i) => (
|
||||||
|
<div key={`c-${i}`} className={cellCls}>
|
||||||
|
{consos[i] ?? "—"}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
8
src/lib/format-gmt.ts
Normal file
8
src/lib/format-gmt.ts
Normal file
@@ -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}`;
|
||||||
|
}
|
||||||
54
src/lib/lottery-echo.ts
Normal file
54
src/lib/lottery-echo.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
25
src/lib/player-datetime.ts
Normal file
25
src/lib/player-datetime.ts
Normal file
@@ -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}`;
|
||||||
|
}
|
||||||
27
src/types/api/draw-current.ts
Normal file
27
src/types/api/draw-current.ts
Normal file
@@ -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;
|
||||||
|
};
|
||||||
41
src/types/api/draw-results.ts
Normal file
41
src/types/api/draw-results.ts
Normal file
@@ -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;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user