feat: 增强大厅与结果展示功能
- 在 .env.example 中新增可选配置项 NEXT_PUBLIC_LOTTERY_PLAY_CURRENCY - 在 API 模块中导出 getPlayEffective 函数 - 在 HallScreen 组件中引入 HallPlayCatalogPanel 以展示玩法目录 - 在多个屏幕组件中使用 queueMicrotask 优化数据加载逻辑 - 在 lottery-locale.ts 中新增 getLotteryRequestLocale 函数以支持语言选择 - 在类型定义中新增与玩法相关的类型导出
This commit is contained in:
@@ -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
|
||||
@@ -7,6 +7,7 @@ export {
|
||||
getDrawResultByNo,
|
||||
type GetDrawResultsParams,
|
||||
} from "@/api/draw";
|
||||
export { getPlayEffective, type GetPlayEffectiveParams } from "@/api/play";
|
||||
export {
|
||||
getWalletBalance,
|
||||
getWalletLogs,
|
||||
|
||||
26
src/api/play.ts
Normal file
26
src/api/play.ts
Normal 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,
|
||||
},
|
||||
);
|
||||
}
|
||||
279
src/features/hall/hall-play-catalog-panel.tsx
Normal file
279
src/features/hall/hall-play-catalog-panel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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() {
|
||||
<HallWalletStrip />
|
||||
<HallDrawPanel />
|
||||
|
||||
<HallPlayCatalogPanel />
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">下注表格</CardTitle>
|
||||
<CardDescription>
|
||||
2D / 3D / 4D 动态列在阶段 5 接入玩法配置后按界面 §4.2 渲染(实施计划 docs/06
|
||||
§13.3「承接阶段 3」)。
|
||||
阶段 5:按玩法配置动态渲染 2D / 3D / 4D 下注格;封盘整表置灰与「已封盘」按钮见实施计划
|
||||
docs/06 §13.3、§16.2。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="text-sm text-muted-foreground">
|
||||
封盘整表置灰、按钮「已封盘」与 WebSocket 倒计时见 docs/06 §11.7 表、§13.3、§16.2
|
||||
第二轮。
|
||||
当前已展示开放玩法、限额与赔率快照;真实下注与售罄校验将在阶段 5 接入。
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -43,7 +43,9 @@ export function DrawResultDetailScreen({ drawNo }: DrawResultDetailScreenProps)
|
||||
}, [drawNo]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
queueMicrotask(() => {
|
||||
void load();
|
||||
});
|
||||
}, [load]);
|
||||
|
||||
if (loading) {
|
||||
|
||||
@@ -44,7 +44,9 @@ export function DrawResultsListScreen() {
|
||||
}, [date]);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchList();
|
||||
queueMicrotask(() => {
|
||||
void fetchList();
|
||||
});
|
||||
}, [fetchList]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -58,7 +58,9 @@ export function WalletLogsScreen() {
|
||||
}, [filter]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
queueMicrotask(() => {
|
||||
void load();
|
||||
});
|
||||
}, [load]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -16,3 +16,11 @@ export type {
|
||||
WalletLogsData,
|
||||
WalletPendingTransfer,
|
||||
} from "./wallet-logs";
|
||||
export type {
|
||||
PlayEffectiveConfigSlice,
|
||||
PlayEffectiveOddsSlice,
|
||||
PlayEffectivePayload,
|
||||
PlayEffectivePlayRow,
|
||||
PlayEffectiveRiskCapRow,
|
||||
PlayEffectiveVersionHead,
|
||||
} from "./play-effective";
|
||||
|
||||
62
src/types/api/play-effective.ts
Normal file
62
src/types/api/play-effective.ts
Normal 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[];
|
||||
};
|
||||
Reference in New Issue
Block a user