From ea75120269a334990c585216fcd89d12f9cbd340 Mon Sep 17 00:00:00 2001 From: kang Date: Mon, 11 May 2026 10:09:06 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=BC=BA=E5=A4=A7=E5=8E=85?= =?UTF-8?q?=E4=B8=8E=E7=BB=93=E6=9E=9C=E5=B1=95=E7=A4=BA=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 .env.example 中新增可选配置项 NEXT_PUBLIC_LOTTERY_PLAY_CURRENCY - 在 API 模块中导出 getPlayEffective 函数 - 在 HallScreen 组件中引入 HallPlayCatalogPanel 以展示玩法目录 - 在多个屏幕组件中使用 queueMicrotask 优化数据加载逻辑 - 在 lottery-locale.ts 中新增 getLotteryRequestLocale 函数以支持语言选择 - 在类型定义中新增与玩法相关的类型导出 --- .env.example | 3 + src/api/index.ts | 1 + src/api/play.ts | 26 ++ src/features/hall/hall-play-catalog-panel.tsx | 279 ++++++++++++++++++ src/features/hall/hall-screen.tsx | 12 +- .../results/draw-result-detail-screen.tsx | 4 +- .../results/draw-results-list-screen.tsx | 4 +- src/features/wallet/wallet-logs-screen.tsx | 4 +- src/lib/lottery-locale.ts | 5 + src/types/api/index.ts | 8 + src/types/api/play-effective.ts | 62 ++++ 11 files changed, 400 insertions(+), 8 deletions(-) create mode 100644 src/api/play.ts create mode 100644 src/features/hall/hall-play-catalog-panel.tsx create mode 100644 src/types/api/play-effective.ts diff --git a/.env.example b/.env.example index 14c8fef..b5a3b81 100644 --- a/.env.example +++ b/.env.example @@ -10,5 +10,8 @@ LOTTERY_API_PROXY_TARGET=http://127.0.0.1:8000 # 一般本地开发建议留空,让请求走同源 /api 代理,避免 CORS。 # NEXT_PUBLIC_LOTTERY_API_BASE_URL=http://127.0.0.1:8000 +# 可选:大厅「玩法与赔率」接口 `/api/v1/play/effective` 的 ?currency=(如 NPR);不设则由后端选默认可下注币种。 +# NEXT_PUBLIC_LOTTERY_PLAY_CURRENCY=NPR + # 可选:入口授权失败时“返回主站重新进入”的地址。 # NEXT_PUBLIC_MAIN_SITE_URL=http://localhost:5173 \ No newline at end of file diff --git a/src/api/index.ts b/src/api/index.ts index d89c487..46c6de8 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -7,6 +7,7 @@ export { getDrawResultByNo, type GetDrawResultsParams, } from "@/api/draw"; +export { getPlayEffective, type GetPlayEffectiveParams } from "@/api/play"; export { getWalletBalance, getWalletLogs, diff --git a/src/api/play.ts b/src/api/play.ts new file mode 100644 index 0000000..5f21d54 --- /dev/null +++ b/src/api/play.ts @@ -0,0 +1,26 @@ +import { lotteryRequest } from "@/lib/lottery-http"; +import { API_V1_PREFIX } from "@/api/paths"; +import type { PlayEffectivePayload } from "@/types/api/play-effective"; + +export type GetPlayEffectiveParams = { + /** 不传则后端取首个可下注币种(通常为 NPR) */ + currency?: string; +}; + +/** + * `GET /api/v1/play/effective`(公开;无需登录)。 + * 对齐阶段 4:生效玩法目录 + 赔率快照 + 封顶样本。 + */ +export function getPlayEffective( + params?: GetPlayEffectiveParams, +): Promise { + return lotteryRequest.get( + `${API_V1_PREFIX}/play/effective`, + { + params: + params?.currency !== undefined && params.currency !== "" + ? { currency: params.currency } + : undefined, + }, + ); +} diff --git a/src/features/hall/hall-play-catalog-panel.tsx b/src/features/hall/hall-play-catalog-panel.tsx new file mode 100644 index 0000000..a2a4edd --- /dev/null +++ b/src/features/hall/hall-play-catalog-panel.tsx @@ -0,0 +1,279 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; + +import { getPlayEffective } from "@/api/play"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { getLotteryRequestLocale } from "@/lib/lottery-locale"; +import { cn } from "@/lib/utils"; +import { LotteryApiBizError } from "@/types/api/errors"; +import type { + PlayEffectivePayload, + PlayEffectivePlayRow, +} from "@/types/api/play-effective"; + +const DEFAULT_POLL_MS = 120_000; + +function pickDisplayName(row: PlayEffectivePlayRow): string { + const loc = getLotteryRequestLocale(); + if (loc === "zh") { + return row.display_name_zh ?? row.display_name_en ?? row.play_code; + } + if (loc === "ne") { + return row.display_name_ne ?? row.display_name_en ?? row.play_code; + } + return row.display_name_en ?? row.display_name_zh ?? row.play_code; +} + +function pickRuleText(row: PlayEffectivePlayRow): string | null { + const c = row.config; + if (!c) { + return null; + } + const loc = getLotteryRequestLocale(); + if (loc === "zh") { + return c.rule_text_zh ?? c.rule_text_en; + } + if (loc === "ne") { + return c.rule_text_ne ?? c.rule_text_en; + } + return c.rule_text_en ?? c.rule_text_zh; +} + +/** 主数据开关 + 配置版本内开关,同时有 config 行才算对客开放 */ +function isPlayOpenForPlayer(row: PlayEffectivePlayRow): boolean { + if (!row.master_enabled || row.config === null) { + return false; + } + return row.config.is_enabled; +} + +type LoadState = + | { kind: "loading" } + | { kind: "ok"; data: PlayEffectivePayload } + | { kind: "error"; message: string; notReady?: boolean }; + +function formatMoneyAmount(n: number): string { + return new Intl.NumberFormat(undefined, { maximumFractionDigits: 0 }).format( + n, + ); +} + +export function HallPlayCatalogPanel() { + const [state, setState] = useState({ kind: "loading" }); + const currencyParam = useMemo(() => { + const fromEnv = process.env.NEXT_PUBLIC_LOTTERY_PLAY_CURRENCY?.trim(); + return fromEnv !== undefined && fromEnv !== "" ? fromEnv : undefined; + }, []); + + const load = useCallback(async () => { + setState((s) => (s.kind === "ok" ? s : { kind: "loading" })); + try { + const data = await getPlayEffective( + currencyParam !== undefined ? { currency: currencyParam } : undefined, + ); + setState({ kind: "ok", data }); + } catch (e) { + if (e instanceof LotteryApiBizError && e.code === 9004) { + setState({ + kind: "error", + message: + "玩法配置尚未初始化。请在 Laravel 执行含 OperationalConfigV1Seeder 的 seed。", + notReady: true, + }); + return; + } + const msg = + e instanceof LotteryApiBizError + ? e.message + : "加载玩法配置失败,请稍后重试。"; + setState({ kind: "error", message: msg }); + } + }, [currencyParam]); + + useEffect(() => { + queueMicrotask(() => { + void load(); + }); + }, [load]); + + useEffect(() => { + const id = window.setInterval(() => { + void load(); + }, DEFAULT_POLL_MS); + return () => window.clearInterval(id); + }, [load]); + + const body = (() => { + if (state.kind === "loading") { + return ( +

