feat: 增强大厅与结果展示功能

- 在 .env.example 中新增可选配置项 NEXT_PUBLIC_LOTTERY_PLAY_CURRENCY
- 在 API 模块中导出 getPlayEffective 函数
- 在 HallScreen 组件中引入 HallPlayCatalogPanel 以展示玩法目录
- 在多个屏幕组件中使用 queueMicrotask 优化数据加载逻辑
- 在 lottery-locale.ts 中新增 getLotteryRequestLocale 函数以支持语言选择
- 在类型定义中新增与玩法相关的类型导出
This commit is contained in:
2026-05-11 10:09:06 +08:00
parent 7e28cc154a
commit ea75120269
11 changed files with 400 additions and 8 deletions

View File

@@ -10,5 +10,8 @@ LOTTERY_API_PROXY_TARGET=http://127.0.0.1:8000
# 一般本地开发建议留空,让请求走同源 /api 代理,避免 CORS。 # 一般本地开发建议留空,让请求走同源 /api 代理,避免 CORS。
# NEXT_PUBLIC_LOTTERY_API_BASE_URL=http://127.0.0.1:8000 # 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 # NEXT_PUBLIC_MAIN_SITE_URL=http://localhost:5173

View File

@@ -7,6 +7,7 @@ export {
getDrawResultByNo, getDrawResultByNo,
type GetDrawResultsParams, type GetDrawResultsParams,
} from "@/api/draw"; } from "@/api/draw";
export { getPlayEffective, type GetPlayEffectiveParams } from "@/api/play";
export { export {
getWalletBalance, getWalletBalance,
getWalletLogs, getWalletLogs,

26
src/api/play.ts Normal file
View File

@@ -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<PlayEffectivePayload> {
return lotteryRequest.get<PlayEffectivePayload>(
`${API_V1_PREFIX}/play/effective`,
{
params:
params?.currency !== undefined && params.currency !== ""
? { currency: params.currency }
: undefined,
},
);
}

View File

@@ -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<LoadState>({ 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 (
<p className="text-sm text-muted-foreground"></p>
);
}
if (state.kind === "error") {
return (
<div className="space-y-2">
<p className="text-sm text-destructive">{state.message}</p>
<Button type="button" size="sm" variant="secondary" onClick={() => void load()}>
</Button>
</div>
);
}
const { data } = state;
const ordered = [...data.plays].sort(
(a, b) => a.sort_order - b.sort_order || a.play_code.localeCompare(b.play_code),
);
return (
<div className="space-y-6">
<p className="text-xs text-muted-foreground">
{data.currency_code} · play#{data.effective_versions.play_config.version_no}
odds#{data.effective_versions.odds.version_no} ·
</p>
<div className="overflow-x-auto rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="min-w-[140px]"></TableHead>
<TableHead className="w-[88px] text-center"></TableHead>
<TableHead className="min-w-[160px] whitespace-nowrap"></TableHead>
<TableHead className="min-w-[100px]">×</TableHead>
<TableHead className="min-w-[200px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{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 (
<TableRow
key={row.play_code}
className={cn(!open && "opacity-60")}
>
<TableCell>
<div className="flex flex-col gap-0.5">
<span className="font-medium">{pickDisplayName(row)}</span>
<span className="font-mono text-xs text-muted-foreground">
{row.play_code}
{row.dimension != null ? ` · ${row.dimension}D` : ""}
</span>
</div>
</TableCell>
<TableCell className="text-center">
{open ? (
<Badge variant="default" className="font-normal">
</Badge>
) : (
<Badge variant="secondary" className="font-normal">
</Badge>
)}
</TableCell>
<TableCell className="font-mono text-sm tabular-nums">
{row.config ? (
<>
{formatMoneyAmount(row.config.min_bet_amount)}
{" — "}
{formatMoneyAmount(row.config.max_bet_amount)}
</>
) : (
"—"
)}
</TableCell>
<TableCell className="font-mono text-sm tabular-nums">
{row.odds ? oddsMul : "—"}
</TableCell>
<TableCell className="text-muted-foreground text-sm leading-snug">
{rule ?? "—"}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
{data.risk_cap_items.length > 0 ? (
<div className="space-y-2">
<h3 className="text-sm font-medium text-foreground"></h3>
<div className="overflow-x-auto rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.risk_cap_items.map((r, i) => (
<TableRow key={`${r.normalized_number}-${i}`}>
<TableCell className="font-mono">{r.normalized_number}</TableCell>
<TableCell className="font-mono tabular-nums text-sm">
{formatMoneyAmount(r.cap_amount)}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{r.cap_type}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
) : null}
</div>
);
})();
return (
<Card>
<CardHeader className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
<div className="space-y-1">
<CardTitle className="text-base"></CardTitle>
<CardDescription>
<code className="text-xs">GET /api/v1/play/effective</code>
{DEFAULT_POLL_MS / 1000}s
</CardDescription>
</div>
<Button
type="button"
variant="outline"
size="sm"
className="shrink-0"
onClick={() => void load()}
>
</Button>
</CardHeader>
<CardContent>{body}</CardContent>
</Card>
);
}

