feat(hall): 支持全保金额整除校验与规则提示,优化桌面端转账按钮布局
Some checks failed
lotteryfront CI / build (push) Has been cancelled

This commit is contained in:
2026-07-15 13:40:19 +08:00
parent 295cfb97cd
commit 406ebb7671
14 changed files with 510 additions and 74 deletions

2
.gitignore vendored
View File

@@ -12,6 +12,8 @@
# testing # testing
/coverage /coverage
/.playwright-cli/
/output/
# next.js # next.js
/.next/ /.next/

View File

@@ -56,7 +56,11 @@ export function isValidRollNumber(value: string): boolean {
); );
} }
export type DraftLineIssueReason = "invalid_number_length" | "roll_requires_r" | "missing_digit_slot"; export type DraftLineIssueReason =
| "invalid_number_length"
| "roll_requires_r"
| "missing_digit_slot"
| "full_cover_amount_not_divisible";
/** /**
* 某玩法列已填金额但无法组成合法注单行时返回原因;合法则返回 null。 * 某玩法列已填金额但无法组成合法注单行时返回原因;合法则返回 null。

View File

@@ -1,9 +1,10 @@
"use client"; "use client";
import { ChevronDown, ChevronUp, Lock, Ticket, Trash2, Star } from "lucide-react"; import { ChevronDown, ChevronUp, CircleHelp, Lock, Ticket, Trash2, Star } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState, memo } from "react"; import { useCallback, useEffect, useMemo, useRef, useState, memo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { toast } from "sonner"; import { toast } from "sonner";
import { Tooltip } from "@base-ui/react/tooltip";
import { getBetProviders } from "@/api/bet-providers"; import { getBetProviders } from "@/api/bet-providers";
import { getPlayEffective } from "@/api/play"; import { getPlayEffective } from "@/api/play";
@@ -34,6 +35,7 @@ import {
type DraftLineIssueReason, type DraftLineIssueReason,
} from "@/features/hall/hall-bet-rules"; } from "@/features/hall/hall-bet-rules";
import { import {
isFullCoverAmountDivisible,
isHighCostSelectionType, isHighCostSelectionType,
resolveSelectionTotalBet, resolveSelectionTotalBet,
selectionCombinationCount, selectionCombinationCount,
@@ -1111,6 +1113,18 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
playCode: column.play.play_code, playCode: column.play.play_code,
reason, reason,
}); });
return;
}
const comboCount = selectionCombinationCount(row.number, row.selectionType);
if (
row.selectionType === "full_cover"
&& !isFullCoverAmountDivisible(amount, comboCount)
) {
issues.push({
rowNo: rowIndex + 1,
playCode: column.play.play_code,
reason: "full_cover_amount_not_divisible",
});
} }
}); });
}); });
@@ -2257,7 +2271,7 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
) : null} ) : null}
</div> </div>
) : null} ) : null}
<DialogFooter className="gap-2 border-t border-[#eef2f8] px-5 py-4 sm:justify-end"> <DialogFooter className="mx-0 mb-0 gap-2 rounded-b-2xl border-t border-[#eef2f8] bg-white px-5 py-4 sm:justify-end">
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
@@ -2341,6 +2355,10 @@ const DraftRowItem = memo(function DraftRowItem({
}) { }) {
const displayNumber = sanitizeNumber(row.number, activeCategory); const displayNumber = sanitizeNumber(row.number, activeCategory);
const comboCount = selectionCombinationCount(displayNumber, row.selectionType); const comboCount = selectionCombinationCount(displayNumber, row.selectionType);
const hasFullCoverAmountError = row.selectionType === "full_cover" && playColumns.some((column) => {
const amount = parseDecimalInputToMinor(row.amounts[column.key] ?? "", currencyCode);
return amount !== null && amount > 0 && !isFullCoverAmountDivisible(amount, comboCount);
});
const rowTotalMinor = const rowTotalMinor =
playColumns.reduce((total, column) => { playColumns.reduce((total, column) => {
if (draftLineIssueReason(column.play.play_code, row.number, column.digitSlot) !== null) { if (draftLineIssueReason(column.play.play_code, row.number, column.digitSlot) !== null) {
@@ -2359,7 +2377,7 @@ const DraftRowItem = memo(function DraftRowItem({
> >
<td <td
className={cn( className={cn(
"sticky left-0 z-20 px-0.5 py-1.5 text-center font-black text-[#17408d] shadow-[2px_0_6px_rgba(15,23,42,0.04)]", "sticky left-0 z-20 align-top px-0.5 py-1.5 text-center font-black text-[#17408d] shadow-[2px_0_6px_rgba(15,23,42,0.04)]",
indexColClass, indexColClass,
rowActive ? "bg-[#f5f9ff]" : "bg-white", rowActive ? "bg-[#f5f9ff]" : "bg-white",
)} )}
@@ -2368,7 +2386,7 @@ const DraftRowItem = memo(function DraftRowItem({
</td> </td>
<td <td
className={cn( className={cn(
"sticky z-20 px-1 py-1.5 shadow-[2px_0_6px_rgba(15,23,42,0.04)]", "sticky z-20 align-top px-1 py-1.5 shadow-[2px_0_6px_rgba(15,23,42,0.04)]",
stickyNumberLeftClass, stickyNumberLeftClass,
numberColClass, numberColClass,
rowActive ? "bg-[#f5f9ff]" : "bg-white", rowActive ? "bg-[#f5f9ff]" : "bg-white",
@@ -2455,7 +2473,7 @@ const DraftRowItem = memo(function DraftRowItem({
); );
})} })}
{showSelectionTypeColumn ? ( {showSelectionTypeColumn ? (
<td className={cn(selectionTypeColClass, "px-1 py-1.5 text-center", rowActive && "bg-[#f5f9ff]")}> <td className={cn(selectionTypeColClass, "align-top px-1 py-1.5 text-center", rowActive && "bg-[#f5f9ff]")}>
<select <select
value={row.selectionType} value={row.selectionType}
disabled={tableDisabled} disabled={tableDisabled}
@@ -2470,8 +2488,33 @@ const DraftRowItem = memo(function DraftRowItem({
))} ))}
</select> </select>
{comboCount > 1 && displayNumber.length >= 2 ? ( {comboCount > 1 && displayNumber.length >= 2 ? (
<p className="mt-0.5 text-[9px] font-bold leading-none text-[#0b56b7]"> <div className="mt-0.5 flex items-center justify-center gap-0.5 text-[9px] font-bold leading-none text-[#0b56b7]">
{t("hall.table.comboCount", { count: comboCount })} <span>{t("hall.table.comboCount", { count: comboCount })}</span>
{row.selectionType === "full_cover" || row.selectionType === "half_play" ? (
<Tooltip.Root>
<Tooltip.Trigger
className="inline-flex size-3 items-center justify-center rounded-full text-[#5378b5] outline-none hover:text-[#0b56b7] focus-visible:ring-1 focus-visible:ring-[#0b56b7]"
aria-label={t("hall.table.selectionTypeRule")}
title={t("hall.table.selectionTypeRule")}
>
<CircleHelp className="size-3" aria-hidden />
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Positioner side="bottom" sideOffset={6}>
<Tooltip.Popup className="z-[70] max-w-52 rounded-md bg-slate-900 px-2 py-1.5 text-left text-[11px] font-medium leading-snug text-white shadow-lg">
{row.selectionType === "full_cover"
? t("hall.table.fullCoverSplitHint", { count: comboCount })
: t("hall.table.halfPlayHint")}
</Tooltip.Popup>
</Tooltip.Positioner>
</Tooltip.Portal>
</Tooltip.Root>
) : null}
</div>
) : null}
{row.selectionType === "full_cover" && comboCount > 1 && hasFullCoverAmountError ? (
<p className="mt-0.5 text-[9px] font-medium leading-tight text-red-600">
{t("hall.table.fullCoverDivisibilityError", { count: comboCount })}
</p> </p>
) : null} ) : null}
</td> </td>

View File

@@ -84,7 +84,7 @@ export function HallWalletStrip() {
<div <div
className={cn( className={cn(
"relative overflow-hidden rounded-xl bg-[#e5002c] text-white shadow-[0_8px_24px_rgba(229,0,44,0.22)]", "relative overflow-hidden rounded-xl bg-[#e5002c] text-white shadow-[0_8px_24px_rgba(229,0,44,0.22)]",
isMobile ? "px-3 py-2.5" : "px-5 py-3 lg:h-[5.5rem] lg:px-4", isMobile ? "px-3 py-2.5" : "px-5 py-3 lg:h-[5.5rem] lg:px-4 lg:pr-14",
)} )}
> >
<Image <Image
@@ -150,15 +150,45 @@ export function HallWalletStrip() {
) : null} ) : null}
</div> </div>
</div> </div>
{!isCreditPlayer ? (
<div className="absolute right-3 top-1/2 hidden -translate-y-1/2 flex-col gap-1 lg:flex">
<TransferInDialog
idPrefix="hall-desktop-"
triggerVariant="hall"
triggerLabel={t("wallet.transferIn")}
triggerIconOnly
triggerClassName="size-7 min-h-0 w-7 flex-none rounded-md border border-white/25 bg-white/15 p-0 text-white shadow-none hover:bg-white/25"
currency={currency}
lotteryMinor={transferInLotteryMinor}
mainMinor={mainMinor}
onSuccess={async () => {
await mutate(BALANCE_KEY(currency));
}}
/>
<TransferOutDialog
idPrefix="hall-desktop-"
triggerVariant="hall"
triggerLabel={t("wallet.transferOut")}
triggerIconOnly
triggerClassName="size-7 min-h-0 w-7 flex-none rounded-md border border-white/25 bg-white/15 p-0 text-white shadow-none hover:bg-white/25"
currency={currency}
availableMinor={availableMinor}
onSuccess={async () => {
await mutate(BALANCE_KEY(currency));
}}
/>
</div>
) : null}
</div> </div>
{isCreditPlayer ? null : ( {isCreditPlayer ? null : (
<div className={cn("grid grid-cols-2", isMobile ? "gap-2" : "max-w-sm gap-2.5")}> <div className="grid grid-cols-2 gap-2 lg:hidden">
<TransferInDialog <TransferInDialog
idPrefix="hall-" idPrefix="hall-"
triggerVariant="hall" triggerVariant="hall"
triggerLabel={t("wallet.transferIn")} triggerLabel={t("wallet.transferIn")}
triggerClassName={cn("rounded-lg font-bold", isMobile ? "h-10 text-sm" : "h-10 text-sm")} triggerClassName="h-10 rounded-lg text-sm font-bold"
currency={currency} currency={currency}
lotteryMinor={transferInLotteryMinor} lotteryMinor={transferInLotteryMinor}
mainMinor={mainMinor} mainMinor={mainMinor}
@@ -170,7 +200,7 @@ export function HallWalletStrip() {
idPrefix="hall-" idPrefix="hall-"
triggerVariant="hall" triggerVariant="hall"
triggerLabel={t("wallet.transferOut")} triggerLabel={t("wallet.transferOut")}
triggerClassName={cn("rounded-lg font-bold", isMobile ? "h-10 text-sm" : "h-10 text-sm")} triggerClassName="h-10 rounded-lg text-sm font-bold"
currency={currency} currency={currency}
availableMinor={availableMinor} availableMinor={availableMinor}
onSuccess={async () => { onSuccess={async () => {

View File

@@ -108,8 +108,7 @@ export function resolveSelectionTotalBet(
const count = Math.max(1, combinationCount); const count = Math.max(1, combinationCount);
if (selectionType === "full_cover") { if (selectionType === "full_cover") {
const unit = Math.floor(amountMinor / count); return amountMinor;
return unit * count;
} }
if ( if (
@@ -123,6 +122,15 @@ export function resolveSelectionTotalBet(
return amountMinor; return amountMinor;
} }
/** 全保为均摊玩法,输入总额必须恰好能分配到每个排列。 */
export function isFullCoverAmountDivisible(
amountMinor: number,
combinationCount: number,
): boolean {
if (!Number.isFinite(amountMinor) || amountMinor <= 0) return true;
return amountMinor % Math.max(1, combinationCount) === 0;
}
export function resolveSelectionUnitBet( export function resolveSelectionUnitBet(
amountMinor: number, amountMinor: number,
selectionType: SelectionType, selectionType: SelectionType,

View File

@@ -28,6 +28,7 @@ import { getPlayerMe, getPlayerPing } from "@/api/player";
import { isInIframe } from "@/components/iframe-bridge"; import { isInIframe } from "@/components/iframe-bridge";
import { LanguageSwitcher } from "@/components/language-switcher"; import { LanguageSwitcher } from "@/components/language-switcher";
import { EntryHeroBanner } from "@/features/player/entry-hero-banner"; import { EntryHeroBanner } from "@/features/player/entry-hero-banner";
import { PlayerEntryDesktop } from "@/features/player/player-entry-desktop";
import { Button, buttonVariants } from "@/components/ui/button"; import { Button, buttonVariants } from "@/components/ui/button";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { usePlayerSessionStore } from "@/stores/player-session-store"; import { usePlayerSessionStore } from "@/stores/player-session-store";
@@ -103,6 +104,20 @@ function getServerUrlTokenSnapshot(): string {
return ""; return "";
} }
/** SSR 与首个客户端画面均视为未就绪hydration 后才开放入口判断。 */
function subscribeHydration(onStoreChange: () => void): () => void {
queueMicrotask(onStoreChange);
return () => {};
}
function getHydratedSnapshot(): boolean {
return true;
}
function getServerHydratedSnapshot(): boolean {
return false;
}
function stripSearchParamFromBrowserUrl(name: string): void { function stripSearchParamFromBrowserUrl(name: string): void {
if (typeof window === "undefined") return; if (typeof window === "undefined") return;
const url = new URL(window.location.href); const url = new URL(window.location.href);
@@ -143,6 +158,15 @@ export function EntryGate() {
getServerUrlTokenSnapshot, getServerUrlTokenSnapshot,
); );
const sessionExpired = searchParams.get("session") === "expired"; const sessionExpired = searchParams.get("session") === "expired";
const hasHydrated = useSyncExternalStore(
subscribeHydration,
getHydratedSnapshot,
getServerHydratedSnapshot,
);
/** URL token 被安全剥离后,直连进场仍须持续到验签完成。 */
const [directEntryAccepted, setDirectEntryAccepted] = useState(false);
/** 防止 token 写入 store / URL 剥离后重复触发进场,避免成功/失败页闪一下。 */
const entryLifecycleRef = useRef<"idle" | "running" | "done">("idle");
const { bearerToken, setBearerToken, setProfile, setCurrencies, clearBearerToken } = const { bearerToken, setBearerToken, setProfile, setCurrencies, clearBearerToken } =
usePlayerSessionStore(); usePlayerSessionStore();
@@ -162,27 +186,30 @@ export function EntryGate() {
!(bearerToken ?? "").trim(); !(bearerToken ?? "").trim();
const gateReady = useMemo(() => { const gateReady = useMemo(() => {
if (typeof window === "undefined") return false; // 首次客户端渲染与 SSR 都显示同一 loading 状态,避免 hydration 重建。
if (typeof window === "undefined" || !hasHydrated) return false;
if (!isInIframe()) { if (!isInIframe()) {
if (sessionExpired) return false; if (sessionExpired) return false;
return capturedUrlToken !== "" || tokenFromUrl !== ""; return directEntryAccepted || capturedUrlToken !== "" || tokenFromUrl !== "";
} }
return true; return true;
}, [capturedUrlToken, sessionExpired, tokenFromUrl]); }, [capturedUrlToken, directEntryAccepted, hasHydrated, sessionExpired, tokenFromUrl]);
useEffect(() => { useEffect(() => {
if (!hasHydrated) return;
if (gateReady) return; if (gateReady) return;
if (typeof window === "undefined") return; if (typeof window === "undefined") return;
if (isInIframe()) return; if (isInIframe()) return;
if (entryLifecycleRef.current !== "idle") return;
if (sessionExpired) { if (sessionExpired) {
router.replace("/login?session=expired"); router.replace("/login?session=expired");
} else if (!tokenFromUrl) { } else if (!tokenFromUrl) {
router.replace("/login"); router.replace("/login");
} }
}, [gateReady, router, sessionExpired, tokenFromUrl]); }, [gateReady, hasHydrated, router, sessionExpired, tokenFromUrl]);
const [phase, setPhase] = useState<Phase>(sessionExpired ? "failed" : "loading"); const [phase, setPhase] = useState<Phase>(sessionExpired ? "failed" : "loading");
const [failureDetails, setFailureDetails] = useState<FailureRow[]>(() => const [failureDetails, setFailureDetails] = useState<FailureRow[]>(() =>
@@ -203,12 +230,10 @@ export function EntryGate() {
return urlToken || bearerToken; return urlToken || bearerToken;
} }
if (!isInIframe()) { if (!isInIframe()) {
return urlToken; return urlToken || (directEntryAccepted ? bearerToken : "");
} }
return urlToken || bearerToken; return urlToken || bearerToken;
}, [bearerToken, capturedUrlToken, tokenFromUrl]); }, [bearerToken, capturedUrlToken, directEntryAccepted, tokenFromUrl]);
/** 防止 token 写入 store / URL 剥离后重复触发进场,避免成功/失败页闪一下 */
const entryLifecycleRef = useRef<"idle" | "running" | "done">("idle");
const updateStep = useCallback((stepId: EntryStepId, status: EntryStepStatus) => { const updateStep = useCallback((stepId: EntryStepId, status: EntryStepStatus) => {
setSteps((prev) => prev.map((s) => (s.id === stepId ? { ...s, status } : s))); setSteps((prev) => prev.map((s) => (s.id === stepId ? { ...s, status } : s)));
@@ -247,6 +272,9 @@ export function EntryGate() {
const urlToken = capturedUrlToken || tokenFromUrl; const urlToken = capturedUrlToken || tokenFromUrl;
if (urlToken) { if (urlToken) {
if (typeof window !== "undefined" && !isInIframe()) {
setDirectEntryAccepted(true);
}
setBearerToken(urlToken); setBearerToken(urlToken);
pendingUrlToken = ""; pendingUrlToken = "";
stripSearchParamFromBrowserUrl("token"); stripSearchParamFromBrowserUrl("token");
@@ -464,7 +492,19 @@ export function EntryGate() {
} }
return ( return (
<div className="relative flex min-h-0 flex-1 flex-col overflow-y-auto overscroll-y-contain bg-white"> <>
<PlayerEntryDesktop
phase={phase}
waitingForToken={waitingForEmbeddedToken}
progress={progress}
steps={steps}
failureDetails={failureDetails}
t={t}
tc={tc}
mainSiteUrl={MAIN_SITE_URL}
onRetry={handleRetry}
/>
<div className="relative flex min-h-0 flex-1 flex-col overflow-y-auto overscroll-y-contain bg-white lg:hidden">
<div className="absolute left-0 right-0 top-0 z-20 hidden items-center px-4 py-3 lg:flex"> <div className="absolute left-0 right-0 top-0 z-20 hidden items-center px-4 py-3 lg:flex">
<LanguageSwitcher variant="header" showFlag={false} /> <LanguageSwitcher variant="header" showFlag={false} />
</div> </div>
@@ -684,7 +724,8 @@ export function EntryGate() {
<span>{t("footer.secure")}</span> <span>{t("footer.secure")}</span>
</div> </div>
</div> </div>
</div> </div>
</>
); );
} }

View File

@@ -0,0 +1,260 @@
"use client";
import type { TFunction } from "i18next";
import {
AlertCircle,
AlertTriangle,
CheckCircle2,
Globe,
Loader2,
RefreshCw,
ShieldCheck,
} from "lucide-react";
import Image from "next/image";
import { useTranslation } from "react-i18next";
import { LanguageSwitcher } from "@/components/language-switcher";
import { Button, buttonVariants } from "@/components/ui/button";
import { cn } from "@/lib/utils";
export type DesktopEntryStepStatus = "pending" | "in-progress" | "done" | "error";
type DesktopEntryStep = {
id: "token" | "account" | "hall";
status: DesktopEntryStepStatus;
};
type DesktopFailureRow = {
code?: string;
detailKey?: string;
fallbackMessage?: string;
};
type PlayerEntryDesktopProps = {
phase: "loading" | "success" | "failed";
waitingForToken: boolean;
progress: number;
steps: DesktopEntryStep[];
failureDetails: DesktopFailureRow[];
t: TFunction<"entry">;
tc: TFunction<"common">;
mainSiteUrl: string;
onRetry: () => void;
};
/** PC Token 入场校验,视觉与账号密码登录保持一致。 */
export function PlayerEntryDesktop({
phase,
waitingForToken,
progress,
steps,
failureDetails,
t,
tc,
mainSiteUrl,
onRetry,
}: PlayerEntryDesktopProps) {
const isLoading = phase === "loading" || waitingForToken;
return (
<div className="relative hidden min-h-0 flex-1 overflow-y-auto lg:flex lg:items-center lg:justify-center lg:px-4 lg:py-4 xl:px-8 xl:py-10">
<div className="pointer-events-none absolute inset-0" aria-hidden>
<Image
src="/entry/login-pc-bg.png"
alt=""
fill
sizes="100vw"
className="object-cover object-center"
priority
/>
</div>
<div className="relative z-10 flex min-h-[min(560px,calc(100dvh-2rem))] w-full max-w-[960px] overflow-hidden rounded-[1.5rem] bg-white shadow-[0_24px_80px_rgba(15,23,42,0.12)] xl:min-h-[min(640px,88vh)] xl:max-w-[1080px] xl:rounded-[2rem]">
<div className="relative w-[min(48%,400px)] shrink-0 self-stretch overflow-hidden bg-[#f8fafc] xl:w-[min(50%,460px)]">
<Image
src="/entry/login-pc-hero.png"
alt={t("header.backgroundAlt")}
fill
sizes="(max-width: 1280px) 400px, 460px"
className="object-cover object-center"
priority
/>
</div>
<div className="flex min-w-0 flex-1 flex-col px-6 py-5 xl:px-10 xl:py-10">
<div className="flex justify-end">
<LanguageSwitcher variant="pill" menuAlign="end" showFlag={false} useFullLabel />
</div>
<div className="mx-auto flex w-full max-w-[25rem] flex-1 flex-col justify-center">
{isLoading ? (
<EntryProgress progress={progress} steps={steps} t={t} />
) : null}
{phase === "failed" ? (
<EntryFailure
details={failureDetails}
mainSiteUrl={mainSiteUrl}
onRetry={onRetry}
t={t}
tc={tc}
/>
) : null}
</div>
<div className="flex items-center justify-center gap-2 text-xs font-medium text-slate-500">
<ShieldCheck className="size-4 text-red-500" aria-hidden />
<span>{t("footer.secure")}</span>
</div>
</div>
</div>
</div>
);
}
function EntryProgress({
progress,
steps,
t,
}: Pick<PlayerEntryDesktopProps, "progress" | "steps" | "t">) {
return (
<>
<div className="mb-8 flex items-center gap-3">
<div className="flex size-10 items-center justify-center rounded-lg bg-red-600 text-white shadow-[0_6px_16px_rgba(220,38,38,0.2)]">
<Globe className="size-5" aria-hidden />
</div>
<div>
<h1 className="font-semibold text-slate-900">{t("loading.title")}</h1>
<p className="mt-0.5 text-xs text-slate-500">{t("loading.progress")}</p>
</div>
</div>
<div className="mb-8">
<div className="mb-2 flex justify-between text-xs text-slate-500">
<span>{t("loading.progress")}</span>
<span className="font-semibold text-red-600">{progress}%</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-slate-100">
<div
className="h-full rounded-full bg-red-600 transition-all duration-500"
style={{ width: `${progress}%` }}
/>
</div>
</div>
<div className="space-y-5">
{steps.map((step) => (
<div key={step.id} className="flex items-start gap-3">
<StepIcon status={step.status} />
<div className="min-w-0 flex-1 pt-0.5">
<div className="flex items-center justify-between gap-3">
<span className="font-medium text-slate-800">{t(`steps.${step.id}.title`)}</span>
<StepBadge status={step.status} />
</div>
<p className="mt-1 text-xs leading-5 text-slate-500">
{t(`steps.${step.id}.description`)}
</p>
</div>
</div>
))}
</div>
</>
);
}
function EntryFailure({
details,
mainSiteUrl,
onRetry,
t,
tc,
}: Pick<PlayerEntryDesktopProps, "mainSiteUrl" | "onRetry" | "t" | "tc"> & {
details: DesktopFailureRow[];
}) {
return (
<div>
<div className="mb-7 text-center">
<div className="mx-auto mb-4 flex size-14 items-center justify-center rounded-full bg-red-50 text-red-600">
<AlertTriangle className="size-7" aria-hidden />
</div>
<h1 className="text-xl font-bold text-red-600">{t("failure.title")}</h1>
<p className="mt-1 text-sm text-slate-500">{t("failure.subtitle")}</p>
</div>
{details.length > 0 ? (
<div className="mb-6 overflow-hidden rounded-xl border border-red-100 bg-red-50/60">
<div className="border-b border-red-100 bg-red-50 px-4 py-2.5 text-sm font-medium text-red-800">
{t("failure.detailsTitle")}
</div>
<div className="divide-y divide-red-100">
{details.map((detail, index) => (
<div key={`${detail.code}-${index}`} className="grid grid-cols-[2rem_6.5rem_minmax(0,1fr)] gap-2 px-4 py-3 text-xs">
<span className="text-slate-400">{index + 1}</span>
<span className="font-medium text-slate-700">{detail.code ?? tc("errors.general")}</span>
<span className="leading-5 text-slate-600">
{detail.detailKey
? t(detail.detailKey)
: (detail.fallbackMessage ?? t("errors.unknown"))}
</span>
</div>
))}
</div>
</div>
) : null}
<div className="flex gap-3">
<Button onClick={onRetry} className="flex-1 gap-2 bg-red-600 text-white hover:bg-red-700" type="button">
<RefreshCw className="size-4" aria-hidden />
{t("failure.reenter")}
</Button>
{mainSiteUrl ? (
<a
href={mainSiteUrl}
target="_top"
rel="noopener noreferrer"
className={cn(buttonVariants({ variant: "outline" }), "flex-1")}
>
{t("failure.backToMainSite")}
</a>
) : null}
</div>
</div>
);
}
function StepIcon({ status }: { status: DesktopEntryStepStatus }) {
const className = cn(
"flex size-8 shrink-0 items-center justify-center rounded-full border-2",
status === "done" && "border-emerald-500 bg-emerald-500 text-white",
status === "in-progress" && "border-blue-600 bg-blue-600 text-white",
status === "pending" && "border-slate-200 bg-slate-50 text-slate-400",
status === "error" && "border-red-500 bg-red-500 text-white",
);
return (
<div className={className}>
{status === "done" ? <CheckCircle2 className="size-4" aria-hidden /> : null}
{status === "in-progress" ? <Loader2 className="size-4 animate-spin" aria-hidden /> : null}
{status === "pending" ? <div className="size-2 rounded-full bg-current" aria-hidden /> : null}
{status === "error" ? <AlertCircle className="size-4" aria-hidden /> : null}
</div>
);
}
function StepBadge({ status }: { status: DesktopEntryStepStatus }) {
const { t } = useTranslation("common");
const className = cn(
"shrink-0 rounded px-2 py-0.5 text-xs font-medium",
status === "done" && "bg-emerald-50 text-emerald-700",
status === "in-progress" && "bg-blue-50 text-blue-700",
status === "pending" && "bg-slate-100 text-slate-500",
status === "error" && "bg-red-50 text-red-700",
);
const label = {
done: t("status.done"),
"in-progress": t("status.inProgress"),
pending: t("status.pending"),
error: t("status.failed"),
}[status];
return <span className={className}>{label}</span>;
}

View File

@@ -1,6 +1,7 @@
"use client"; "use client";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation";
import { CalendarIcon, ChevronRight, XIcon } from "lucide-react"; import { CalendarIcon, ChevronRight, XIcon } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -33,7 +34,6 @@ import { useCurrencyCatalog } from "@/hooks/use-currency-catalog";
import { useIsMobile } from "@/hooks/use-mobile"; import { useIsMobile } from "@/hooks/use-mobile";
import { formatPlayerInstant } from "@/lib/player-datetime"; import { formatPlayerInstant } from "@/lib/player-datetime";
import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency"; import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
import { cn } from "@/lib/utils";
import { resultsPrizeLabelKey, RESULTS_TOP_PRIZE_KEYS } from "@/lib/results-prize-labels"; import { resultsPrizeLabelKey, RESULTS_TOP_PRIZE_KEYS } from "@/lib/results-prize-labels";
import type { DrawResultListItem } from "@/types/api/draw-results"; import type { DrawResultListItem } from "@/types/api/draw-results";
@@ -46,6 +46,7 @@ const MONTH_OPTIONS = Array.from({ length: 12 }, (_, value) => ({
export function DrawResultsListScreen() { export function DrawResultsListScreen() {
const { t } = useTranslation("player"); const { t } = useTranslation("player");
const router = useRouter();
useCurrencyCatalog(); useCurrencyCatalog();
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const [items, setItems] = useState<DrawResultListItem[] | null>(null); const [items, setItems] = useState<DrawResultListItem[] | null>(null);
@@ -378,10 +379,20 @@ export function DrawResultsListScreen() {
return ( return (
<TableRow <TableRow
key={row.draw_no} key={row.draw_no}
className="border-[#eef3fa] hover:bg-[#f8fbff]" role="link"
tabIndex={0}
aria-label={`${t("results.openDetail", { defaultValue: "查看详情" })} ${row.draw_no}`}
className="group cursor-pointer border-[#eef3fa] transition-colors hover:bg-[#f8fbff] focus-visible:bg-[#f1f6ff] focus-visible:outline-none"
onClick={() => router.push(href)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
router.push(href);
}
}}
> >
<TableCell className="px-5 py-3 align-middle"> <TableCell className="px-5 py-3 align-middle">
<Link href={href} prefetch={false} className="block min-w-0"> <div className="block min-w-0">
<div className="flex min-w-0 items-center gap-2"> <div className="flex min-w-0 items-center gap-2">
<p className="truncate font-mono text-sm font-bold text-[#0b3f96]"> <p className="truncate font-mono text-sm font-bold text-[#0b3f96]">
{row.draw_no} {row.draw_no}
@@ -403,28 +414,19 @@ export function DrawResultsListScreen() {
.join(" / ")} .join(" / ")}
</p> </p>
) : null} ) : null}
</Link> </div>
</TableCell> </TableCell>
{RESULTS_TOP_PRIZE_KEYS.map((tier) => ( {RESULTS_TOP_PRIZE_KEYS.map((tier) => (
<TableCell key={tier} className="px-3 py-3 text-center align-middle"> <TableCell key={tier} className="px-3 py-3 text-center align-middle">
<Link <span className="font-mono text-base font-bold tabular-nums text-[#e5002c]">
href={href}
prefetch={false}
className="font-mono text-base font-bold tabular-nums text-[#e5002c]"
>
{row.results[tier]} {row.results[tier]}
</Link> </span>
</TableCell> </TableCell>
))} ))}
<TableCell className="px-3 py-3 text-right align-middle"> <TableCell className="px-3 py-3 text-right align-middle">
<Link <span className="inline-flex size-8 items-center justify-center rounded-lg text-[#7890b8] transition-colors group-hover:bg-[#eaf2ff] group-hover:text-[#0b56b7]">
href={href}
prefetch={false}
aria-label={t("results.detail")}
className="inline-flex size-8 items-center justify-center rounded-lg text-[#7890b8] transition-colors hover:bg-[#eaf2ff] hover:text-[#0b56b7]"
>
<ChevronRight className="size-4" aria-hidden /> <ChevronRight className="size-4" aria-hidden />
</Link> </span>
</TableCell> </TableCell>
</TableRow> </TableRow>
); );

View File

@@ -152,27 +152,28 @@ export function WalletLogsBlock({
<div> <div>
<h2 className="text-sm font-black text-[#0b3f96] lg:text-base">{resolvedTitle}</h2> <h2 className="text-sm font-black text-[#0b3f96] lg:text-base">{resolvedTitle}</h2>
</div> </div>
{/* 移动端小胶囊PC大厅 4D/3D 同款分段控件 */} <div className="w-full overflow-x-auto overscroll-x-contain [scrollbar-width:none] lg:w-auto lg:max-w-full">
<div className="flex flex-wrap gap-1.5 lg:inline-flex lg:max-w-full lg:flex-nowrap lg:items-center lg:gap-1 lg:overflow-x-auto lg:rounded-lg lg:bg-[#f3f6fb] lg:p-1"> <div className="inline-flex min-w-max items-center gap-1 rounded-lg bg-[#f3f6fb] p-1">
{filters.map((f) => { {filters.map((f) => {
const active = filter === f.value; const active = filter === f.value;
return ( return (
<button <button
key={f.value || "all"} key={f.value || "all"}
type="button" type="button"
disabled={logsLoading && active} disabled={logsLoading && active}
onClick={() => onFilterChange(f.value)} onClick={() => onFilterChange(f.value)}
aria-pressed={active} aria-pressed={active}
className={ className={
active active
? "inline-flex h-8 items-center justify-center rounded-full bg-[#07459f] px-3 text-xs font-bold text-white transition-colors hover:bg-[#063b88] disabled:opacity-60 lg:h-auto lg:min-w-[4.5rem] lg:rounded-lg lg:bg-[#2d63e2] lg:px-3.5 lg:py-2 lg:text-sm lg:shadow-[0_4px_12px_rgba(45,99,226,0.28)] lg:hover:bg-[#2556c7]" ? "inline-flex h-8 items-center justify-center rounded-md bg-[#2d63e2] px-3 text-sm font-bold text-white shadow-[0_3px_8px_rgba(45,99,226,0.24)] transition-colors hover:bg-[#2556c7] disabled:opacity-60 lg:h-auto lg:min-w-[4.5rem] lg:px-3.5 lg:py-2"
: "inline-flex h-8 items-center justify-center rounded-full border border-[#dce7f7] bg-white px-3 text-xs font-bold text-[#32518d] transition-colors hover:bg-[#f8fbff] lg:h-auto lg:min-w-[4.5rem] lg:rounded-lg lg:border-0 lg:bg-transparent lg:px-3.5 lg:py-2 lg:text-sm lg:text-[#5b7fbf] lg:hover:bg-[#f3f7ff] lg:hover:text-[#2d63e2]" : "inline-flex h-8 items-center justify-center rounded-md px-3 text-sm font-bold text-[#5b7fbf] transition-colors hover:bg-[#f3f7ff] hover:text-[#2d63e2] lg:h-auto lg:min-w-[4.5rem] lg:px-3.5 lg:py-2"
} }
> >
{f.label} {f.label}
</button> </button>
); );
})} })}
</div>
</div> </div>
</div> </div>

View File

@@ -498,7 +498,7 @@ export function WalletScreen() {
</div> </div>
</div> </div>
</section> </section>
<div className="mt-3 grid grid-cols-2 gap-3 lg:mt-0 lg:grid-cols-1 lg:gap-3"> <aside className="mt-3 grid grid-cols-2 gap-3 lg:mt-0 lg:grid-cols-1 lg:grid-rows-2 lg:rounded-xl lg:border lg:border-[#dce7f7] lg:bg-[#f8fbff] lg:p-3">
<TransferInDialog <TransferInDialog
idPrefix="wallet-" idPrefix="wallet-"
currency={currency} currency={currency}
@@ -511,7 +511,7 @@ export function WalletScreen() {
onSuccess={refreshAll} onSuccess={refreshAll}
triggerVariant="hall" triggerVariant="hall"
triggerLabel={t("wallet.transferIn", { defaultValue: "Transfer In" })} triggerLabel={t("wallet.transferIn", { defaultValue: "Transfer In" })}
triggerClassName="h-14 rounded-2xl text-base font-black lg:h-full lg:min-h-[4.5rem]" triggerClassName="h-10 rounded-lg text-sm font-bold lg:h-full lg:min-h-0 lg:rounded-xl lg:text-lg lg:font-black"
open={transferInOpen} open={transferInOpen}
onOpenChange={setTransferInOpen} onOpenChange={setTransferInOpen}
/> />
@@ -522,11 +522,11 @@ export function WalletScreen() {
onSuccess={refreshAll} onSuccess={refreshAll}
triggerVariant="hall" triggerVariant="hall"
triggerLabel={t("wallet.transferOut", { defaultValue: "Transfer Out" })} triggerLabel={t("wallet.transferOut", { defaultValue: "Transfer Out" })}
triggerClassName="h-14 rounded-2xl text-base font-black lg:h-full lg:min-h-[4.5rem]" triggerClassName="h-10 rounded-lg border-[#cddbf1] bg-white text-sm font-bold text-[#0b3f96] shadow-none hover:bg-[#f1f6ff] lg:h-full lg:min-h-0 lg:rounded-xl lg:text-lg lg:font-black"
open={transferOutOpen} open={transferOutOpen}
onOpenChange={setTransferOutOpen} onOpenChange={setTransferOutOpen}
/> />
</div> </aside>
</div> </div>
) : ( ) : (
<div className="space-y-3 lg:grid lg:grid-cols-[minmax(0,1.1fr)_minmax(20rem,0.9fr)] lg:items-stretch lg:gap-4 lg:space-y-0"> <div className="space-y-3 lg:grid lg:grid-cols-[minmax(0,1.1fr)_minmax(20rem,0.9fr)] lg:items-stretch lg:gap-4 lg:space-y-0">

View File

@@ -36,6 +36,8 @@ export function TransferInDialog({
triggerClassName, triggerClassName,
triggerVariant = "wallet", triggerVariant = "wallet",
triggerLabel, triggerLabel,
triggerDescription,
triggerIconOnly = false,
open: controlledOpen, open: controlledOpen,
onOpenChange: controlledOnOpenChange, onOpenChange: controlledOnOpenChange,
}: BaseProps & { }: BaseProps & {
@@ -44,6 +46,8 @@ export function TransferInDialog({
triggerClassName?: string; triggerClassName?: string;
triggerVariant?: "wallet" | "hall"; triggerVariant?: "wallet" | "hall";
triggerLabel?: string; triggerLabel?: string;
triggerDescription?: string;
triggerIconOnly?: boolean;
}) { }) {
const [uncontrolledOpen, setUncontrolledOpen] = useState(false); const [uncontrolledOpen, setUncontrolledOpen] = useState(false);
const isControlled = controlledOpen !== undefined; const isControlled = controlledOpen !== undefined;
@@ -72,9 +76,20 @@ export function TransferInDialog({
triggerClassName, triggerClassName,
)} )}
onClick={() => setOpen(true)} onClick={() => setOpen(true)}
aria-label={triggerIconOnly ? resolvedTriggerLabel : undefined}
title={triggerIconOnly ? resolvedTriggerLabel : undefined}
> >
<ArrowDownLeft className="size-4 shrink-0" /> <ArrowDownLeft className="size-4 shrink-0" />
{resolvedTriggerLabel} {triggerIconOnly ? (
<span className="sr-only">{resolvedTriggerLabel}</span>
) : triggerDescription ? (
<span className="flex min-w-0 flex-col text-left">
<span>{resolvedTriggerLabel}</span>
<span className="mt-0.5 text-xs font-medium opacity-75">{triggerDescription}</span>
</span>
) : (
resolvedTriggerLabel
)}
</Button> </Button>
<DialogContent showCloseButton className="gap-0 overflow-hidden p-0 sm:max-w-md"> <DialogContent showCloseButton className="gap-0 overflow-hidden p-0 sm:max-w-md">
<DialogHeader className="space-y-1.5 border-b border-border px-4 py-3 text-left"> <DialogHeader className="space-y-1.5 border-b border-border px-4 py-3 text-left">
@@ -110,6 +125,8 @@ export function TransferOutDialog({
triggerClassName, triggerClassName,
triggerVariant = "wallet", triggerVariant = "wallet",
triggerLabel, triggerLabel,
triggerDescription,
triggerIconOnly = false,
open: controlledOpen, open: controlledOpen,
onOpenChange: controlledOnOpenChange, onOpenChange: controlledOnOpenChange,
}: BaseProps & { }: BaseProps & {
@@ -117,6 +134,8 @@ export function TransferOutDialog({
triggerClassName?: string; triggerClassName?: string;
triggerVariant?: "wallet" | "hall"; triggerVariant?: "wallet" | "hall";
triggerLabel?: string; triggerLabel?: string;
triggerDescription?: string;
triggerIconOnly?: boolean;
}) { }) {
const [uncontrolledOpen, setUncontrolledOpen] = useState(false); const [uncontrolledOpen, setUncontrolledOpen] = useState(false);
const isControlled = controlledOpen !== undefined; const isControlled = controlledOpen !== undefined;
@@ -145,9 +164,20 @@ export function TransferOutDialog({
triggerClassName, triggerClassName,
)} )}
onClick={() => setOpen(true)} onClick={() => setOpen(true)}
aria-label={triggerIconOnly ? resolvedTriggerLabel : undefined}
title={triggerIconOnly ? resolvedTriggerLabel : undefined}
> >
<ArrowUpRight className="size-4 shrink-0" /> <ArrowUpRight className="size-4 shrink-0" />
{resolvedTriggerLabel} {triggerIconOnly ? (
<span className="sr-only">{resolvedTriggerLabel}</span>
) : triggerDescription ? (
<span className="flex min-w-0 flex-col text-left">
<span>{resolvedTriggerLabel}</span>
<span className="mt-0.5 text-xs font-medium opacity-75">{triggerDescription}</span>
</span>
) : (
resolvedTriggerLabel
)}
</Button> </Button>
<DialogContent showCloseButton className="gap-0 overflow-hidden p-0 sm:max-w-md"> <DialogContent showCloseButton className="gap-0 overflow-hidden p-0 sm:max-w-md">
<DialogHeader className="space-y-1.5 border-b border-border px-4 py-3 text-left"> <DialogHeader className="space-y-1.5 border-b border-border px-4 py-3 text-left">

View File

@@ -155,7 +155,8 @@
"lineIssue": { "lineIssue": {
"invalid_number_length": "Row {{row}} «{{play}}»: invalid number length. Check the number field.", "invalid_number_length": "Row {{row}} «{{play}}»: invalid number length. Check the number field.",
"roll_requires_r": "Row {{row}} «{{play}}»: use 4 characters with at least one R (e.g. 12R4, RR34). R marks rolling digits.", "roll_requires_r": "Row {{row}} «{{play}}»: use 4 characters with at least one R (e.g. 12R4, RR34). R marks rolling digits.",
"missing_digit_slot": "Row {{row}} «{{play}}»: missing digit slot. Refresh and try again." "missing_digit_slot": "Row {{row}} «{{play}}»: missing digit slot. Refresh and try again.",
"full_cover_amount_not_divisible": "Row {{row}}: the Full cover amount for {{play}} must divide evenly across its combinations."
}, },
"previewFailed": "Preview failed", "previewFailed": "Preview failed",
"closedSubmit": "Closed. Cannot submit.", "closedSubmit": "Closed. Cannot submit.",
@@ -250,10 +251,14 @@
"half_play": "Half play" "half_play": "Half play"
}, },
"comboCount": "{{count}} combos", "comboCount": "{{count}} combos",
"selectionTypeRule": "View type rule",
"fullCoverSplitHint": "Total split across {{count}} combos",
"fullCoverDivisibilityError": "Amount must be a multiple of {{count}}",
"halfPlayHint": "Keeps the first two digits in this order",
"selectionConfirm": { "selectionConfirm": {
"title": "Confirm type change", "title": "Confirm type change",
"increaseBody": "This play ({{type}}) expands to about {{count}} combinations. Stake will increase from {{from}} to {{to}}. Continue?", "increaseBody": "This play ({{type}}) expands to about {{count}} combinations. Stake will increase from {{from}} to {{to}}. Continue?",
"fullCoverBody": "This play ({{type}}) covers about {{count}} combinations. Total stake {{amount}} will be split across them, with shared prize payout. Continue?", "fullCoverBody": "This play ({{type}}) covers about {{count}} combinations. Total stake {{amount}} is split equally across them, and payout uses each combination's stake. Continue?",
"genericBody": "This play ({{type}}) covers about {{count}} combinations and may raise the stake significantly. Continue?", "genericBody": "This play ({{type}}) covers about {{count}} combinations and may raise the stake significantly. Continue?",
"comboHint": "About {{count}} number permutations", "comboHint": "About {{count}} number permutations",
"numberHint": "Number: {{number}}", "numberHint": "Number: {{number}}",

View File

@@ -155,7 +155,8 @@
"lineIssue": { "lineIssue": {
"invalid_number_length": "पङ्क्ति {{row}} «{{play}}»: नम्बरको लम्बाइ मिलेन। नम्बर क्षेत्र जाँच गर्नुहोस्।", "invalid_number_length": "पङ्क्ति {{row}} «{{play}}»: नम्बरको लम्बाइ मिलेन। नम्बर क्षेत्र जाँच गर्नुहोस्।",
"roll_requires_r": "पङ्क्ति {{row}} «{{play}}»: 4 अक्षर र कम्तीमा एक R चाहिन्छ (जस्तै 12R4, RR34)। R = घुम्ने अंक।", "roll_requires_r": "पङ्क्ति {{row}} «{{play}}»: 4 अक्षर र कम्तीमा एक R चाहिन्छ (जस्तै 12R4, RR34)। R = घुम्ने अंक।",
"missing_digit_slot": "पङ्क्ति {{row}} «{{play}}»: अंक स्थान छुट्यो। रिफ्रेस गरी पुनः प्रयास गर्नुहोस्।" "missing_digit_slot": "पङ्क्ति {{row}} «{{play}}»: अंक स्थान छुट्यो। रिफ्रेस गरी पुनः प्रयास गर्नुहोस्।",
"full_cover_amount_not_divisible": "पङ्क्ति {{row}}: {{play}} को पूर्ण कभर रकम संयोजनमा पूरा बाँडिनुपर्छ।"
}, },
"previewFailed": "पूर्वावलोकन असफल", "previewFailed": "पूर्वावलोकन असफल",
"closedSubmit": "बन्द भयो। पेश गर्न सकिँदैन।", "closedSubmit": "बन्द भयो। पेश गर्न सकिँदैन।",
@@ -250,10 +251,14 @@
"half_play": "आधा खेल" "half_play": "आधा खेल"
}, },
"comboCount": "{{count}} संयोजन", "comboCount": "{{count}} संयोजन",
"selectionTypeRule": "प्रकार नियम हेर्नुहोस्",
"fullCoverSplitHint": "कुल रकम {{count}} संयोजनमा बाँडिन्छ",
"fullCoverDivisibilityError": "रकम {{count}} को गुणज हुनुपर्छ",
"halfPlayHint": "पहिला दुई अंकको क्रम राखिन्छ",
"selectionConfirm": { "selectionConfirm": {
"title": "प्रकार परिवर्तन पुष्टि गर्नुहोस्", "title": "प्रकार परिवर्तन पुष्टि गर्नुहोस्",
"increaseBody": "यो प्ले ({{type}}) ले करिब {{count}} संयोजन बनाउँछ। रकम {{from}} बाट {{to}} मा बढ्नेछ। जारी राख्ने?", "increaseBody": "यो प्ले ({{type}}) ले करिब {{count}} संयोजन बनाउँछ। रकम {{from}} बाट {{to}} मा बढ्नेछ। जारी राख्ने?",
"fullCoverBody": "यो प्ले ({{type}}) ले करिब {{count}} संयोजन समेट्छ। कुल रकम {{amount}} समूहमा बाँडिनछ। जारी राख्ने?", "fullCoverBody": "यो प्ले ({{type}}) ले करिब {{count}} संयोजन समेट्छ। कुल रकम {{amount}} सबै संयोजनमा बराबर बाँडिनछ। जारी राख्ने?",
"genericBody": "यो प्ले ({{type}}) ले करिब {{count}} संयोजन समेट्छ र रकम धेरै बढ्न सक्छ। जारी राख्ने?", "genericBody": "यो प्ले ({{type}}) ले करिब {{count}} संयोजन समेट्छ र रकम धेरै बढ्न सक्छ। जारी राख्ने?",
"comboHint": "करिब {{count}} नम्बर संयोजन", "comboHint": "करिब {{count}} नम्बर संयोजन",
"numberHint": "नम्बर: {{number}}", "numberHint": "नम्बर: {{number}}",

View File

@@ -155,7 +155,8 @@
"lineIssue": { "lineIssue": {
"invalid_number_length": "第 {{row}} 行「{{play}}」号码位数不正确,请检查号码列。", "invalid_number_length": "第 {{row}} 行「{{play}}」号码位数不正确,请检查号码列。",
"roll_requires_r": "第 {{row}} 行「{{play}}」须为 4 位且含 R如 12R4、RR34R 表示滚动位。", "roll_requires_r": "第 {{row}} 行「{{play}}」须为 4 位且含 R如 12R4、RR34R 表示滚动位。",
"missing_digit_slot": "第 {{row}} 行「{{play}}」缺少位数,请刷新后重试。" "missing_digit_slot": "第 {{row}} 行「{{play}}」缺少位数,请刷新后重试。",
"full_cover_amount_not_divisible": "第 {{row}} 行「{{play}}」使用全保时,金额须能被组合数整除。"
}, },
"previewFailed": "预览失败", "previewFailed": "预览失败",
"closedSubmit": "已封盘,无法提交。", "closedSubmit": "已封盘,无法提交。",
@@ -249,10 +250,14 @@
"half_play": "半打" "half_play": "半打"
}, },
"comboCount": "共{{count}}组", "comboCount": "共{{count}}组",
"selectionTypeRule": "查看种类规则",
"fullCoverSplitHint": "总额均分至{{count}}组",
"fullCoverDivisibilityError": "金额须为{{count}}的整数倍",
"halfPlayHint": "按前两位先后顺序组合",
"selectionConfirm": { "selectionConfirm": {
"title": "确认切换种类", "title": "确认切换种类",
"increaseBody": "此玩法({{type}})会增加投注组合数量(约 {{count}} 组),投注金额将从 {{from}} 增加至 {{to}},是否继续?", "increaseBody": "此玩法({{type}})会增加投注组合数量(约 {{count}} 组),投注金额将从 {{from}} 增加至 {{to}},是否继续?",
"fullCoverBody": "此玩法({{type}})将覆盖约 {{count}} 组号码总投注 {{amount}} 将平摊到各组,中奖派彩按组合分摊。是否继续?", "fullCoverBody": "此玩法({{type}})将覆盖约 {{count}} 组号码总投注 {{amount}} 会均分至每组,中奖派彩也按每组金额计算。是否继续?",
"genericBody": "此玩法({{type}})将覆盖约 {{count}} 组号码,投注金额可能显著增加,是否继续?", "genericBody": "此玩法({{type}})将覆盖约 {{count}} 组号码,投注金额可能显著增加,是否继续?",
"comboHint": "包含约 {{count}} 组号码排列", "comboHint": "包含约 {{count}} 组号码排列",
"numberHint": "号码:{{number}}", "numberHint": "号码:{{number}}",