feat: 新增玩法规则页并增强注单筛选与结果展示
This commit is contained in:
@@ -10,6 +10,10 @@ export type GetTicketItemsParams = {
|
||||
page?: number;
|
||||
per_page?: number;
|
||||
draw_no?: string;
|
||||
number?: string;
|
||||
status?: string[];
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
};
|
||||
|
||||
/** `GET /api/v1/ticket/items`(需登录) */
|
||||
@@ -23,6 +27,10 @@ export function getTicketItems(
|
||||
page: params?.page,
|
||||
per_page: params?.per_page,
|
||||
draw_no: params?.draw_no,
|
||||
number: params?.number,
|
||||
status: params?.status,
|
||||
start_date: params?.start_date,
|
||||
end_date: params?.end_date,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
5
src/app/(player)/(main)/rules/page.tsx
Normal file
5
src/app/(player)/(main)/rules/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { PlayRulesScreen } from "@/features/rules/play-rules-screen";
|
||||
|
||||
export default function RulesPage() {
|
||||
return <PlayRulesScreen />;
|
||||
}
|
||||
@@ -3,28 +3,44 @@
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
import { BarChart3, ClipboardList, Home, Wallet } from "lucide-react";
|
||||
import { BarChart3, BookOpen, ClipboardList, Home, Wallet } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const tabs = [
|
||||
{ href: "/hall", labelKey: "nav.home", icon: Home, match: (p: string) => p === "/hall" },
|
||||
{
|
||||
href: "/hall",
|
||||
labelKey: "nav.home",
|
||||
labelDefault: "首页",
|
||||
icon: Home,
|
||||
match: (p: string) => p === "/hall",
|
||||
},
|
||||
{
|
||||
href: "/results",
|
||||
labelKey: "nav.results",
|
||||
labelDefault: "开奖结果",
|
||||
icon: BarChart3,
|
||||
match: (p: string) => p === "/results" || p.startsWith("/results/"),
|
||||
},
|
||||
{
|
||||
href: "/orders",
|
||||
labelKey: "nav.orders",
|
||||
labelDefault: "我的注单",
|
||||
icon: ClipboardList,
|
||||
match: (p: string) => p === "/orders" || p.startsWith("/orders/"),
|
||||
},
|
||||
{
|
||||
href: "/rules",
|
||||
labelKey: "nav.rules",
|
||||
labelDefault: "规则",
|
||||
icon: BookOpen,
|
||||
match: (p: string) => p === "/rules",
|
||||
},
|
||||
{
|
||||
href: "/wallet",
|
||||
labelKey: "nav.wallet",
|
||||
labelDefault: "钱包",
|
||||
icon: Wallet,
|
||||
match: (p: string) => p === "/wallet" || p.startsWith("/wallet/"),
|
||||
},
|
||||
@@ -42,10 +58,10 @@ export function PlayerBottomNav() {
|
||||
className="fixed bottom-0 left-0 right-0 z-50 border-t border-[#e4ebf5] bg-white/96 pb-[env(safe-area-inset-bottom,0px)] shadow-[0_-10px_30px_rgba(15,23,42,0.08)] backdrop-blur-md"
|
||||
aria-label={t("nav.aria")}
|
||||
>
|
||||
<div className="mx-auto grid h-16 w-full max-w-lg grid-rows-1 [grid-template-columns:repeat(4,minmax(0,1fr))]">
|
||||
{tabs.map(({ href, labelKey, icon: Icon, match }) => {
|
||||
<div className="mx-auto grid h-16 w-full max-w-lg grid-rows-1 [grid-template-columns:repeat(5,minmax(0,1fr))]">
|
||||
{tabs.map(({ href, labelKey, labelDefault, icon: Icon, match }) => {
|
||||
const active = match(pathname);
|
||||
const label = t(labelKey);
|
||||
const label = t(labelKey, { defaultValue: labelDefault });
|
||||
return (
|
||||
<Link
|
||||
key={href}
|
||||
|
||||
@@ -17,6 +17,7 @@ type PlayerPanelProps = {
|
||||
backHref?: string;
|
||||
backLabel?: string;
|
||||
className?: string;
|
||||
containerClassName?: string;
|
||||
};
|
||||
|
||||
export function PlayerPanel({
|
||||
@@ -26,13 +27,14 @@ export function PlayerPanel({
|
||||
backHref = "/hall",
|
||||
backLabel,
|
||||
className,
|
||||
containerClassName,
|
||||
}: PlayerPanelProps) {
|
||||
const { t } = useTranslation("common");
|
||||
const { t: tp } = useTranslation("player");
|
||||
const resolvedBackLabel = backLabel ?? tp("panel.home");
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-[480px]">
|
||||
<div className={cn("mx-auto w-full max-w-[480px]", containerClassName)}>
|
||||
<section
|
||||
className={cn(
|
||||
"overflow-hidden bg-white px-4 pb-8 pt-4 text-slate-900",
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { AlertTriangleIcon } from "lucide-react";
|
||||
import {
|
||||
AlertTriangleIcon,
|
||||
CheckCircle2,
|
||||
LoaderCircle,
|
||||
WalletCards,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
@@ -12,9 +18,8 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { formatMinorAsCurrency } from "@/lib/money";
|
||||
import { playLabel } from "@/lib/play-labels";
|
||||
import type { TicketPreviewData, TicketPreviewWarning } from "@/types/api/ticket";
|
||||
|
||||
type HallBetPreviewDialogProps = {
|
||||
@@ -33,17 +38,17 @@ function WarningsBlock({ warnings }: { warnings: TicketPreviewWarning[] }) {
|
||||
|
||||
if (warnings.length === 0) return null;
|
||||
return (
|
||||
<Alert className="border-amber-200 bg-amber-50 text-amber-950">
|
||||
<AlertTriangleIcon />
|
||||
<AlertTitle>{t("hall.preview.warningsTitle")}</AlertTitle>
|
||||
<AlertDescription className="space-y-1">
|
||||
<p className="text-xs leading-relaxed">
|
||||
<Alert className="rounded-xl border-[#ffd7b8] bg-[#fff7ed] p-3 text-[#b45309]">
|
||||
<AlertTriangleIcon className="size-5" />
|
||||
<AlertTitle className="text-sm font-black">{t("hall.preview.warningsTitle")}</AlertTitle>
|
||||
<AlertDescription className="space-y-2">
|
||||
<p className="text-xs leading-relaxed text-[#92400e]">
|
||||
{t("hall.preview.warningsDescription")}
|
||||
</p>
|
||||
<ul className="list-inside list-disc text-xs">
|
||||
<ul className="space-y-1 text-xs text-slate-700">
|
||||
{warnings.map((w, i) => (
|
||||
<li key={`${w.number_4d}-${i}`}>
|
||||
<span className="font-mono">{w.number_4d}</span> — {w.message}
|
||||
<span className="font-mono font-black text-[#e5002c]">{w.number_4d}</span> · {w.message}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -52,6 +57,38 @@ function WarningsBlock({ warnings }: { warnings: TicketPreviewWarning[] }) {
|
||||
);
|
||||
}
|
||||
|
||||
function SubmittingPanel() {
|
||||
const { t } = useTranslation("player");
|
||||
|
||||
return (
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
className="max-w-[340px] overflow-hidden rounded-2xl border border-white/70 bg-white p-0 shadow-[0_24px_70px_rgba(15,23,42,0.22)]"
|
||||
>
|
||||
<div className="px-8 py-10 text-center">
|
||||
<div className="mx-auto flex size-20 rotate-[-8deg] items-center justify-center rounded-2xl bg-[#e5002c] text-4xl font-black italic text-white shadow-[0_16px_28px_rgba(229,0,44,0.28)]">
|
||||
N
|
||||
</div>
|
||||
<div className="mx-auto mt-8 h-2 w-44 overflow-hidden rounded-full bg-[#dbe3f1]">
|
||||
<div className="h-full w-2/3 rounded-full bg-[#0755c7] shadow-[0_0_14px_rgba(7,85,199,0.45)]" />
|
||||
</div>
|
||||
<DialogHeader className="mt-8 items-center gap-2">
|
||||
<DialogTitle className="text-xl font-black text-slate-950">
|
||||
{t("hall.preview.processingTitle", { defaultValue: "正在提交下注" })}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="max-w-[220px] text-center text-sm leading-relaxed text-slate-500">
|
||||
{t("hall.preview.processingDescription", { defaultValue: "请勿关闭页面或返回上一页。" })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="mt-7 inline-flex items-center gap-2 text-sm font-semibold text-slate-500">
|
||||
<LoaderCircle className="size-4 animate-spin text-[#0755c7]" />
|
||||
{t("hall.preview.processingProgress", { defaultValue: "正在处理注单..." })}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 预览弹窗 + 提交确认(产品文档 §10.1.2:预览不下单,确认后 place)。
|
||||
*/
|
||||
@@ -68,15 +105,40 @@ export function HallBetPreviewDialog({
|
||||
const summary = data?.summary;
|
||||
const lines = data?.lines ?? [];
|
||||
|
||||
if (placing) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={() => {}}>
|
||||
<SubmittingPanel />
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-h-[min(90vh,560px)] gap-0 overflow-hidden border-[#e8eef7] p-0 shadow-[0_12px_40px_rgba(15,23,42,0.12)] ring-[#e8eef7] sm:max-w-md">
|
||||
<div className="p-4 pb-2">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-lg font-black text-[#0b3f96]">
|
||||
{t("hall.preview.title")}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs leading-relaxed text-slate-500">
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
className="max-h-[min(92vh,760px)] gap-0 overflow-hidden rounded-2xl border-[#e4ebf6] bg-white p-0 shadow-[0_18px_60px_rgba(15,23,42,0.18)] ring-[#e8eef7] sm:max-w-lg"
|
||||
>
|
||||
<div className="relative px-4 pb-3 pt-5 sm:px-5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={placing}
|
||||
className="absolute right-3 top-3 inline-flex size-9 items-center justify-center rounded-full text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-700 disabled:opacity-50"
|
||||
aria-label={t("actions.close", { defaultValue: "关闭" })}
|
||||
>
|
||||
<XIcon className="size-5" />
|
||||
</button>
|
||||
<DialogHeader className="pr-10">
|
||||
<div className="mb-2 flex items-center gap-3">
|
||||
<span className="flex size-11 items-center justify-center rounded-full bg-[#fff0f3] text-[#e5002c]">
|
||||
<WalletCards className="size-6" />
|
||||
</span>
|
||||
<DialogTitle className="text-xl font-black text-slate-950">
|
||||
{t("hall.preview.title")}
|
||||
</DialogTitle>
|
||||
</div>
|
||||
<DialogDescription className="text-sm leading-relaxed text-slate-500">
|
||||
{t("hall.preview.description")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
@@ -91,109 +153,122 @@ export function HallBetPreviewDialog({
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<ScrollArea className="max-h-[min(52vh,360px)] border-y border-[#e8eef7] px-4">
|
||||
<div className="space-y-4 py-3 pr-3">
|
||||
<div
|
||||
className="overflow-y-auto border-y border-[#e8eef7] px-4 sm:px-5"
|
||||
style={{ maxHeight: "min(58vh, 470px)" }}
|
||||
>
|
||||
<div className="space-y-4 py-4">
|
||||
{!data ? (
|
||||
<p className="text-sm text-slate-500">{t("hall.preview.empty")}</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="rounded-xl border border-[#e8eef7] bg-[#f8fbff] p-3 text-xs shadow-[0_4px_14px_rgba(15,23,42,0.04)]">
|
||||
<p className="text-slate-600">
|
||||
{t("hall.preview.draw")}{" "}
|
||||
<span className="font-mono font-black text-[#0b3f96]">{data.draw.draw_id}</span> ·{" "}
|
||||
{t("hall.preview.status")}{" "}
|
||||
<span className="font-semibold text-[#32518d]">{data.draw.status}</span>
|
||||
</p>
|
||||
{summary ? (
|
||||
<ul className="mt-2 space-y-1 tabular-nums text-slate-700">
|
||||
<li>
|
||||
{t("hall.preview.amount")}{" "}
|
||||
<span className="font-semibold text-[#d81435]">
|
||||
{formatMinorAsCurrency(summary.total_bet_amount, currencyCode)}
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
{t("hall.preview.rebate")}{" "}
|
||||
<span className="font-semibold text-emerald-600">
|
||||
{formatMinorAsCurrency(summary.total_rebate_amount, currencyCode)}
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
{t("hall.preview.actual")}{" "}
|
||||
<span className="font-bold text-[#0b3f96]">
|
||||
{formatMinorAsCurrency(summary.total_actual_deduct, currencyCode)}
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
{t("hall.preview.total")}{" "}
|
||||
<span className="font-medium text-slate-800">
|
||||
{formatMinorAsCurrency(summary.total_actual_deduct, currencyCode)}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
) : null}
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||
<span className="text-slate-500">{t("hall.preview.draw")}:</span>
|
||||
<span className="font-mono font-black text-[#e5002c]">{data.draw.draw_id}</span>
|
||||
<span className="rounded-full bg-[#edf4ff] px-2 py-0.5 text-xs font-bold text-[#0b3f96]">
|
||||
{data.draw.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<WarningsBlock warnings={data.warnings} />
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
|
||||
{t("hall.preview.lines")}
|
||||
</p>
|
||||
<ul className="space-y-2 text-sm">
|
||||
{lines.map((ln) => (
|
||||
<li
|
||||
key={ln.client_line_no}
|
||||
className="rounded-xl border border-[#e6edf8] bg-white px-3 py-2 shadow-[0_4px_14px_rgba(15,23,42,0.04)]"
|
||||
>
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
||||
<span className="font-mono text-xs text-slate-400">
|
||||
#{ln.client_line_no}
|
||||
</span>
|
||||
<span className="font-mono text-sm font-semibold text-[#32518d]">
|
||||
<div className="overflow-x-auto rounded-xl border border-[#dfe8f6]">
|
||||
<table className="min-w-[520px] w-full border-collapse text-xs">
|
||||
<thead className="bg-[#f4f7fd] text-[#304f86]">
|
||||
<tr>
|
||||
<th className="w-10 border-r border-[#dfe8f6] px-2 py-3 text-center font-black">No.</th>
|
||||
<th className="border-r border-[#dfe8f6] px-2 py-3 text-center font-black">
|
||||
{t("hall.result.number", { defaultValue: "号码" })}
|
||||
</th>
|
||||
<th className="border-r border-[#dfe8f6] px-2 py-3 text-center font-black">
|
||||
{t("orders.play", { defaultValue: "玩法" })}
|
||||
</th>
|
||||
<th className="border-r border-[#dfe8f6] px-2 py-3 text-center font-black">
|
||||
{t("hall.preview.amount")}
|
||||
</th>
|
||||
<th className="border-r border-[#dfe8f6] px-2 py-3 text-center font-black">
|
||||
{t("hall.preview.rebate")}
|
||||
</th>
|
||||
<th className="px-2 py-3 text-center font-black">
|
||||
{t("hall.preview.actual")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{lines.map((ln) => (
|
||||
<tr key={ln.client_line_no} className="border-t border-[#e8eef7] bg-white">
|
||||
<td className="border-r border-[#e8eef7] px-2 py-3 text-center">
|
||||
<span className="inline-flex size-6 items-center justify-center rounded-full border border-[#dfe8f6] font-black text-[#304f86]">
|
||||
{ln.client_line_no}
|
||||
</span>
|
||||
</td>
|
||||
<td className="border-r border-[#e8eef7] px-2 py-3 text-center">
|
||||
<span className="block font-mono text-base font-black text-slate-950">{ln.number}</span>
|
||||
<span className="text-[11px] font-bold text-[#0755c7]">{playLabel(ln.play_code, t)}</span>
|
||||
</td>
|
||||
<td className="border-r border-[#e8eef7] px-2 py-3 text-center font-semibold text-slate-700">
|
||||
{ln.play_code}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 font-mono text-lg font-black text-[#0b3f96]">{ln.number}</p>
|
||||
<Separator className="my-2 bg-[#e8eef7]" />
|
||||
<div className="grid grid-cols-2 gap-x-2 gap-y-1 text-xs tabular-nums">
|
||||
<span className="text-slate-500">
|
||||
{t("hall.preview.normalizedNumber")}
|
||||
</span>
|
||||
<span className="text-right font-mono">{ln.normalized_number}</span>
|
||||
<span className="text-slate-500">
|
||||
{t("hall.preview.combinationCount")}
|
||||
</span>
|
||||
<span className="text-right">{ln.combination_count}</span>
|
||||
<span className="text-slate-500">
|
||||
{t("hall.preview.actual")}
|
||||
</span>
|
||||
<span className="text-right font-semibold text-[#0b3f96]">
|
||||
</td>
|
||||
<td className="border-r border-[#e8eef7] px-2 py-3 text-center font-semibold tabular-nums">
|
||||
{formatMinorAsCurrency(ln.total_bet_amount, currencyCode)}
|
||||
</td>
|
||||
<td className="border-r border-[#e8eef7] px-2 py-3 text-center font-semibold tabular-nums text-emerald-600">
|
||||
-{formatMinorAsCurrency(ln.rebate_amount, currencyCode).replace(`${currencyCode} `, "")}
|
||||
</td>
|
||||
<td className="px-2 py-3 text-center font-black tabular-nums text-[#0b3f96]">
|
||||
{formatMinorAsCurrency(ln.actual_deduct_amount, currencyCode)}
|
||||
</span>
|
||||
<span className="text-slate-500">
|
||||
{t("hall.preview.estimatedMax")}
|
||||
</span>
|
||||
<span className="text-right">
|
||||
{formatMinorAsCurrency(ln.estimated_max_payout, currencyCode)}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{summary ? (
|
||||
<div className="grid grid-cols-3 overflow-hidden rounded-xl border border-[#dfe8f6] bg-[#f8fbff] text-center text-xs">
|
||||
<div className="border-r border-[#dfe8f6] px-2 py-3">
|
||||
<p className="font-bold text-[#304f86]">{t("hall.preview.totalBet")}</p>
|
||||
<p className="mt-1 font-black tabular-nums text-[#0b3f96]">
|
||||
{formatMinorAsCurrency(summary.total_bet_amount, currencyCode)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="border-r border-[#dfe8f6] px-2 py-3">
|
||||
<p className="font-bold text-[#304f86]">{t("hall.preview.rebate")}</p>
|
||||
<p className="mt-1 font-black tabular-nums text-emerald-600">
|
||||
-
|
||||
{formatMinorAsCurrency(summary.total_rebate_amount, currencyCode).replace(
|
||||
`${currencyCode} `,
|
||||
"",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="px-2 py-3">
|
||||
<p className="font-bold text-[#304f86]">{t("hall.preview.actualDeduct")}</p>
|
||||
<p className="mt-1 font-black tabular-nums text-[#e5002c]">
|
||||
{formatMinorAsCurrency(summary.total_actual_deduct, currencyCode)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{data.warnings.length > 0 ? (
|
||||
<WarningsBlock warnings={data.warnings} />
|
||||
) : (
|
||||
<div className="flex items-center gap-2 rounded-xl border border-emerald-100 bg-emerald-50 px-3 py-3 text-sm font-semibold text-emerald-700">
|
||||
<CheckCircle2 className="size-5" />
|
||||
{t("hall.preview.noWarnings", { defaultValue: "当前预览未发现明显风险。" })}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col-reverse gap-2 border-t border-[#e8eef7] bg-[#f8fbff] p-4 sm:flex-row sm:justify-between">
|
||||
<div className="grid grid-cols-2 gap-3 border-t border-[#e8eef7] bg-white p-4 sm:p-5">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={placing}
|
||||
className="h-11 rounded-xl border-[#dce7f7] bg-white text-sm font-semibold text-[#07459f] hover:bg-[#f1f6ff]"
|
||||
className="h-12 rounded-lg border-[#ff3650] bg-white text-base font-black text-[#e5002c] hover:bg-[#fff5f6]"
|
||||
>
|
||||
{t("hall.preview.backEdit")}
|
||||
</Button>
|
||||
@@ -201,7 +276,7 @@ export function HallBetPreviewDialog({
|
||||
type="button"
|
||||
onClick={onConfirmPlace}
|
||||
disabled={!data || placing || !allowSubmit}
|
||||
className="h-11 rounded-xl border-0 bg-[#e5002c] text-sm font-bold text-white shadow-[0_8px_20px_rgba(229,0,44,0.26)] hover:bg-[#d10028] disabled:bg-slate-300 disabled:shadow-none"
|
||||
className="h-12 rounded-lg border-0 bg-[#e5002c] text-base font-black text-white shadow-[0_10px_24px_rgba(229,0,44,0.28)] hover:bg-[#d10028] disabled:bg-slate-300 disabled:shadow-none"
|
||||
>
|
||||
{placing
|
||||
? t("hall.preview.submitting")
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { CheckCircle2, ChevronRight } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { CheckCircle2, ClipboardList, Ticket } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -11,8 +12,6 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { formatMinorAsCurrency } from "@/lib/money";
|
||||
import { playLabel } from "@/lib/play-labels";
|
||||
import type { TicketPlaceData } from "@/types/api/ticket";
|
||||
@@ -38,163 +37,155 @@ export function HallBetResultDialog({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-h-[min(90vh,620px)] gap-0 overflow-hidden border-[#e8eef7] p-0 shadow-[0_12px_40px_rgba(15,23,42,0.12)] ring-[#e8eef7] sm:max-w-md">
|
||||
<div className="p-4 pb-2">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-lg font-black text-[#0b3f96]">
|
||||
<DialogContent className="max-h-[min(92vh,760px)] gap-0 overflow-hidden rounded-2xl border-[#e4ebf6] bg-white p-0 shadow-[0_18px_60px_rgba(15,23,42,0.18)] ring-[#e8eef7] sm:max-w-lg">
|
||||
<div className="px-4 pb-3 pt-7 text-center sm:px-5">
|
||||
<div className="mx-auto flex size-16 items-center justify-center rounded-full border-4 border-emerald-100 bg-white text-emerald-600 shadow-[0_10px_24px_rgba(22,163,74,0.12)]">
|
||||
<CheckCircle2 className="size-11" strokeWidth={2.5} />
|
||||
</div>
|
||||
<DialogHeader className="mt-4 items-center gap-2">
|
||||
<DialogTitle className="text-2xl font-black text-slate-950">
|
||||
{t("hall.result.title", { defaultValue: "下注结果" })}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs leading-relaxed text-slate-500">
|
||||
{t("hall.result.description", {
|
||||
defaultValue: "本次提交已完成,以下为本次结果明细。",
|
||||
})}
|
||||
</DialogDescription>
|
||||
{data ? (
|
||||
<DialogDescription className="text-sm leading-relaxed text-slate-500">
|
||||
{t("hall.result.draw", { defaultValue: "期号" })}{" "}
|
||||
<span className="font-mono font-black text-[#e5002c]">{data.draw.draw_id}</span>
|
||||
</DialogDescription>
|
||||
) : null}
|
||||
</DialogHeader>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="max-h-[min(56vh,400px)] border-y border-[#e8eef7] px-4">
|
||||
<div className="space-y-4 py-3 pr-3">
|
||||
<div
|
||||
className="overflow-y-auto border-y border-[#e8eef7] px-4 sm:px-5"
|
||||
style={{ maxHeight: "min(58vh, 470px)" }}
|
||||
>
|
||||
<div className="space-y-4 py-4">
|
||||
{!data ? (
|
||||
<p className="text-sm text-slate-500">
|
||||
{t("hall.result.empty", { defaultValue: "暂无结果。" })}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="rounded-xl border border-[#e8eef7] bg-[#f8fbff] p-3 text-sm shadow-[0_4px_14px_rgba(15,23,42,0.04)]">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle2 className="size-4 text-emerald-600" />
|
||||
<span className="font-semibold text-slate-800">
|
||||
{t("hall.result.orderNo", {
|
||||
defaultValue: "订单号",
|
||||
})}{" "}
|
||||
<span className="font-mono font-black text-[#0b3f96]">{data.order_no}</span>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="rounded-lg border border-emerald-100 bg-emerald-50 px-3 py-4 text-center">
|
||||
<p className="text-sm font-bold text-emerald-700">
|
||||
{t("hall.result.successCount", { defaultValue: "成功注项" })}
|
||||
</p>
|
||||
<p className="mt-2 text-4xl font-black tabular-nums text-emerald-600">{totalSuccess}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-rose-100 bg-rose-50 px-3 py-4 text-center">
|
||||
<p className="text-sm font-bold text-rose-600">
|
||||
{t("hall.result.failureCount", { defaultValue: "失败注项" })}
|
||||
</p>
|
||||
<p className="mt-2 text-4xl font-black tabular-nums text-[#e5002c]">{totalFailure}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-3 rounded-lg border border-[#dbe7ff] bg-[#f4f8ff] px-4 py-3">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<span className="flex size-10 shrink-0 items-center justify-center rounded-full bg-[#0755c7] text-white">
|
||||
<ClipboardList className="size-5" />
|
||||
</span>
|
||||
<span className="truncate text-sm font-black text-[#0b3f96]">
|
||||
{t("hall.result.actual", { defaultValue: "实扣金额" })}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-slate-500">
|
||||
{t("hall.result.draw", { defaultValue: "期号" })}{" "}
|
||||
<span className="font-mono font-semibold text-[#32518d]">{data.draw.draw_id}</span>
|
||||
<span className="shrink-0 font-mono text-base font-black tabular-nums text-[#0b3f96]">
|
||||
{formatMinorAsCurrency(data.summary.total_actual_deduct, currencyCode)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 rounded-lg border border-[#e8eef7] bg-white px-4 py-3 text-xs text-slate-600 sm:grid-cols-2">
|
||||
<p>
|
||||
{t("hall.result.orderNo", { defaultValue: "订单号" })}:{" "}
|
||||
<span className="font-mono font-black text-[#0b3f96]">{data.order_no}</span>
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-slate-500">
|
||||
{t("hall.result.status", { defaultValue: "注单状态" })}{" "}
|
||||
<span className="font-semibold text-[#32518d]">
|
||||
{t("ticketStatus.success", { defaultValue: "待开奖" })}
|
||||
<p>
|
||||
{t("hall.result.balanceAfter", { defaultValue: "剩余余额" })}:{" "}
|
||||
<span className="font-semibold text-slate-950">
|
||||
{formatMinorAsCurrency(data.balance_after, currencyCode)}
|
||||
</span>
|
||||
</p>
|
||||
<Separator className="my-3 bg-[#e8eef7]" />
|
||||
<ul className="space-y-1 text-xs tabular-nums text-slate-700">
|
||||
<li>
|
||||
{t("hall.result.successCount", { defaultValue: "成功数" })}{" "}
|
||||
<span className="font-semibold text-emerald-700">{totalSuccess}</span>
|
||||
</li>
|
||||
<li>
|
||||
{t("hall.result.failureCount", { defaultValue: "失败数" })}{" "}
|
||||
<span className="font-semibold text-rose-600">{totalFailure}</span>
|
||||
</li>
|
||||
<li>
|
||||
{t("hall.result.amount", { defaultValue: "金额" })}{" "}
|
||||
<span className="font-semibold text-slate-800">
|
||||
{formatMinorAsCurrency(data.summary.total_bet_amount, currencyCode)}
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
{t("hall.result.rebate", { defaultValue: "回水" })}{" "}
|
||||
<span className="font-semibold text-emerald-700">
|
||||
{formatMinorAsCurrency(data.summary.total_rebate_amount, currencyCode)}
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
{t("hall.result.actual", { defaultValue: "实扣" })}{" "}
|
||||
<span className="font-bold text-[#0b3f96]">
|
||||
{formatMinorAsCurrency(data.summary.total_actual_deduct, currencyCode)}
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
{t("hall.result.total", { defaultValue: "合计" })}{" "}
|
||||
<span className="font-bold text-[#0b3f96]">
|
||||
{formatMinorAsCurrency(data.summary.total_actual_deduct, currencyCode)}
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
{t("hall.result.balanceAfter", { defaultValue: "剩余余额" })}{" "}
|
||||
<span className="font-semibold text-slate-800">
|
||||
{formatMinorAsCurrency(data.balance_after, currencyCode)}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
|
||||
<p className="text-sm font-black text-[#e5002c]">
|
||||
{t("hall.result.items", {
|
||||
defaultValue: "每一注成功/失败详情",
|
||||
defaultValue: "成功注项明细",
|
||||
})}
|
||||
</p>
|
||||
<ul className="space-y-2 text-sm">
|
||||
{data.items.map((item, index) => (
|
||||
<li
|
||||
key={`${item.ticket_no}-${index}`}
|
||||
className="rounded-xl border border-[#e6edf8] bg-white px-3 py-2 shadow-[0_4px_14px_rgba(15,23,42,0.04)]"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="font-mono text-xs text-slate-400">
|
||||
#{index + 1}
|
||||
</span>
|
||||
<span className="text-xs font-semibold text-emerald-700">
|
||||
{t("hall.result.success", { defaultValue: "成功" })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 flex items-center justify-between gap-2">
|
||||
<span className="font-semibold text-[#32518d]">{playLabel(item.play_code, t)}</span>
|
||||
<span className="font-mono text-xs text-slate-500">
|
||||
{item.ticket_no}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 grid grid-cols-2 gap-x-2 gap-y-1 text-xs tabular-nums">
|
||||
<span className="text-slate-500">
|
||||
<div className="overflow-x-auto rounded-xl border border-[#dfe8f6]">
|
||||
<table className="min-w-[430px] w-full border-collapse text-xs">
|
||||
<thead className="bg-[#f4f7fd] text-[#304f86]">
|
||||
<tr>
|
||||
<th className="w-10 border-r border-[#dfe8f6] px-2 py-2.5 text-center font-black">No.</th>
|
||||
<th className="border-r border-[#dfe8f6] px-2 py-2.5 text-center font-black">
|
||||
{t("hall.result.number", { defaultValue: "号码" })}
|
||||
</span>
|
||||
<span className="text-right font-mono font-black text-[#0b3f96]">{item.number}</span>
|
||||
<span className="text-slate-500">
|
||||
{t("hall.result.comboCount", { defaultValue: "组合数" })}
|
||||
</span>
|
||||
<span className="text-right">{item.combination_count}</span>
|
||||
<span className="text-slate-500">
|
||||
</th>
|
||||
<th className="border-r border-[#dfe8f6] px-2 py-2.5 text-center font-black">
|
||||
{t("orders.play", { defaultValue: "玩法" })}
|
||||
</th>
|
||||
<th className="px-2 py-2.5 text-center font-black">
|
||||
{t("hall.result.actualDeduct", { defaultValue: "实扣" })}
|
||||
</span>
|
||||
<span className="text-right font-semibold text-[#0b3f96]">
|
||||
{formatMinorAsCurrency(item.actual_deduct_amount, currencyCode)}
|
||||
</span>
|
||||
<span className="text-slate-500">
|
||||
{t("hall.result.estimatedMax", { defaultValue: "最坏赔付" })}
|
||||
</span>
|
||||
<span className="text-right">
|
||||
{formatMinorAsCurrency(item.estimated_max_payout, currencyCode)}
|
||||
</span>
|
||||
</div>
|
||||
{item.combination_count > 1 ? (
|
||||
<div className="mt-2 inline-flex items-center gap-1 rounded-full border border-[#dce7f7] bg-[#f8fbff] px-2 py-1 text-[11px] text-[#07459f]">
|
||||
<ChevronRight className="size-3" />
|
||||
{t("hall.result.comboHint", {
|
||||
defaultValue: "已按展开组合分摊",
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.items.map((item, index) => (
|
||||
<tr
|
||||
key={`${item.ticket_no}-${index}`}
|
||||
className="border-t border-[#e8eef7] bg-white"
|
||||
>
|
||||
<td className="border-r border-[#e8eef7] px-2 py-3 text-center font-black text-[#304f86]">
|
||||
{index + 1}
|
||||
</td>
|
||||
<td className="border-r border-[#e8eef7] px-2 py-3 text-center">
|
||||
<span className="block font-mono text-base font-black text-slate-950">
|
||||
{item.number}
|
||||
</span>
|
||||
<span className="block truncate text-[10px] text-slate-400">
|
||||
{item.ticket_no}
|
||||
</span>
|
||||
</td>
|
||||
<td className="border-r border-[#e8eef7] px-2 py-3 text-center font-semibold text-[#0755c7]">
|
||||
{playLabel(item.play_code, t)}
|
||||
</td>
|
||||
<td className="px-2 py-3 text-center font-black tabular-nums text-[#0b3f96]">
|
||||
{formatMinorAsCurrency(item.actual_deduct_amount, currencyCode)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{totalFailure === 0 ? (
|
||||
<div className="rounded-lg border border-emerald-100 bg-emerald-50 px-3 py-3 text-sm font-semibold text-emerald-700">
|
||||
{t("hall.result.noFailures", { defaultValue: "本次提交没有失败注项。" })}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end border-t border-[#e8eef7] bg-[#f8fbff] p-4">
|
||||
<div className="grid grid-cols-2 gap-3 border-t border-[#e8eef7] bg-white p-4 sm:p-5">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-12 rounded-lg border-[#8dadf0] bg-white text-base font-black text-[#0b3f96] hover:bg-[#f4f8ff]"
|
||||
render={<Link href="/orders" />}
|
||||
>
|
||||
<ClipboardList className="size-5" />
|
||||
{t("hall.result.viewBets", { defaultValue: "查看注单" })}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="h-11 rounded-xl border-0 bg-[#07459f] px-6 text-sm font-bold text-white hover:bg-[#063b88]"
|
||||
className="h-12 rounded-lg border-0 bg-[#07459f] text-base font-black text-white shadow-[0_10px_24px_rgba(7,69,159,0.24)] hover:bg-[#063b88]"
|
||||
>
|
||||
{t("actions.close", { defaultValue: "关闭" })}
|
||||
<Ticket className="size-5" />
|
||||
{t("hall.result.continueBetting", { defaultValue: "继续下注" })}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { Bell } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { LanguageSwitcher } from "@/components/language-switcher";
|
||||
@@ -15,6 +16,7 @@ import { useHallDrawLive } from "@/features/hall/use-hall-draw-live";
|
||||
*/
|
||||
export function HallScreen() {
|
||||
const { t } = useTranslation("common");
|
||||
const { t: tp } = useTranslation("player");
|
||||
const drawLive = useHallDrawLive();
|
||||
|
||||
return (
|
||||
@@ -38,6 +40,12 @@ export function HallScreen() {
|
||||
showFlag={false}
|
||||
className="shrink-0 rounded-full border border-[#e4eaf4] bg-[#f8fafc]"
|
||||
/>
|
||||
<Link
|
||||
href="/rules"
|
||||
className="shrink-0 rounded-full border border-[#e4eaf4] bg-[#f8fafc] px-3 py-1.5 text-xs font-bold text-[#0b3f96] hover:bg-[#f1f6ff]"
|
||||
>
|
||||
{tp("nav.rules", { defaultValue: "规则" })}
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
className="relative flex size-8 shrink-0 items-center justify-center rounded-full text-[#1d57b7] hover:bg-[#f4f7fb]"
|
||||
|
||||
@@ -10,6 +10,9 @@ export function ticketStatusDisplay(
|
||||
if (status === "success") {
|
||||
return { label: t?.("ticketStatus.success") ?? "待开奖", dotClass: "bg-sky-500" };
|
||||
}
|
||||
if (status === "pending_payout") {
|
||||
return { label: t?.("ticketStatus.pending_payout") ?? "已中奖待派彩", dotClass: "bg-amber-500" };
|
||||
}
|
||||
if (status === "settled_win" && total > 0) {
|
||||
return { label: t?.("ticketStatus.settled_win") ?? "已派彩", dotClass: "bg-emerald-500" };
|
||||
}
|
||||
|
||||
@@ -42,6 +42,27 @@ function formatOddsSnapshot(
|
||||
return parts.length ? parts.join(" · ") : "—";
|
||||
}
|
||||
|
||||
type TimelineRow = {
|
||||
code: string;
|
||||
label: string;
|
||||
time: string;
|
||||
};
|
||||
|
||||
type TicketItemDetailWithExtras = TicketItemDetailPayload & {
|
||||
timeline?: TimelineRow[];
|
||||
match_result?: {
|
||||
matched?: boolean;
|
||||
matched_prize_tier?: string | null;
|
||||
win_amount_minor?: number;
|
||||
jackpot_allocation_minor?: number;
|
||||
lines?: Array<{
|
||||
number_4d?: string | null;
|
||||
matched_tier?: string | null;
|
||||
payout?: number | null;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
|
||||
/** 界面文档 §4.8 注单详情 */
|
||||
export function TicketOrderDetailScreen({ ticketNo }: { ticketNo: string }) {
|
||||
const { t } = useTranslation("player");
|
||||
@@ -145,6 +166,10 @@ export function TicketOrderDetailScreen({ ticketNo }: { ticketNo: string }) {
|
||||
defaultValue: data.settlement.matched_prize_tier,
|
||||
})
|
||||
: null;
|
||||
const extras = data as TicketItemDetailWithExtras;
|
||||
const timeline = extras.timeline ?? [];
|
||||
const matchResult = extras.match_result;
|
||||
const hasSettlement = data.settlement !== null || data.status === "settled_win" || data.status === "settled_lose";
|
||||
|
||||
return (
|
||||
<PlayerPanel
|
||||
@@ -221,34 +246,42 @@ export function TicketOrderDetailScreen({ ticketNo }: { ticketNo: string }) {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{pub?.results ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-bold text-[#0b3f96]">{t("orders.drawNumbers")}</p>
|
||||
<TwentyThreeResultsGrid numbers={pub.results} highlighted4d={highlight} />
|
||||
{first ? (
|
||||
<p className="text-xs text-slate-500">
|
||||
{t("orders.firstPrize")}{" "}
|
||||
<span className="font-mono font-semibold text-slate-900">{first}</span>
|
||||
{comboHits.length > 0 ? (
|
||||
<span className="font-semibold text-emerald-600">
|
||||
{" "}
|
||||
← {t("orders.hit")}
|
||||
</span>
|
||||
) : null}
|
||||
{pub?.results ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-bold text-[#0b3f96]">{t("orders.drawNumbers")}</p>
|
||||
<TwentyThreeResultsGrid numbers={pub.results} highlighted4d={highlight} />
|
||||
{first ? (
|
||||
<p className="text-xs text-slate-500">
|
||||
{t("orders.firstPrize")}{" "}
|
||||
<span className="font-mono font-semibold text-slate-900">{first}</span>
|
||||
{comboHits.length > 0 ? (
|
||||
<span className="font-semibold text-emerald-600">
|
||||
{" "}
|
||||
← {t("orders.hit")}
|
||||
</span>
|
||||
) : null}
|
||||
</p>
|
||||
) : null}
|
||||
{!hasSettlement ? (
|
||||
<p className="rounded-lg border border-[#dce7f7] bg-[#f8fbff] px-3 py-2 text-xs text-[#32518d]">
|
||||
{t("orders.matchPendingSettlement", { defaultValue: "已开奖,等待系统结算后显示中奖结果。" })}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border border-[#dce7f7] bg-[#f8fbff] px-3 py-3 text-xs">
|
||||
<p className="font-bold text-[#0b3f96]">{t("orders.drawNumbers")}</p>
|
||||
<p className="mt-1 text-[#32518d]">
|
||||
{t("orders.drawPendingMatch", { defaultValue: "本期开奖号码尚未发布,暂不能判断是否中奖。" })}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<p className="rounded-lg border border-[#dce7f7] bg-[#f8fbff] px-3 py-2 text-xs text-[#32518d]">
|
||||
{t("orders.notPublished")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data.settlement && tierLabel ? (
|
||||
<div className="rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2 text-xs">
|
||||
<p className="font-bold text-emerald-900">
|
||||
{t("orders.matchWin", { tier: tierLabel })}
|
||||
</p>
|
||||
{data.settlement && tierLabel ? (
|
||||
<div className="rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2 text-xs">
|
||||
<p className="font-bold text-emerald-900">
|
||||
{t("orders.matchWin", { tier: tierLabel })}
|
||||
</p>
|
||||
<p className="mt-1 font-mono text-emerald-800/90">
|
||||
{t("orders.winAmount", {
|
||||
amount: formatMinorAsCurrency(data.settlement.win_amount_minor, cur),
|
||||
@@ -269,10 +302,53 @@ export function TicketOrderDetailScreen({ ticketNo }: { ticketNo: string }) {
|
||||
<p className="mt-1 font-mono font-semibold text-emerald-900">
|
||||
{t("orders.payoutTotal", { amount: formatMinorAsCurrency(totalWin, cur) })}
|
||||
</p>
|
||||
</div>
|
||||
) : data.status === "settled_lose" ? (
|
||||
<p className="text-xs text-slate-500">{t("orders.matchLose")}</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : hasSettlement ? (
|
||||
<p className="text-xs text-slate-500">{t("orders.matchLose")}</p>
|
||||
) : null}
|
||||
|
||||
{matchResult && hasSettlement ? (
|
||||
<div className="rounded-lg border border-[#dce7f7] bg-[#f8fbff] px-3 py-3 text-xs">
|
||||
<p className="font-bold text-[#0b3f96]">
|
||||
{t("orders.matchResult", { defaultValue: "匹配结果" })}
|
||||
</p>
|
||||
<p className="mt-1 text-slate-600">
|
||||
{matchResult.matched
|
||||
? t("orders.matchWin", { tier: tierLabel ?? (matchResult.matched_prize_tier ?? "—") })
|
||||
: t("orders.matchLose")}
|
||||
</p>
|
||||
{Array.isArray(matchResult.lines) && matchResult.lines.length > 0 ? (
|
||||
<div className="mt-2 space-y-1">
|
||||
{matchResult.lines.map((line, idx) => (
|
||||
<p key={`${line.number_4d ?? "line"}-${idx}`} className="font-mono text-[11px] text-slate-500">
|
||||
{line.number_4d ?? "—"} · {line.matched_tier ?? "—"} · {formatMinorAsCurrency(line.payout ?? 0, cur)}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{timeline.length > 0 ? (
|
||||
<div className="rounded-xl border border-[#e8eef7] bg-[#f8fbff] px-3 py-3">
|
||||
<p className="text-sm font-bold text-[#0b3f96]">
|
||||
{t("orders.timeline", { defaultValue: "时间线" })}
|
||||
</p>
|
||||
<div className="mt-2 space-y-2">
|
||||
{timeline.map((row) => (
|
||||
<div key={row.code} className="flex items-start justify-between gap-3 rounded-lg bg-white px-3 py-2">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-bold text-[#32518d]">{row.label}</p>
|
||||
<p className="mt-0.5 font-mono text-[11px] text-slate-500">{row.code}</p>
|
||||
</div>
|
||||
<p className="shrink-0 font-mono text-[11px] text-slate-500">
|
||||
{formatLotteryInstant(row.time)}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{data.settled_at ? (
|
||||
<p className="text-[11px] text-slate-500">
|
||||
|
||||
@@ -3,27 +3,46 @@
|
||||
import Link from "next/link";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { CalendarRange, ChevronDown, Search } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { getTicketItems } from "@/api/ticket-items";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Calendar } from "@/components/ui/calendar";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { PlayerPanel } from "@/components/layout/player-panel";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { StatusDot, ticketStatusDisplay } from "@/features/orders/ticket-item-status";
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import { formatMinorAsCurrency } from "@/lib/money";
|
||||
import { formatLotteryInstant } from "@/lib/player-datetime";
|
||||
import { playLabel } from "@/lib/play-labels";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { TicketItemListRow } from "@/types/api/ticket-items";
|
||||
|
||||
const ORDERS_PAGE_SIZE = 10;
|
||||
const ORDERS_PAGE_SIZE = 20;
|
||||
const STATUS_OPTIONS = ["success", "settled_win", "settled_lose", "failed"] as const;
|
||||
|
||||
function parseYmd(value: string): Date | undefined {
|
||||
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
|
||||
if (!m) return undefined;
|
||||
const d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
|
||||
return Number.isNaN(d.getTime()) ? undefined : d;
|
||||
}
|
||||
|
||||
function formatYmd(value: Date): string {
|
||||
const y = value.getFullYear();
|
||||
const m = String(value.getMonth() + 1).padStart(2, "0");
|
||||
const d = String(value.getDate()).padStart(2, "0");
|
||||
return `${y}-${m}-${d}`;
|
||||
}
|
||||
|
||||
export function TicketOrdersListScreen() {
|
||||
const searchParams = useSearchParams();
|
||||
const { t } = useTranslation("player");
|
||||
const drawNoFilter = useMemo(
|
||||
() => (searchParams.get("draw_no") ?? "").trim(),
|
||||
[searchParams],
|
||||
);
|
||||
const drawNoFilter = useMemo(() => (searchParams.get("draw_no") ?? "").trim(), [searchParams]);
|
||||
|
||||
const [items, setItems] = useState<TicketItemListRow[]>([]);
|
||||
const [page, setPage] = useState(1);
|
||||
@@ -32,7 +51,39 @@ export function TicketOrdersListScreen() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [queryDrawNo, setQueryDrawNo] = useState(drawNoFilter);
|
||||
const [queryNumber, setQueryNumber] = useState("");
|
||||
const [queryStatuses, setQueryStatuses] = useState<string[]>([]);
|
||||
const [fromDate, setFromDate] = useState("");
|
||||
const [toDate, setToDate] = useState("");
|
||||
const [rangeOpen, setRangeOpen] = useState(false);
|
||||
const [statusOpen, setStatusOpen] = useState(false);
|
||||
const [calendarMonth, setCalendarMonth] = useState(() => new Date());
|
||||
const loadMoreRef = useRef<HTMLDivElement | null>(null);
|
||||
const isMobile = useIsMobile();
|
||||
const initialLoadDone = useRef(false);
|
||||
|
||||
const selectedRange = useMemo(() => {
|
||||
const from = parseYmd(fromDate);
|
||||
const to = parseYmd(toDate);
|
||||
if (!from && !to) return undefined;
|
||||
if (from && to) return { from, to };
|
||||
if (from) return { from };
|
||||
return to ? { from: to } : undefined;
|
||||
}, [fromDate, toDate]);
|
||||
|
||||
const formatCompactDate = useCallback((value: string) => {
|
||||
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
|
||||
if (!m) return value;
|
||||
return `${m[2]}-${m[3]}`;
|
||||
}, []);
|
||||
|
||||
const dateLabel = useMemo(() => {
|
||||
if (!fromDate && !toDate) return t("orders.dateRange", { defaultValue: "日期范围" });
|
||||
if (fromDate && toDate) return `${formatCompactDate(fromDate)} ~ ${formatCompactDate(toDate)}`;
|
||||
return `${fromDate ? formatCompactDate(fromDate) : "..." } ~ ${toDate ? formatCompactDate(toDate) : "..."}`;
|
||||
}, [formatCompactDate, fromDate, t, toDate]);
|
||||
const visiblePages = useMemo(() => buildPageWindow(page, lastPage), [lastPage, page]);
|
||||
|
||||
const fetchPage = useCallback(
|
||||
async (nextPage: number, append: boolean) => {
|
||||
@@ -43,7 +94,11 @@ export function TicketOrdersListScreen() {
|
||||
const res = await getTicketItems({
|
||||
page: nextPage,
|
||||
per_page: ORDERS_PAGE_SIZE,
|
||||
draw_no: drawNoFilter || undefined,
|
||||
draw_no: queryDrawNo || drawNoFilter || undefined,
|
||||
number: queryNumber || undefined,
|
||||
status: queryStatuses.length ? queryStatuses : undefined,
|
||||
start_date: fromDate || undefined,
|
||||
end_date: toDate || undefined,
|
||||
});
|
||||
setItems((prev) => (append ? [...prev, ...res.items] : res.items));
|
||||
setPage(res.page);
|
||||
@@ -57,19 +112,20 @@ export function TicketOrdersListScreen() {
|
||||
setLoadingMore(false);
|
||||
}
|
||||
},
|
||||
[drawNoFilter, t],
|
||||
[drawNoFilter, fromDate, queryDrawNo, queryNumber, queryStatuses, t, toDate],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
if (!initialLoadDone.current) {
|
||||
initialLoadDone.current = true;
|
||||
void fetchPage(1, false);
|
||||
});
|
||||
}, [fetchPage]);
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
if (page >= lastPage || loadingMore) return;
|
||||
void fetchPage(page + 1, true);
|
||||
}, [fetchPage, lastPage, loadingMore, page]);
|
||||
return;
|
||||
}
|
||||
setItems([]);
|
||||
setPage(1);
|
||||
setLastPage(1);
|
||||
void fetchPage(1, false);
|
||||
}, [fetchPage, queryDrawNo, queryNumber, queryStatuses, fromDate, toDate]);
|
||||
|
||||
useEffect(() => {
|
||||
const target = loadMoreRef.current;
|
||||
@@ -78,7 +134,7 @@ export function TicketOrdersListScreen() {
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry?.isIntersecting) {
|
||||
loadMore();
|
||||
void fetchPage(page + 1, true);
|
||||
}
|
||||
},
|
||||
{ rootMargin: "160px" },
|
||||
@@ -86,14 +142,19 @@ export function TicketOrdersListScreen() {
|
||||
|
||||
observer.observe(target);
|
||||
return () => observer.disconnect();
|
||||
}, [lastPage, loadMore, loading, loadingMore, page]);
|
||||
}, [fetchPage, lastPage, loading, loadingMore, page]);
|
||||
|
||||
return (
|
||||
<PlayerPanel title={t("orders.title")} subtitle={t("orders.subtitle")} eyebrow={t("brand.name")}>
|
||||
<PlayerPanel
|
||||
title={t("orders.title")}
|
||||
subtitle={t("orders.subtitle")}
|
||||
eyebrow={t("brand.name")}
|
||||
containerClassName="max-w-[720px]"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-xl border border-[#e6edf8] bg-[#f8fbff] p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="rounded-xl border border-[#e6edf8] bg-white p-3 shadow-[0_8px_24px_rgba(15,23,42,0.04)] sm:p-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs font-bold text-[#32518d]">
|
||||
{drawNoFilter ? t("orders.filteredIssue") : t("orders.totalRecords")}
|
||||
</p>
|
||||
@@ -101,21 +162,158 @@ export function TicketOrdersListScreen() {
|
||||
{drawNoFilter || total}
|
||||
</p>
|
||||
</div>
|
||||
{drawNoFilter ? (
|
||||
<Link
|
||||
href="/orders"
|
||||
className="shrink-0 rounded-full border border-[#dce7f7] bg-white px-3 py-1.5 text-xs font-bold text-[#0b56b7]"
|
||||
>
|
||||
{t("actions.clear")}
|
||||
</Link>
|
||||
) : (
|
||||
<div className="flex shrink-0 items-center gap-2 self-start sm:self-auto">
|
||||
<Link
|
||||
href="/hall"
|
||||
className="shrink-0 rounded-full bg-[#e5002c] px-3 py-1.5 text-xs font-bold text-white"
|
||||
className="inline-flex h-10 shrink-0 items-center rounded-full bg-[#e5002c] px-5 text-sm font-bold text-white"
|
||||
>
|
||||
{t("orders.betNow")}
|
||||
</Link>
|
||||
)}
|
||||
{(queryDrawNo || queryNumber || fromDate || toDate || queryStatuses.length > 0) ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-10 rounded-full border-[#dce7f7] bg-white px-4 text-sm font-semibold text-[#32518d] hover:bg-[#f8fbff]"
|
||||
onClick={() => {
|
||||
setQueryDrawNo(drawNoFilter);
|
||||
setQueryNumber("");
|
||||
setFromDate("");
|
||||
setToDate("");
|
||||
setQueryStatuses([]);
|
||||
setRangeOpen(false);
|
||||
setStatusOpen(false);
|
||||
}}
|
||||
>
|
||||
{t("actions.clear")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid grid-cols-1 gap-2 min-[420px]:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="flex h-10 items-center rounded-full border border-[#dce7f7] bg-[#fbfdff] px-3">
|
||||
<Input
|
||||
value={queryDrawNo}
|
||||
onChange={(e) => setQueryDrawNo(e.target.value)}
|
||||
placeholder={t("orders.drawNo")}
|
||||
aria-label={t("orders.drawNo")}
|
||||
className="h-8 border-0 bg-transparent px-0 text-sm shadow-none focus-visible:ring-0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex h-10 items-center gap-2 rounded-full border border-[#dce7f7] bg-[#fbfdff] px-3">
|
||||
<Search className="size-3.5 shrink-0 text-slate-400" />
|
||||
<Input
|
||||
value={queryNumber}
|
||||
onChange={(e) => setQueryNumber(e.target.value)}
|
||||
placeholder={t("orders.number")}
|
||||
aria-label={t("orders.number")}
|
||||
className="h-8 border-0 bg-transparent px-0 text-sm shadow-none focus-visible:ring-0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Popover open={rangeOpen} onOpenChange={setRangeOpen}>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-10 w-full justify-start gap-2 rounded-full border-[#dce7f7] bg-[#fbfdff] px-3 text-left text-sm font-semibold text-[#32518d] hover:bg-white"
|
||||
>
|
||||
<CalendarRange className="size-4 text-[#7890b8]" />
|
||||
<span className="truncate">{dateLabel}</span>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<PopoverContent align="start" className="w-auto border-[#dce7f7] p-2 shadow-[0_16px_40px_rgba(15,23,42,0.14)]">
|
||||
<Calendar
|
||||
mode="range"
|
||||
month={calendarMonth}
|
||||
onMonthChange={setCalendarMonth}
|
||||
numberOfMonths={isMobile ? 1 : 2}
|
||||
selected={selectedRange}
|
||||
onSelect={(range) => {
|
||||
if (!range?.from && !range?.to) {
|
||||
setFromDate("");
|
||||
setToDate("");
|
||||
return;
|
||||
}
|
||||
setFromDate(range?.from ? formatYmd(range.from) : "");
|
||||
setToDate(range?.to ? formatYmd(range.to) : "");
|
||||
}}
|
||||
/>
|
||||
<div className="flex items-center justify-end gap-2 border-t px-2 py-1.5">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={() => {
|
||||
setFromDate("");
|
||||
setToDate("");
|
||||
}}
|
||||
>
|
||||
清除
|
||||
</Button>
|
||||
<Button type="button" variant="secondary" size="xs" onClick={() => setRangeOpen(false)}>
|
||||
完成
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<Popover open={statusOpen} onOpenChange={setStatusOpen}>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-10 w-full justify-between gap-2 rounded-full border-[#dce7f7] bg-[#fbfdff] px-3 text-sm font-semibold text-[#32518d] hover:bg-white"
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-1.5">
|
||||
<span className="shrink-0">状态</span>
|
||||
{queryStatuses.length ? (
|
||||
<span className="inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-[#0b56b7] px-1 text-[10px] font-black text-white">
|
||||
{queryStatuses.length}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span className="flex items-center gap-1 text-[#7890b8]">
|
||||
<ChevronDown className="size-3.5" />
|
||||
</span>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<PopoverContent align="start" className="w-56 border-[#dce7f7] p-2 shadow-[0_16px_40px_rgba(15,23,42,0.14)]">
|
||||
<div className="space-y-1">
|
||||
<p className="px-1 pb-1 text-[11px] font-bold text-[#32518d]">
|
||||
{t("orders.statusFilter", { defaultValue: "状态筛选" })}
|
||||
</p>
|
||||
{STATUS_OPTIONS.map((status) => {
|
||||
const checked = queryStatuses.includes(status);
|
||||
return (
|
||||
<button
|
||||
key={status}
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs font-semibold transition-colors",
|
||||
checked ? "bg-[#eaf2ff] text-[#0b56b7]" : "text-[#32518d] hover:bg-[#f8fbff]",
|
||||
)}
|
||||
onClick={() => {
|
||||
setQueryStatuses((current) =>
|
||||
current.includes(status)
|
||||
? current.filter((s) => s !== status)
|
||||
: [...current, status],
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Checkbox className="size-3.5" checked={checked} />
|
||||
<span className="truncate">{t(`ticketStatus.${status}`, { defaultValue: status })}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -152,12 +350,7 @@ export function TicketOrdersListScreen() {
|
||||
<div className="space-y-3">
|
||||
{items.map((row) => {
|
||||
const cur = row.currency_code ?? "NPR";
|
||||
const st = ticketStatusDisplay(
|
||||
row.status,
|
||||
row.win_amount,
|
||||
row.jackpot_win_amount,
|
||||
t,
|
||||
);
|
||||
const st = ticketStatusDisplay(row.status, row.win_amount, row.jackpot_win_amount, t);
|
||||
const totalWin = row.win_amount + row.jackpot_win_amount;
|
||||
return (
|
||||
<Link
|
||||
@@ -178,17 +371,13 @@ export function TicketOrdersListScreen() {
|
||||
</div>
|
||||
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||
<div className="rounded-lg bg-[#f8fbff] px-3 py-2">
|
||||
<p className="text-[10px] font-bold uppercase text-[#7890b8]">
|
||||
{t("orders.stake")}
|
||||
</p>
|
||||
<p className="text-[10px] font-bold uppercase text-[#7890b8]">{t("orders.stake")}</p>
|
||||
<p className="mt-1 text-sm font-black text-slate-900">
|
||||
{formatMinorAsCurrency(row.total_bet_amount, cur)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg bg-[#f8fbff] px-3 py-2">
|
||||
<p className="text-[10px] font-bold uppercase text-[#7890b8]">
|
||||
{t("orders.deduction")}
|
||||
</p>
|
||||
<p className="text-[10px] font-bold uppercase text-[#7890b8]">{t("orders.deduction")}</p>
|
||||
<p className="mt-1 text-sm font-black text-[#0b3f96]">
|
||||
{formatMinorAsCurrency(row.actual_deduct_amount, cur)}
|
||||
</p>
|
||||
@@ -206,19 +395,58 @@ export function TicketOrdersListScreen() {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div ref={loadMoreRef} className="min-h-1" />
|
||||
{page < lastPage ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-10 w-full rounded-xl border-[#dce7f7] bg-white text-sm font-bold text-[#32518d] hover:bg-[#f8fbff]"
|
||||
disabled={loadingMore}
|
||||
onClick={loadMore}
|
||||
>
|
||||
{loadingMore
|
||||
? t("actions.loading", { defaultValue: "加载中..." })
|
||||
: t("actions.loadMore", { defaultValue: "加载更多" })}
|
||||
</Button>
|
||||
{isMobile ? <div ref={loadMoreRef} className="min-h-1" /> : null}
|
||||
{isMobile && page < lastPage ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-10 w-full rounded-xl border-[#dce7f7] bg-white text-sm font-bold text-[#32518d] hover:bg-[#f8fbff]"
|
||||
disabled={loadingMore}
|
||||
onClick={() => void fetchPage(page + 1, true)}
|
||||
>
|
||||
{loadingMore ? t("actions.loading", { defaultValue: "加载中..." }) : t("actions.loadMore", { defaultValue: "加载更多" })}
|
||||
</Button>
|
||||
) : !isMobile && lastPage > 1 ? (
|
||||
<div className="flex flex-wrap items-center justify-center gap-2 rounded-xl border border-[#e6edf8] bg-white px-3 py-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="rounded-full border-[#dce7f7] bg-white text-[#32518d]"
|
||||
disabled={loading || page <= 1}
|
||||
onClick={() => void fetchPage(Math.max(1, page - 1), false)}
|
||||
>
|
||||
{t("actions.previous", { defaultValue: "上一页" })}
|
||||
</Button>
|
||||
{visiblePages.map((p) => (
|
||||
<Button
|
||||
key={p}
|
||||
type="button"
|
||||
variant={p === page ? "default" : "outline"}
|
||||
size="sm"
|
||||
className={cn(
|
||||
"min-w-8 rounded-full",
|
||||
p === page
|
||||
? "bg-[#07459f] text-white hover:bg-[#063b88]"
|
||||
: "border-[#dce7f7] bg-white text-[#32518d]",
|
||||
)}
|
||||
disabled={loading}
|
||||
onClick={() => void fetchPage(p, false)}
|
||||
>
|
||||
{p}
|
||||
</Button>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="rounded-full border-[#dce7f7] bg-white text-[#32518d]"
|
||||
disabled={loading || page >= lastPage}
|
||||
onClick={() => void fetchPage(Math.min(lastPage, page + 1), false)}
|
||||
>
|
||||
{t("actions.next", { defaultValue: "下一页" })}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<p className="py-2 text-center text-xs text-slate-400">
|
||||
{t("orders.noMore", { defaultValue: "没有更多注单" })}
|
||||
@@ -230,3 +458,12 @@ export function TicketOrdersListScreen() {
|
||||
</PlayerPanel>
|
||||
);
|
||||
}
|
||||
|
||||
function buildPageWindow(current: number, last: number): number[] {
|
||||
if (last <= 5) {
|
||||
return Array.from({ length: last }, (_, index) => index + 1);
|
||||
}
|
||||
|
||||
const start = Math.max(1, Math.min(current - 2, last - 4));
|
||||
return Array.from({ length: 5 }, (_, index) => start + index);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import { PlayerPanel } from "@/components/layout/player-panel";
|
||||
import { JackpotResultsStrip } from "@/features/results/jackpot-results-strip";
|
||||
import { TwentyThreeResultsGrid } from "@/features/results/twenty-three-results-grid";
|
||||
import { formatLotteryInstant } from "@/lib/player-datetime";
|
||||
import type { DrawResultListItem } from "@/types/api/draw-results";
|
||||
|
||||
@@ -44,6 +45,8 @@ export function DrawResultsListScreen() {
|
||||
const selectedDate = useMemo(() => parseBusinessDate(date), [date]);
|
||||
const businessDate = /^\d{4}-\d{2}-\d{2}$/.test(date) ? date : undefined;
|
||||
const quickYears = useMemo(() => buildYearOptions(calendarMonth), [calendarMonth]);
|
||||
const featured = items?.[0] ?? null;
|
||||
const olderDraw = items?.[1] ?? null;
|
||||
|
||||
const fetchList = useCallback(async (targetPage = 1, append = false) => {
|
||||
setError(null);
|
||||
@@ -221,6 +224,59 @@ export function DrawResultsListScreen() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{featured ? (
|
||||
<div className="rounded-xl border border-[#e5edf8] bg-white p-3 shadow-[0_10px_28px_rgba(15,23,42,0.06)]">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-[#edf2f9] pb-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-w-[5rem] rounded-full border-[#e6edf8] bg-white text-slate-400"
|
||||
disabled
|
||||
>
|
||||
{t("results.next")}
|
||||
</Button>
|
||||
<div className="min-w-0 flex-1 text-center">
|
||||
<p className="font-mono text-lg font-black text-[#0b3f96]">
|
||||
{featured.draw_no}
|
||||
</p>
|
||||
<p className="mt-1 font-mono text-xs text-slate-500">
|
||||
{t("results.drawTime", {
|
||||
time: formatLotteryInstant(featured.draw_time_iso ?? featured.draw_time ?? null),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
{olderDraw ? (
|
||||
<Link
|
||||
href={`/results/${encodeURIComponent(olderDraw.draw_no)}`}
|
||||
className="inline-flex h-7 min-w-[5rem] items-center justify-center rounded-full border border-[#dce7f7] bg-white px-2.5 text-[0.8rem] font-medium text-[#0b56b7] transition-colors hover:bg-[#f1f6ff]"
|
||||
>
|
||||
{t("results.previous")}
|
||||
</Link>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-w-[5rem] rounded-full border-[#e6edf8] bg-white text-slate-400"
|
||||
disabled
|
||||
>
|
||||
{t("results.previous")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="pt-4">
|
||||
<TwentyThreeResultsGrid numbers={featured.results} />
|
||||
<Link
|
||||
href={`/orders?draw_no=${encodeURIComponent(featured.draw_no)}`}
|
||||
className="mt-4 inline-flex h-10 w-full items-center justify-center rounded-xl bg-[#e5002c] px-4 text-sm font-bold text-white transition-colors hover:bg-[#d10028]"
|
||||
>
|
||||
{t("results.viewMyWinning")}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{items?.map((row) => (
|
||||
<Link
|
||||
key={row.draw_no}
|
||||
|
||||
205
src/features/rules/play-rules-screen.tsx
Normal file
205
src/features/rules/play-rules-screen.tsx
Normal file
@@ -0,0 +1,205 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { BookOpen, ShieldCheck, TimerReset } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { PlayerPanel } from "@/components/layout/player-panel";
|
||||
|
||||
const prizeRows = [
|
||||
{ label: "rules.prizes.first", defaultValue: "头奖", count: "1" },
|
||||
{ label: "rules.prizes.second", defaultValue: "二奖", count: "1" },
|
||||
{ label: "rules.prizes.third", defaultValue: "三奖", count: "1" },
|
||||
{ label: "rules.prizes.starter", defaultValue: "特别奖", count: "10" },
|
||||
{ label: "rules.prizes.consolation", defaultValue: "安慰奖", count: "10" },
|
||||
] as const;
|
||||
|
||||
const playSections = [
|
||||
{
|
||||
title: "rules.sections.dimensions.title",
|
||||
titleDefault: "什么是 2D、3D、4D",
|
||||
items: [
|
||||
["rules.sections.dimensions.d4", "4D:按完整 4 位号码判断,例如 1234。"],
|
||||
["rules.sections.dimensions.d3", "3D:按下注后三位与开奖号码后三位匹配,例如 234 可命中 1234。"],
|
||||
["rules.sections.dimensions.d2", "2D:按下注后二位与开奖号码后二位匹配,例如 34 可命中 1234。"],
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "rules.sections.bigSmall.title",
|
||||
titleDefault: "Big vs Small",
|
||||
items: [
|
||||
["rules.sections.bigSmall.big", "Big:覆盖全部 23 个奖项,命中头奖、二奖、三奖、特别奖或安慰奖任一组即中奖。"],
|
||||
["rules.sections.bigSmall.small", "Small:只覆盖头奖、二奖、三奖;命中特别奖或安慰奖不中奖。"],
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "rules.sections.positions.title",
|
||||
titleDefault: "位置类玩法",
|
||||
items: [
|
||||
["rules.sections.positions.d4", "4A / 4B / 4C 分别对应头奖、二奖、三奖;4D 命中特别奖任一组;4E 命中安慰奖任一组。"],
|
||||
["rules.sections.positions.d3", "3A / 3B / 3C 分别匹配头奖、二奖、三奖后三位;3ABC 命中头/二/三任一后三位。"],
|
||||
["rules.sections.positions.d2", "2A / 2B / 2C 分别匹配头奖、二奖、三奖后二位;2ABC 命中头/二/三任一后二位。"],
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "rules.sections.box.title",
|
||||
titleDefault: "包号类玩法",
|
||||
items: [
|
||||
["rules.sections.box.straight", "Straight:顺序完全一致才中奖。"],
|
||||
["rules.sections.box.box", "Box:系统展开不重复排列组合,中奖按命中的展开号码结算。"],
|
||||
["rules.sections.box.ibox", "iBox:每个排列都按单注金额下注,总扣款 = 单注金额 × 组合数 × (1 - 回水率)。"],
|
||||
["rules.sections.box.mbox", "mBox:总输入金额平摊到所有组合,不能整除的尾差不扣除,相当于退回玩家钱包。"],
|
||||
["rules.sections.box.roll", "Roll:用 R 表示滚动位,每个 R 覆盖 0-9;组合数 = 10 的滚动位数次方。"],
|
||||
["rules.sections.box.half", "Half Box:一期仅预留数据结构,前台不开放下注。"],
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "rules.sections.attributes.title",
|
||||
titleDefault: "Head / Tail、单双、数字大小",
|
||||
items: [
|
||||
["rules.sections.attributes.headTail", "Head / Tail:只看头奖首位数字;5-9 为 Head,0-4 为 Tail。"],
|
||||
["rules.sections.attributes.oddEven", "单双:按号码末位判断,1/3/5/7/9 为单,0/2/4/6/8 为双,可叠加 2D / 3D / 4D 维度。"],
|
||||
["rules.sections.attributes.digitSize", "Big Digit / Small Digit:按指定位数字判断,5-9 为大,0-4 为小;多位分别下注时独立结算。"],
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "rules.sections.wallet.title",
|
||||
titleDefault: "Jackpot、回水、封盘与售罄",
|
||||
items: [
|
||||
["rules.sections.wallet.rebate", "回水 / 佣金:下注时锁定到注单赔率快照,结算不会受之后配置变化影响。"],
|
||||
["rules.sections.wallet.jackpot", "Jackpot:命中规则与后台奖池配置相关;爆池、蓄水和派彩以系统记录为准。"],
|
||||
["rules.sections.wallet.close", "封盘:到达封盘时间后大厅不可继续编辑或提交注单。"],
|
||||
["rules.sections.wallet.soldOut", "售罄:号码赔付池额度不足时,预览会提示风险,提交时额度不足会整单拒绝。"],
|
||||
],
|
||||
},
|
||||
] as const;
|
||||
|
||||
export function PlayRulesScreen() {
|
||||
const { t } = useTranslation("player");
|
||||
|
||||
return (
|
||||
<PlayerPanel
|
||||
title={t("rules.title", { defaultValue: "玩法规则" })}
|
||||
subtitle={t("rules.subtitle", {
|
||||
defaultValue: "2D / 3D / 4D、包号、回水、封盘与售罄说明",
|
||||
})}
|
||||
backHref="/hall"
|
||||
className="bg-[#f7f9fd]"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<Card className="border-[#dbe7fb] bg-white shadow-sm">
|
||||
<CardHeader className="space-y-3 pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex size-9 items-center justify-center rounded-2xl bg-[#0b3f96] text-white">
|
||||
<BookOpen className="size-4" aria-hidden />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<CardTitle className="text-base text-[#0b3f96]">
|
||||
{t("rules.quick.title", { defaultValue: "开奖结构" })}
|
||||
</CardTitle>
|
||||
<p className="mt-0.5 text-xs text-slate-500">
|
||||
{t("rules.quick.description", {
|
||||
defaultValue: "每期开奖 23 组 4 位号码,结算以下注时锁定的赔率快照为准。",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2 text-center">
|
||||
<div className="rounded-2xl bg-[#eef5ff] p-3">
|
||||
<p className="text-lg font-black text-[#0b3f96]">23</p>
|
||||
<p className="text-[11px] font-semibold text-slate-500">
|
||||
{t("rules.quick.totalPrizes", { defaultValue: "奖项组数" })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-2xl bg-[#fff2f4] p-3">
|
||||
<p className="text-lg font-black text-[#f10b32]">15</p>
|
||||
<p className="text-[11px] font-semibold text-slate-500">
|
||||
{t("rules.quick.cooldown", { defaultValue: "冷静期分钟" })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-2xl bg-[#eefbf4] p-3">
|
||||
<p className="text-lg font-black text-[#168a4a]">1</p>
|
||||
<p className="text-[11px] font-semibold text-slate-500">
|
||||
{t("rules.quick.snapshot", { defaultValue: "赔率快照" })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{prizeRows.map((row) => (
|
||||
<div
|
||||
key={row.label}
|
||||
className="flex items-center justify-between rounded-xl border border-[#e6edf7] bg-white px-3 py-2 text-sm"
|
||||
>
|
||||
<span className="font-semibold text-slate-700">
|
||||
{t(row.label, { defaultValue: row.defaultValue })}
|
||||
</span>
|
||||
<Badge variant="secondary">{row.count}</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-3">
|
||||
{playSections.map((section) => (
|
||||
<Card key={section.title} className="border-[#e1e8f2] bg-white shadow-sm">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-black text-slate-900">
|
||||
{t(section.title, { defaultValue: section.titleDefault })}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="space-y-2">
|
||||
{section.items.map(([item, defaultValue]) => (
|
||||
<li
|
||||
key={item}
|
||||
className="rounded-xl bg-[#f8fafc] px-3 py-2 text-sm leading-6 text-slate-700"
|
||||
>
|
||||
{t(item, { defaultValue })}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Card className="border-[#d9e5f5] bg-[#0b3f96] text-white shadow-sm">
|
||||
<CardContent className="space-y-3 p-4">
|
||||
<div className="flex gap-3">
|
||||
<ShieldCheck className="mt-0.5 size-5 shrink-0" aria-hidden />
|
||||
<p className="text-sm leading-6 text-white/90">
|
||||
{t("rules.footer.config", {
|
||||
defaultValue: "下注大厅的玩法列、启用状态、限额和赔率来自后台生效玩法配置。",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<TimerReset className="mt-0.5 size-5 shrink-0" aria-hidden />
|
||||
<p className="text-sm leading-6 text-white/90">
|
||||
{t("rules.footer.phaseTwo", {
|
||||
defaultValue: "5D / 6D 属于二期扩展,一期前后端均不提供可用入口。",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/hall"
|
||||
className="inline-flex w-full items-center justify-center rounded-full bg-white px-4 py-2 text-sm font-black text-[#0b3f96]"
|
||||
>
|
||||
{t("rules.footer.backBet", { defaultValue: "返回下注大厅" })}
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</PlayerPanel>
|
||||
);
|
||||
}
|
||||
17
src/hooks/use-mobile.ts
Normal file
17
src/hooks/use-mobile.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export function useIsMobile(breakpoint = 768): boolean {
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const query = window.matchMedia(`(max-width: ${breakpoint - 1}px)`);
|
||||
const update = () => setIsMobile(query.matches);
|
||||
update();
|
||||
query.addEventListener("change", update);
|
||||
return () => query.removeEventListener("change", update);
|
||||
}, [breakpoint]);
|
||||
|
||||
return isMobile;
|
||||
}
|
||||
@@ -19,6 +19,7 @@
|
||||
"home": "Home",
|
||||
"results": "Results",
|
||||
"orders": "My Bets",
|
||||
"rules": "Rules",
|
||||
"wallet": "Wallet"
|
||||
},
|
||||
"panel": {
|
||||
@@ -158,6 +159,10 @@
|
||||
"backEdit": "Back to edit",
|
||||
"submitting": "Submitting...",
|
||||
"confirmSubmit": "Confirm submit",
|
||||
"processingTitle": "Submitting your bets",
|
||||
"processingDescription": "Please do not close the page or go back.",
|
||||
"processingProgress": "Processing tickets...",
|
||||
"noWarnings": "No obvious risk was found in this preview.",
|
||||
"warningsTitle": "Payout pool warning",
|
||||
"warningsDescription": "The following numbers have high payout pool usage for this issue. Betting is still allowed, but the order may be rejected as sold out if capacity is insufficient."
|
||||
},
|
||||
@@ -299,6 +304,8 @@
|
||||
"totalRecords": "Total Records",
|
||||
"betNow": "Bet Now",
|
||||
"empty": "No bet records yet.",
|
||||
"dateRange": "Date range",
|
||||
"statusFilter": "Status filter",
|
||||
"submitBet": "Submit Bet",
|
||||
"stake": "Stake",
|
||||
"deduction": "Deduction",
|
||||
@@ -323,6 +330,11 @@
|
||||
"jackpotAmount": "Jackpot {{amount}}",
|
||||
"payoutTotal": "Payout total {{amount}}",
|
||||
"matchLose": "Match result: not won",
|
||||
"matchResult": "Match result",
|
||||
"drawPendingMatch": "Draw numbers are not published yet. Winning status cannot be determined.",
|
||||
"matchPendingDraw": "Waiting for draw results. Winning status cannot be determined yet.",
|
||||
"matchPendingSettlement": "Draw results are published. Winning status will show after settlement.",
|
||||
"timeline": "Timeline",
|
||||
"settledAt": "Settled at {{time}}",
|
||||
"viewDraw": "View this draw",
|
||||
"backToOrders": "Back to My Bets",
|
||||
@@ -365,8 +377,73 @@
|
||||
"consolation": "Consolation"
|
||||
}
|
||||
},
|
||||
"rules": {
|
||||
"title": "Play Rules",
|
||||
"subtitle": "2D / 3D / 4D, box plays, rebate, closing and sold-out rules",
|
||||
"quick": {
|
||||
"title": "Prize Structure",
|
||||
"description": "Each draw publishes 23 four-digit numbers. Settlement uses the odds snapshot locked when the bet was placed.",
|
||||
"totalPrizes": "Prize groups",
|
||||
"cooldown": "Cooldown mins",
|
||||
"snapshot": "Odds snapshot"
|
||||
},
|
||||
"prizes": {
|
||||
"first": "First prize",
|
||||
"second": "Second prize",
|
||||
"third": "Third prize",
|
||||
"starter": "Starter",
|
||||
"consolation": "Consolation"
|
||||
},
|
||||
"sections": {
|
||||
"dimensions": {
|
||||
"title": "What are 2D, 3D and 4D",
|
||||
"d4": "4D: match the full four-digit number, for example 1234.",
|
||||
"d3": "3D: match your last three digits against the result's last three digits, for example 234 can hit 1234.",
|
||||
"d2": "2D: match your last two digits against the result's last two digits, for example 34 can hit 1234."
|
||||
},
|
||||
"bigSmall": {
|
||||
"title": "Big vs Small",
|
||||
"big": "Big: covers all 23 prizes. Any hit in first, second, third, starter or consolation wins.",
|
||||
"small": "Small: only covers first, second and third prizes. Starter or consolation hits do not win."
|
||||
},
|
||||
"positions": {
|
||||
"title": "Position Plays",
|
||||
"d4": "4A / 4B / 4C map to first, second and third prize. 4D hits any starter row. 4E hits any consolation row.",
|
||||
"d3": "3A / 3B / 3C match the last three digits of first, second and third prize. 3ABC hits any of those three.",
|
||||
"d2": "2A / 2B / 2C match the last two digits of first, second and third prize. 2ABC hits any of those three."
|
||||
},
|
||||
"box": {
|
||||
"title": "Box Plays",
|
||||
"straight": "Straight: only the exact same order wins.",
|
||||
"box": "Box: expands unique permutations and settles the winning expanded number.",
|
||||
"ibox": "iBox: every permutation is staked by the unit amount. Total deduction = unit amount × combinations × (1 - rebate rate).",
|
||||
"mbox": "mBox: input amount is shared across combinations. Any indivisible remainder is not deducted and is treated as returned to wallet.",
|
||||
"roll": "Roll: R marks a rolling digit covering 0-9. Combination count = 10 to the power of R count.",
|
||||
"half": "Half Box: reserved for phase one data structure only and not open on the player side."
|
||||
},
|
||||
"attributes": {
|
||||
"title": "Head / Tail, Odd / Even and Digit Size",
|
||||
"headTail": "Head / Tail only checks the first digit of the first prize. 5-9 is Head, 0-4 is Tail.",
|
||||
"oddEven": "Odd / Even checks the last digit. 1/3/5/7/9 is odd and 0/2/4/6/8 is even. It can be combined with 2D / 3D / 4D dimensions.",
|
||||
"digitSize": "Big Digit / Small Digit checks a selected digit. 5-9 is big and 0-4 is small. Multiple selected digits settle independently."
|
||||
},
|
||||
"wallet": {
|
||||
"title": "Jackpot, Rebate, Closing and Sold Out",
|
||||
"rebate": "Rebate / commission is locked into the ticket odds snapshot and is not changed by later configuration updates.",
|
||||
"jackpot": "Jackpot depends on backend pool configuration. Burst, contribution and payout follow system records.",
|
||||
"close": "Closing: after close time, the hall cannot edit or submit bets.",
|
||||
"soldOut": "Sold out: when payout pool capacity is insufficient, preview may warn and final submission can reject the whole order."
|
||||
}
|
||||
},
|
||||
"footer": {
|
||||
"config": "Hall play columns, enabled state, limits and odds come from the active backend play configuration.",
|
||||
"phaseTwo": "5D / 6D are phase-two extensions and have no available entry in phase one.",
|
||||
"backBet": "Back to betting hall"
|
||||
}
|
||||
},
|
||||
"ticketStatus": {
|
||||
"success": "Awaiting draw",
|
||||
"pending_payout": "Won, pending payout",
|
||||
"settled_win": "Paid",
|
||||
"settled_lose": "Not won",
|
||||
"unknown": "{{status}}"
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"home": "गृह",
|
||||
"results": "नतिजा",
|
||||
"orders": "मेरा बेट",
|
||||
"rules": "नियम",
|
||||
"wallet": "वालेट"
|
||||
},
|
||||
"panel": {
|
||||
@@ -365,6 +366,70 @@
|
||||
"consolation": "Consolation"
|
||||
}
|
||||
},
|
||||
"rules": {
|
||||
"title": "प्ले नियम",
|
||||
"subtitle": "2D / 3D / 4D, box play, rebate, closing र sold-out नियम",
|
||||
"quick": {
|
||||
"title": "पुरस्कार संरचना",
|
||||
"description": "हरेक ड्रमा 23 वटा 4 अंकका नम्बर प्रकाशित हुन्छन्। सेटलमेन्ट बेट राख्दा लक भएको odds snapshot अनुसार हुन्छ।",
|
||||
"totalPrizes": "पुरस्कार समूह",
|
||||
"cooldown": "Cooldown मिनेट",
|
||||
"snapshot": "Odds snapshot"
|
||||
},
|
||||
"prizes": {
|
||||
"first": "पहिलो पुरस्कार",
|
||||
"second": "दोस्रो पुरस्कार",
|
||||
"third": "तेस्रो पुरस्कार",
|
||||
"starter": "Starter",
|
||||
"consolation": "Consolation"
|
||||
},
|
||||
"sections": {
|
||||
"dimensions": {
|
||||
"title": "2D, 3D र 4D के हो",
|
||||
"d4": "4D: पूरा 4 अंकको नम्बर मिल्नुपर्छ, जस्तै 1234।",
|
||||
"d3": "3D: तपाईंको पछिल्ला 3 अंकलाई नतिजाको पछिल्ला 3 अंकसँग मिलाइन्छ, जस्तै 234 ले 1234 हिट गर्न सक्छ।",
|
||||
"d2": "2D: तपाईंको पछिल्ला 2 अंकलाई नतिजाको पछिल्ला 2 अंकसँग मिलाइन्छ, जस्तै 34 ले 1234 हिट गर्न सक्छ।"
|
||||
},
|
||||
"bigSmall": {
|
||||
"title": "Big vs Small",
|
||||
"big": "Big: सबै 23 पुरस्कार समेट्छ। first, second, third, starter वा consolation मध्ये कुनै पनि हिट भए जित्छ।",
|
||||
"small": "Small: first, second र third मात्र समेट्छ। starter वा consolation हिट भए जित्दैन।"
|
||||
},
|
||||
"positions": {
|
||||
"title": "Position Plays",
|
||||
"d4": "4A / 4B / 4C क्रमशः first, second र third prize हुन्। 4D ले कुनै starter row हिट गर्छ। 4E ले कुनै consolation row हिट गर्छ।",
|
||||
"d3": "3A / 3B / 3C ले first, second र third prize का पछिल्ला 3 अंक मिलाउँछ। 3ABC ले यी तीनमध्ये कुनै पनि मिलाउँछ।",
|
||||
"d2": "2A / 2B / 2C ले first, second र third prize का पछिल्ला 2 अंक मिलाउँछ। 2ABC ले यी तीनमध्ये कुनै पनि मिलाउँछ।"
|
||||
},
|
||||
"box": {
|
||||
"title": "Box Plays",
|
||||
"straight": "Straight: ठीक उही क्रम मिलेमात्र जित्छ।",
|
||||
"box": "Box: unique permutation हरू विस्तार हुन्छन् र हिट भएको expanded नम्बर सेटल हुन्छ।",
|
||||
"ibox": "iBox: हरेक permutation मा unit amount बेट हुन्छ। Total deduction = unit amount × combinations × (1 - rebate rate)।",
|
||||
"mbox": "mBox: input amount सबै combinations मा बाँडिन्छ। बाँकी indivisible रकम deduct हुँदैन र wallet मा फर्किएको मानिन्छ।",
|
||||
"roll": "Roll: R ले rolling digit जनाउँछ र 0-9 समेट्छ। Combination count = 10 को R count power।",
|
||||
"half": "Half Box: phase one मा data structure मात्र reserve छ, player side मा खुला छैन।"
|
||||
},
|
||||
"attributes": {
|
||||
"title": "Head / Tail, Odd / Even र Digit Size",
|
||||
"headTail": "Head / Tail ले first prize को पहिलो अंक मात्र हेर्छ। 5-9 Head, 0-4 Tail।",
|
||||
"oddEven": "Odd / Even ले अन्तिम अंक हेर्छ। 1/3/5/7/9 odd, 0/2/4/6/8 even। यो 2D / 3D / 4D dimension सँग प्रयोग गर्न सकिन्छ।",
|
||||
"digitSize": "Big Digit / Small Digit ले चयन गरिएको digit हेर्छ। 5-9 big, 0-4 small। धेरै digit छुट्टाछुट्टै सेटल हुन्छन्।"
|
||||
},
|
||||
"wallet": {
|
||||
"title": "Jackpot, Rebate, Closing र Sold Out",
|
||||
"rebate": "Rebate / commission टिकटको odds snapshot मा lock हुन्छ र पछि config बदलिए पनि बदलिँदैन।",
|
||||
"jackpot": "Jackpot backend pool configuration मा निर्भर हुन्छ। Burst, contribution र payout system records अनुसार हुन्छ।",
|
||||
"close": "Closing: close time पछि hall मा bet edit वा submit गर्न सकिँदैन।",
|
||||
"soldOut": "Sold out: payout pool capacity अपुग भए preview ले warning दिन सक्छ र final submission ले whole order reject गर्न सक्छ।"
|
||||
}
|
||||
},
|
||||
"footer": {
|
||||
"config": "Hall play columns, enabled state, limits र odds active backend play configuration बाट आउँछन्।",
|
||||
"phaseTwo": "5D / 6D phase-two extensions हुन् र phase one मा available entry छैन।",
|
||||
"backBet": "Betting hall मा फर्कनुहोस्"
|
||||
}
|
||||
},
|
||||
"ticketStatus": {
|
||||
"success": "ड्र पर्खँदै",
|
||||
"settled_win": "भुक्तानी भयो",
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"home": "首页",
|
||||
"results": "开奖结果",
|
||||
"orders": "我的注单",
|
||||
"rules": "规则",
|
||||
"wallet": "钱包"
|
||||
},
|
||||
"panel": {
|
||||
@@ -158,6 +159,10 @@
|
||||
"backEdit": "返回修改",
|
||||
"submitting": "提交中...",
|
||||
"confirmSubmit": "确认提交",
|
||||
"processingTitle": "正在提交下注",
|
||||
"processingDescription": "请勿关闭页面或返回上一页。",
|
||||
"processingProgress": "正在处理注单...",
|
||||
"noWarnings": "当前预览未发现明显风险。",
|
||||
"warningsTitle": "赔付池预警",
|
||||
"warningsDescription": "以下号码本期赔付池占用较高,仍允许下注;若实际占用不足将售罄拒单。"
|
||||
},
|
||||
@@ -299,6 +304,8 @@
|
||||
"totalRecords": "总记录数",
|
||||
"betNow": "立即下注",
|
||||
"empty": "暂无下注记录。",
|
||||
"dateRange": "日期范围",
|
||||
"statusFilter": "状态筛选",
|
||||
"submitBet": "提交下注",
|
||||
"stake": "下注",
|
||||
"deduction": "实扣",
|
||||
@@ -323,6 +330,11 @@
|
||||
"jackpotAmount": "Jackpot {{amount}}",
|
||||
"payoutTotal": "派彩合计 {{amount}}",
|
||||
"matchLose": "匹配结果:未中奖",
|
||||
"matchResult": "匹配结果",
|
||||
"drawPendingMatch": "本期开奖号码尚未发布,暂不能判断是否中奖。",
|
||||
"matchPendingDraw": "待开奖,暂不能判断是否中奖。",
|
||||
"matchPendingSettlement": "已开奖,等待系统结算后显示中奖结果。",
|
||||
"timeline": "时间线",
|
||||
"settledAt": "结算时间 {{time}}",
|
||||
"viewDraw": "查看本期开奖",
|
||||
"backToOrders": "返回我的注单",
|
||||
@@ -365,8 +377,73 @@
|
||||
"consolation": "安慰奖"
|
||||
}
|
||||
},
|
||||
"rules": {
|
||||
"title": "玩法规则",
|
||||
"subtitle": "2D / 3D / 4D、包号、回水、封盘与售罄说明",
|
||||
"quick": {
|
||||
"title": "开奖结构",
|
||||
"description": "每期开奖 23 组 4 位号码,结算以下注时锁定的赔率快照为准。",
|
||||
"totalPrizes": "奖项组数",
|
||||
"cooldown": "冷静期分钟",
|
||||
"snapshot": "赔率快照"
|
||||
},
|
||||
"prizes": {
|
||||
"first": "头奖",
|
||||
"second": "二奖",
|
||||
"third": "三奖",
|
||||
"starter": "特别奖",
|
||||
"consolation": "安慰奖"
|
||||
},
|
||||
"sections": {
|
||||
"dimensions": {
|
||||
"title": "什么是 2D、3D、4D",
|
||||
"d4": "4D:按完整 4 位号码判断,例如 1234。",
|
||||
"d3": "3D:按下注后三位与开奖号码后三位匹配,例如 234 可命中 1234。",
|
||||
"d2": "2D:按下注后二位与开奖号码后二位匹配,例如 34 可命中 1234。"
|
||||
},
|
||||
"bigSmall": {
|
||||
"title": "Big vs Small",
|
||||
"big": "Big:覆盖全部 23 个奖项,命中头奖、二奖、三奖、特别奖或安慰奖任一组即中奖。",
|
||||
"small": "Small:只覆盖头奖、二奖、三奖;命中特别奖或安慰奖不中奖。"
|
||||
},
|
||||
"positions": {
|
||||
"title": "位置类玩法",
|
||||
"d4": "4A / 4B / 4C 分别对应头奖、二奖、三奖;4D 命中特别奖任一组;4E 命中安慰奖任一组。",
|
||||
"d3": "3A / 3B / 3C 分别匹配头奖、二奖、三奖后三位;3ABC 命中头/二/三任一后三位。",
|
||||
"d2": "2A / 2B / 2C 分别匹配头奖、二奖、三奖后二位;2ABC 命中头/二/三任一后二位。"
|
||||
},
|
||||
"box": {
|
||||
"title": "包号类玩法",
|
||||
"straight": "Straight:顺序完全一致才中奖。",
|
||||
"box": "Box:系统展开不重复排列组合,中奖按命中的展开号码结算。",
|
||||
"ibox": "iBox:每个排列都按单注金额下注,总扣款 = 单注金额 × 组合数 × (1 - 回水率)。",
|
||||
"mbox": "mBox:总输入金额平摊到所有组合,不能整除的尾差不扣除,相当于退回玩家钱包。",
|
||||
"roll": "Roll:用 R 表示滚动位,每个 R 覆盖 0-9;组合数 = 10 的滚动位数次方。",
|
||||
"half": "Half Box:一期仅预留数据结构,前台不开放下注。"
|
||||
},
|
||||
"attributes": {
|
||||
"title": "Head / Tail、单双、数字大小",
|
||||
"headTail": "Head / Tail:只看头奖首位数字;5-9 为 Head,0-4 为 Tail。",
|
||||
"oddEven": "单双:按号码末位判断,1/3/5/7/9 为单,0/2/4/6/8 为双,可叠加 2D / 3D / 4D 维度。",
|
||||
"digitSize": "Big Digit / Small Digit:按指定位数字判断,5-9 为大,0-4 为小;多位分别下注时独立结算。"
|
||||
},
|
||||
"wallet": {
|
||||
"title": "Jackpot、回水、封盘与售罄",
|
||||
"rebate": "回水 / 佣金:下注时锁定到注单赔率快照,结算不会受之后配置变化影响。",
|
||||
"jackpot": "Jackpot:命中规则与后台奖池配置相关;爆池、蓄水和派彩以系统记录为准。",
|
||||
"close": "封盘:到达封盘时间后大厅不可继续编辑或提交注单。",
|
||||
"soldOut": "售罄:号码赔付池额度不足时,预览会提示风险,提交时额度不足会整单拒绝。"
|
||||
}
|
||||
},
|
||||
"footer": {
|
||||
"config": "下注大厅的玩法列、启用状态、限额和赔率来自后台生效玩法配置。",
|
||||
"phaseTwo": "5D / 6D 属于二期扩展,一期前后端均不提供可用入口。",
|
||||
"backBet": "返回下注大厅"
|
||||
}
|
||||
},
|
||||
"ticketStatus": {
|
||||
"success": "待开奖",
|
||||
"pending_payout": "已中奖待派彩",
|
||||
"settled_win": "已派彩",
|
||||
"settled_lose": "未中奖",
|
||||
"unknown": "{{status}}"
|
||||
|
||||
Reference in New Issue
Block a user