View File

@@ -8,11 +8,12 @@ import {
CardTitle, CardTitle,
} from "@/components/ui/card"; } from "@/components/ui/card";
import { HallPlayCatalogPanel } from "@/features/hall/hall-play-catalog-panel";
import { HallWalletStrip } from "@/features/hall/hall-wallet-strip"; import { HallWalletStrip } from "@/features/hall/hall-wallet-strip";
import { HallDrawPanel } from "@/features/hall/hall-draw-panel"; 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() { export function HallScreen() {
return ( return (
@@ -20,17 +21,18 @@ export function HallScreen() {
<HallWalletStrip /> <HallWalletStrip />
<HallDrawPanel /> <HallDrawPanel />
<HallPlayCatalogPanel />
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-base"></CardTitle> <CardTitle className="text-base"></CardTitle>
<CardDescription> <CardDescription>
2D / 3D / 4D 5 §4.2 docs/06 5 2D / 3D / 4D
§13.3 3 docs/06 §13.3§16.2
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="text-sm text-muted-foreground"> <CardContent className="text-sm text-muted-foreground">
WebSocket docs/06 §11.7 §13.3§16.2 5
</CardContent> </CardContent>
</Card> </Card>
</div> </div>

View File

@@ -43,7 +43,9 @@ export function DrawResultDetailScreen({ drawNo }: DrawResultDetailScreenProps)
}, [drawNo]); }, [drawNo]);
useEffect(() => { useEffect(() => {
void load(); queueMicrotask(() => {
void load();
});
}, [load]); }, [load]);
if (loading) { if (loading) {

View File

@@ -44,7 +44,9 @@ export function DrawResultsListScreen() {
}, [date]); }, [date]);
useEffect(() => { useEffect(() => {
void fetchList(); queueMicrotask(() => {
void fetchList();
});
}, [fetchList]); }, [fetchList]);
return ( return (

View File

@@ -58,7 +58,9 @@ export function WalletLogsScreen() {
}, [filter]); }, [filter]);
useEffect(() => { useEffect(() => {
void load(); queueMicrotask(() => {
void load();
});
}, [load]); }, [load]);
return ( return (

View File

@@ -17,6 +17,11 @@ function isLotteryLocale(value: string): value is LotteryLocale {
return value === "zh" || value === "en" || value === "ne"; return value === "zh" || value === "en" || value === "ne";
} }
/** 供前端展示文案选用语言(与请求头 `X-Locale` 逻辑一致)。 */
export function getLotteryRequestLocale(): LotteryLocale {
return requestLocale();
}
function requestLocale(): LotteryLocale { function requestLocale(): LotteryLocale {
if (overrideLocale) { if (overrideLocale) {
return overrideLocale; return overrideLocale;

View File

@@ -16,3 +16,11 @@ export type {
WalletLogsData, WalletLogsData,
WalletPendingTransfer, WalletPendingTransfer,
} from "./wallet-logs"; } from "./wallet-logs";
export type {
PlayEffectiveConfigSlice,
PlayEffectiveOddsSlice,
PlayEffectivePayload,
PlayEffectivePlayRow,
PlayEffectiveRiskCapRow,
PlayEffectiveVersionHead,
} from "./play-effective";

View File

@@ -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[];
};