Files
lotteryFront/src/features/hall/hall-play-catalog-panel.tsx
kang ea75120269 feat: 增强大厅与结果展示功能
- 在 .env.example 中新增可选配置项 NEXT_PUBLIC_LOTTERY_PLAY_CURRENCY
- 在 API 模块中导出 getPlayEffective 函数
- 在 HallScreen 组件中引入 HallPlayCatalogPanel 以展示玩法目录
- 在多个屏幕组件中使用 queueMicrotask 优化数据加载逻辑
- 在 lottery-locale.ts 中新增 getLotteryRequestLocale 函数以支持语言选择
- 在类型定义中新增与玩法相关的类型导出
2026-05-11 10:09:06 +08:00

280 lines
9.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}