Some checks failed
lotteryfront CI / build (push) Has been cancelled
- 引入默认草稿行数常量及批量生成草稿行函数
- 根据玩法类别调整号码最大输入长度和清洗规则
- 更新号码输入组件以适应动态最大长度和格式规范
- 修正相关回调依赖,确保玩法类别变更时生效
- 改善行状态、占位符和样式传递,增强代码可读性
feat(results): 添加多提供商开奖结果切换功能
- 新增状态管理并展示多提供商按钮切换激活项
- 根据选中的提供商动态展示对应开奖结果数据
- 细化UI显示,支持多提供商名称并列渲染
feat(results): 在结果列表中显示多提供商名称
- 当存在多个提供商结果时展示其名称或代码
- 改善界面信息丰富度,方便用户辨识来源
feat(wallet): 支持账期还款释额类型的流水显示
- 调整账期流水类型文案,精准反映释额含义
- 引入账期时区参数以支持多时区时间格式化
- 自定义信用盘账期释额流水的颜色和文案显示
- 在流水明细和列表中根据时区格式化展示时间
feat(wallet): 支持钱包账期账单区间时区格式化
- 允许传入账期时区参数格式化账单区间时间
- 在账期账单区间显示组件中显示对应时区格式
- 根据玩家配置自动获取并传递账期时区信息
- 条件渲染中信用盘区块支持账期时区参数传递
chore(i18n): 更新中文钱包模块多处文案
- 新增“释额 {{amount}}”等账期释额相关文案
- 修改“账期收款”为“账期还款释额”以符合业务描述
refactor(types): 扩展开奖结果支持多提供商结构
- 新增DrawResultProviderRow类型定义以描述提供商结果
- 在DrawResultListItem中增加provider_results字段支持多结果
- 在玩家信息类型中新增settlement_timezone字段表示账期时区
320 lines
12 KiB
TypeScript
320 lines
12 KiB
TypeScript
"use client";
|
||
|
||
import { useCallback, useEffect, useState } from "react";
|
||
import { useSearchParams } from "next/navigation";
|
||
import { useTranslation } from "react-i18next";
|
||
|
||
import { getDrawResultByNo } from "@/api/draw";
|
||
import { getTicketDrawMyMatch } from "@/api/ticket-items";
|
||
import Link from "next/link";
|
||
import { Button, buttonVariants } from "@/components/ui/button";
|
||
import { PlayerPanel } from "@/components/layout/player-panel";
|
||
import {
|
||
Card,
|
||
CardContent,
|
||
CardDescription,
|
||
CardHeader,
|
||
CardTitle,
|
||
} from "@/components/ui/card";
|
||
import { Skeleton } from "@/components/ui/skeleton";
|
||
import { DrawWinningCheckPanel } from "@/features/results/draw-winning-check-panel";
|
||
import { JackpotResultsStrip } from "@/features/results/jackpot-results-strip";
|
||
import { TwentyThreeResultsGrid } from "@/features/results/twenty-three-results-grid";
|
||
import { useCurrencyCatalog } from "@/hooks/use-currency-catalog";
|
||
import { getPlayerBearerTokenPayload } from "@/lib/lottery-auth";
|
||
import { formatPlayerInstant } from "@/lib/player-datetime";
|
||
import { formatMinorAsCurrency } from "@/lib/money";
|
||
import { isCreditFundingPlayer } from "@/lib/player-funding-mode";
|
||
import { norm4d } from "@/lib/norm-4d";
|
||
import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
|
||
import { resultsPrizeLabelKey, RESULTS_TOP_PRIZE_KEYS } from "@/lib/results-prize-labels";
|
||
import { cn } from "@/lib/utils";
|
||
import { usePlayerSessionStore } from "@/stores/player-session-store";
|
||
import type { DrawResultDetailPayload } from "@/types/api/draw-results";
|
||
|
||
type DrawResultDetailScreenProps = {
|
||
drawNo: string;
|
||
};
|
||
|
||
/** §4.6 开奖结果详情:23 分区 + [< >] 切换 + 本人命中高亮 + Jackpot */
|
||
export function DrawResultDetailScreen({ drawNo }: DrawResultDetailScreenProps) {
|
||
const { t } = useTranslation("player");
|
||
const searchParams = useSearchParams();
|
||
const checkOpen = searchParams.get("check") === "1";
|
||
const { activeCurrency } = useActivePlayerCurrency();
|
||
const creditMode = isCreditFundingPlayer(usePlayerSessionStore((state) => state.profile));
|
||
useCurrencyCatalog();
|
||
const [data, setData] = useState<DrawResultDetailPayload | null>(null);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [loading, setLoading] = useState(true);
|
||
const [activeProviderCode, setActiveProviderCode] = useState<string>("");
|
||
const [highlightSet, setHighlightSet] = useState<ReadonlySet<string> | null>(null);
|
||
const [myTotals, setMyTotals] = useState<{
|
||
win: number;
|
||
jackpot: number;
|
||
hasBets: boolean;
|
||
} | null>(null);
|
||
|
||
const load = useCallback(async () => {
|
||
setLoading(true);
|
||
setError(null);
|
||
try {
|
||
const row = await getDrawResultByNo(drawNo, { currency: activeCurrency });
|
||
setData(row);
|
||
setActiveProviderCode(String(row.provider_code ?? row.provider_results?.[0]?.provider_code ?? ""));
|
||
} catch {
|
||
setData(null);
|
||
setError(t("results.unavailable"));
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [activeCurrency, drawNo, t]);
|
||
|
||
useEffect(() => {
|
||
queueMicrotask(() => {
|
||
void load();
|
||
});
|
||
}, [load]);
|
||
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
queueMicrotask(() => {
|
||
if (!data) {
|
||
setHighlightSet(null);
|
||
setMyTotals(null);
|
||
return;
|
||
}
|
||
const token = getPlayerBearerTokenPayload();
|
||
if (!token) {
|
||
setHighlightSet(new Set());
|
||
setMyTotals(null);
|
||
return;
|
||
}
|
||
void (async () => {
|
||
try {
|
||
const m = await getTicketDrawMyMatch(data.draw_no);
|
||
if (cancelled) return;
|
||
setHighlightSet(new Set(m.hit_numbers_4d.map((n) => norm4d(n))));
|
||
setMyTotals({
|
||
win: m.total_win_minor,
|
||
jackpot: m.total_jackpot_win_minor,
|
||
hasBets: m.has_bets,
|
||
});
|
||
} catch {
|
||
if (!cancelled) {
|
||
setHighlightSet(new Set());
|
||
setMyTotals(null);
|
||
}
|
||
}
|
||
})();
|
||
});
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [data]);
|
||
|
||
if (loading) {
|
||
return (
|
||
<PlayerPanel
|
||
title={t("results.detailTitle")}
|
||
backHref="/results"
|
||
backLabel={t("results.title")}
|
||
>
|
||
<div className="space-y-3">
|
||
<Skeleton className="h-12 rounded-xl" />
|
||
<Skeleton className="h-56 rounded-xl" />
|
||
</div>
|
||
</PlayerPanel>
|
||
);
|
||
}
|
||
|
||
if (error || !data) {
|
||
return (
|
||
<PlayerPanel
|
||
title={t("results.detailTitle")}
|
||
backHref="/results"
|
||
backLabel={t("results.title")}
|
||
>
|
||
<div className="rounded-xl border border-red-200 bg-red-50 px-3 py-3 text-sm text-red-700">
|
||
<p>{error ?? t("results.noData")}</p>
|
||
<Button type="button" size="sm" variant="secondary" onClick={() => void load()}>
|
||
{t("actions.retry")}
|
||
</Button>
|
||
</div>
|
||
</PlayerPanel>
|
||
);
|
||
}
|
||
|
||
const currency = data.jackpot?.currency_code ?? activeCurrency;
|
||
const providerResults = data.provider_results ?? [];
|
||
const activeProviderResult =
|
||
providerResults.find((row) => String(row.provider_code ?? "") === activeProviderCode)
|
||
?? providerResults[0]
|
||
?? null;
|
||
const activeResults = activeProviderResult?.results ?? data.results;
|
||
const showMyPayout =
|
||
myTotals &&
|
||
myTotals.hasBets &&
|
||
(myTotals.win > 0 || myTotals.jackpot > 0);
|
||
const showHitOnly =
|
||
myTotals?.hasBets &&
|
||
highlightSet &&
|
||
highlightSet.size > 0 &&
|
||
myTotals &&
|
||
myTotals.win === 0 &&
|
||
myTotals.jackpot === 0;
|
||
|
||
return (
|
||
<PlayerPanel
|
||
title={t("results.detailTitle")}
|
||
backHref="/results"
|
||
backLabel={t("results.title")}
|
||
>
|
||
<div className="flex flex-col gap-3">
|
||
<JackpotResultsStrip currencyCode={currency} />
|
||
|
||
<Card className="overflow-hidden border-[#e5edf8] bg-white shadow-[0_10px_28px_rgba(15,23,42,0.06)]">
|
||
<CardHeader className="space-y-3 border-b border-[#edf2f9] bg-[#f8fbff] pb-3">
|
||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||
{data.previous_draw_no ? (
|
||
<Link
|
||
href={`/results/${encodeURIComponent(data.previous_draw_no)}`}
|
||
prefetch={false}
|
||
className={cn(
|
||
buttonVariants({ variant: "outline", size: "sm" }),
|
||
"min-w-[5rem] rounded-full border-[#dce7f7] bg-white text-[#0b56b7] 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 className="flex min-w-0 flex-1 flex-col items-center text-center">
|
||
<CardTitle className="truncate font-mono text-lg font-black text-[#0b3f96]">
|
||
{data.draw_no}
|
||
</CardTitle>
|
||
<CardDescription className="mt-1 font-mono text-xs text-slate-500">
|
||
{t("results.drawTime", {
|
||
time: formatPlayerInstant(data.draw_time_iso ?? data.draw_time ?? null),
|
||
})}
|
||
</CardDescription>
|
||
</div>
|
||
{data.next_draw_no ? (
|
||
<Link
|
||
href={`/results/${encodeURIComponent(data.next_draw_no)}`}
|
||
prefetch={false}
|
||
className={cn(
|
||
buttonVariants({ variant: "outline", size: "sm" }),
|
||
"min-w-[5rem] rounded-full border-[#dce7f7] bg-white text-[#0b56b7] hover:bg-[#f1f6ff]",
|
||
)}
|
||
>
|
||
{t("results.next")}
|
||
</Link>
|
||
) : (
|
||
<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>
|
||
</CardHeader>
|
||
<CardContent className="space-y-3 pt-3">
|
||
<div className="rounded-xl border border-[#e8eef7] bg-[#f8fbff] p-3 shadow-[0_4px_14px_rgba(15,23,42,0.04)]">
|
||
<div className="flex items-center justify-between gap-2">
|
||
<p className="text-sm font-black text-[#0b3f96]">
|
||
{t("results.detailTitle")}
|
||
</p>
|
||
<span className="rounded-full bg-[#f2f6ff] px-2.5 py-1 text-xs font-bold text-[#0b56b7]">
|
||
{t("results.detail")}
|
||
</span>
|
||
</div>
|
||
{providerResults.length > 1 ? (
|
||
<div className="mt-3 flex flex-wrap gap-2">
|
||
{providerResults.map((row) => {
|
||
const code = String(row.provider_code ?? "");
|
||
const active = code === String(activeProviderResult?.provider_code ?? "");
|
||
return (
|
||
<button
|
||
key={code || row.provider_name || row.result_version}
|
||
type="button"
|
||
onClick={() => setActiveProviderCode(code)}
|
||
className={cn(
|
||
"rounded-full border px-3 py-1 text-xs font-bold transition-colors",
|
||
active
|
||
? "border-[#0b56b7] bg-[#0b56b7] text-white"
|
||
: "border-[#dce7f7] bg-white text-[#0b56b7] hover:bg-[#f1f6ff]",
|
||
)}
|
||
>
|
||
{row.provider_name || row.provider_code || "Provider"}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
) : null}
|
||
<div className="mt-3 grid grid-cols-3 gap-2 text-center">
|
||
{RESULTS_TOP_PRIZE_KEYS.map((tier) => (
|
||
<div
|
||
key={tier}
|
||
className="rounded-lg border border-[#edf2f8] bg-white py-2 shadow-[0_4px_12px_rgba(15,23,42,0.03)]"
|
||
>
|
||
<p className="text-[11px] font-bold text-[#7890b8]">
|
||
{t(resultsPrizeLabelKey(tier))}
|
||
</p>
|
||
<p className="mt-1 font-mono text-lg font-black tabular-nums text-[#e5002c]">
|
||
{activeResults[tier]}
|
||
</p>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<TwentyThreeResultsGrid
|
||
numbers={activeResults}
|
||
highlighted4d={highlightSet ?? undefined}
|
||
/>
|
||
|
||
{showMyPayout && myTotals ? (
|
||
<div className="rounded-xl border border-emerald-200 bg-emerald-50 px-3 py-3 text-sm shadow-[0_4px_14px_rgba(15,23,42,0.03)]">
|
||
<p className="font-bold text-emerald-900">
|
||
{t(creditMode ? "results.creditMyWin" : "results.myPayout")}
|
||
</p>
|
||
<p className="mt-2 font-mono text-xs tabular-nums text-emerald-900/80">
|
||
{t("results.regular", {
|
||
amount: formatMinorAsCurrency(myTotals.win, currency),
|
||
})}
|
||
{myTotals.jackpot > 0 ? (
|
||
<>
|
||
{" "}
|
||
·{" "}
|
||
{t("results.jackpot", {
|
||
amount: formatMinorAsCurrency(myTotals.jackpot, currency),
|
||
})}
|
||
</>
|
||
) : null}
|
||
</p>
|
||
</div>
|
||
) : null}
|
||
|
||
{showHitOnly ? (
|
||
<div className="rounded-xl border border-amber-200 bg-amber-50 px-3 py-3 text-xs text-amber-950">
|
||
{t(creditMode ? "results.creditHitPending" : "results.hitPending")}
|
||
</div>
|
||
) : null}
|
||
|
||
<DrawWinningCheckPanel
|
||
key={checkOpen ? `${data.draw_no}:check` : data.draw_no}
|
||
drawNo={data.draw_no}
|
||
businessDate={data.business_date}
|
||
drawTimeIso={data.draw_time_iso}
|
||
drawTime={data.draw_time}
|
||
defaultOpen={checkOpen}
|
||
/>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
</PlayerPanel>
|
||
);
|
||
}
|