- 在 .env.example 中新增可选配置项 NEXT_PUBLIC_LOTTERY_PLAY_CURRENCY - 在 API 模块中导出 getPlayEffective 函数 - 在 HallScreen 组件中引入 HallPlayCatalogPanel 以展示玩法目录 - 在多个屏幕组件中使用 queueMicrotask 优化数据加载逻辑 - 在 lottery-locale.ts 中新增 getLotteryRequestLocale 函数以支持语言选择 - 在类型定义中新增与玩法相关的类型导出
280 lines
9.3 KiB
TypeScript
280 lines
9.3 KiB
TypeScript
"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>
|
||
);
|
||
}
|