加载玩法与赔率…

+ ); + } + if (state.kind === "error") { + return ( +
+

{state.message}

+ +
+ ); + } + + const { data } = state; + const ordered = [...data.plays].sort( + (a, b) => a.sort_order - b.sort_order || a.play_code.localeCompare(b.play_code), + ); + + return ( +
+

+ 币种 {data.currency_code} · 配置版本 play#{data.effective_versions.play_config.version_no} + /odds#{data.effective_versions.odds.version_no} · 限额单位为最小货币单位(与钱包一致)。 +

+ +
+ + + + 玩法 + 状态 + 下注限额 + 赔率× + 说明 + + + + {ordered.map((row) => { + const open = isPlayOpenForPlayer(row); + const rule = pickRuleText(row); + const oddsMul = + row.odds === null + ? "—" + : typeof row.odds.odds_multiplier === "number" + ? row.odds.odds_multiplier.toFixed(4) + : (row.odds.odds_value / 10000).toFixed(4); + + return ( + + +
+ {pickDisplayName(row)} + + {row.play_code} + {row.dimension != null ? ` · ${row.dimension}D` : ""} + +
+
+ + {open ? ( + + 开放 + + ) : ( + + 关闭 + + )} + + + {row.config ? ( + <> + {formatMoneyAmount(row.config.min_bet_amount)} + {" — "} + {formatMoneyAmount(row.config.max_bet_amount)} + + ) : ( + "—" + )} + + + {row.odds ? oddsMul : "—"} + + + {rule ?? "—"} + +
+ ); + })} +
+
+
+ + {data.risk_cap_items.length > 0 ? ( +
+

风控封顶(示例号码)

+
+ + + + 号码 + 封顶额 + 类型 + + + + {data.risk_cap_items.map((r, i) => ( + + {r.normalized_number} + + {formatMoneyAmount(r.cap_amount)} + + + {r.cap_type} + + + ))} + +
+
+
+ ) : null} +
+ ); + })(); + + return ( + + +
+ 玩法与赔率 + + 数据来自 GET /api/v1/play/effective + ;后台修改并发布后,最长约 {DEFAULT_POLL_MS / 1000}s 内自动刷新(亦可手动刷新)。 + +
+ +
+ {body} +
+ ); +} diff --git a/src/features/hall/hall-screen.tsx b/src/features/hall/hall-screen.tsx index 67dce5a..141b017 100644 --- a/src/features/hall/hall-screen.tsx +++ b/src/features/hall/hall-screen.tsx @@ -8,11 +8,12 @@ import { CardTitle, } from "@/components/ui/card"; +import { HallPlayCatalogPanel } from "@/features/hall/hall-play-catalog-panel"; 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。 + * 下注大厅:钱包条 §4 + 当期期号 §4.2;玩法目录阶段 4(§12.3);下注表格阶段 5(§13.3)。 */ export function HallScreen() { return ( @@ -20,17 +21,18 @@ export function HallScreen() { + + 下注表格 - 2D / 3D / 4D 动态列在阶段 5 接入玩法配置后按界面 §4.2 渲染(实施计划 docs/06 - §13.3「承接阶段 3」)。 + 阶段 5:按玩法配置动态渲染 2D / 3D / 4D 下注格;封盘整表置灰与「已封盘」按钮见实施计划 + docs/06 §13.3、§16.2。 - 封盘整表置灰、按钮「已封盘」与 WebSocket 倒计时见 docs/06 §11.7 表、§13.3、§16.2 - 第二轮。 + 当前已展示开放玩法、限额与赔率快照;真实下注与售罄校验将在阶段 5 接入。 diff --git a/src/features/results/draw-result-detail-screen.tsx b/src/features/results/draw-result-detail-screen.tsx index e982115..eaf012c 100644 --- a/src/features/results/draw-result-detail-screen.tsx +++ b/src/features/results/draw-result-detail-screen.tsx @@ -43,7 +43,9 @@ export function DrawResultDetailScreen({ drawNo }: DrawResultDetailScreenProps) }, [drawNo]); useEffect(() => { - void load(); + queueMicrotask(() => { + void load(); + }); }, [load]); if (loading) { diff --git a/src/features/results/draw-results-list-screen.tsx b/src/features/results/draw-results-list-screen.tsx index 4dfa65d..628c61b 100644 --- a/src/features/results/draw-results-list-screen.tsx +++ b/src/features/results/draw-results-list-screen.tsx @@ -44,7 +44,9 @@ export function DrawResultsListScreen() { }, [date]); useEffect(() => { - void fetchList(); + queueMicrotask(() => { + void fetchList(); + }); }, [fetchList]); return ( diff --git a/src/features/wallet/wallet-logs-screen.tsx b/src/features/wallet/wallet-logs-screen.tsx index 187f58a..bfdcf1a 100644 --- a/src/features/wallet/wallet-logs-screen.tsx +++ b/src/features/wallet/wallet-logs-screen.tsx @@ -58,7 +58,9 @@ export function WalletLogsScreen() { }, [filter]); useEffect(() => { - void load(); + queueMicrotask(() => { + void load(); + }); }, [load]); return ( diff --git a/src/lib/lottery-locale.ts b/src/lib/lottery-locale.ts index 7cccb5e..b7fe109 100644 --- a/src/lib/lottery-locale.ts +++ b/src/lib/lottery-locale.ts @@ -17,6 +17,11 @@ function isLotteryLocale(value: string): value is LotteryLocale { return value === "zh" || value === "en" || value === "ne"; } +/** 供前端展示文案选用语言(与请求头 `X-Locale` 逻辑一致)。 */ +export function getLotteryRequestLocale(): LotteryLocale { + return requestLocale(); +} + function requestLocale(): LotteryLocale { if (overrideLocale) { return overrideLocale; diff --git a/src/types/api/index.ts b/src/types/api/index.ts index 4d6c721..06affe9 100644 --- a/src/types/api/index.ts +++ b/src/types/api/index.ts @@ -16,3 +16,11 @@ export type { WalletLogsData, WalletPendingTransfer, } from "./wallet-logs"; +export type { + PlayEffectiveConfigSlice, + PlayEffectiveOddsSlice, + PlayEffectivePayload, + PlayEffectivePlayRow, + PlayEffectiveRiskCapRow, + PlayEffectiveVersionHead, +} from "./play-effective"; diff --git a/src/types/api/play-effective.ts b/src/types/api/play-effective.ts new file mode 100644 index 0000000..9a782ac --- /dev/null +++ b/src/types/api/play-effective.ts @@ -0,0 +1,62 @@ +/** `GET /api/v1/play/effective` → `data` */ + +export type PlayEffectiveVersionHead = { + id: number; + version_no: number; + effective_at: string | null; +}; + +export type PlayEffectiveConfigSlice = { + is_enabled: boolean; + min_bet_amount: number; + max_bet_amount: number; + display_order: number; + rule_text_zh: string | null; + rule_text_en: string | null; + rule_text_ne: string | null; + extra_config_json: unknown; +}; + +export type PlayEffectiveOddsSlice = { + prize_scope: string; + odds_value: number; + rebate_rate: string; + commission_rate: string; + currency_code: string; + extra_config_json: unknown; + /** 赔率乘数小数(与 odds_value/10000 一致) */ + odds_multiplier?: number; +}; + +export type PlayEffectivePlayRow = { + play_code: string; + category: string; + dimension: number | null; + bet_mode: string | null; + display_name_zh: string | null; + display_name_en: string | null; + display_name_ne: string | null; + sort_order: number; + supports_multi_number: boolean; + master_enabled: boolean; + config: PlayEffectiveConfigSlice | null; + odds: PlayEffectiveOddsSlice | null; +}; + +export type PlayEffectiveRiskCapRow = { + draw_id: number | null; + normalized_number: string; + cap_amount: number; + cap_type: string; +}; + +export type PlayEffectivePayload = { + currency_code: string; + effective_versions: { + play_config: PlayEffectiveVersionHead; + odds: PlayEffectiveVersionHead; + risk_cap: PlayEffectiveVersionHead; + }; + plays: PlayEffectivePlayRow[]; + risk_cap_items: PlayEffectiveRiskCapRow[]; +};