Files
lotteryFront/src/features/results/draw-result-detail-screen.tsx
kang 56ba12fbfc
Some checks failed
lotteryfront CI / build (push) Has been cancelled
refactor(hall): 优化投注格子行数与号码输入逻辑
- 引入默认草稿行数常量及批量生成草稿行函数
- 根据玩法类别调整号码最大输入长度和清洗规则
- 更新号码输入组件以适应动态最大长度和格式规范
- 修正相关回调依赖,确保玩法类别变更时生效
- 改善行状态、占位符和样式传递,增强代码可读性

feat(results): 添加多提供商开奖结果切换功能

- 新增状态管理并展示多提供商按钮切换激活项
- 根据选中的提供商动态展示对应开奖结果数据
- 细化UI显示,支持多提供商名称并列渲染

feat(results): 在结果列表中显示多提供商名称

- 当存在多个提供商结果时展示其名称或代码
- 改善界面信息丰富度,方便用户辨识来源

feat(wallet): 支持账期还款释额类型的流水显示

- 调整账期流水类型文案,精准反映释额含义
- 引入账期时区参数以支持多时区时间格式化
- 自定义信用盘账期释额流水的颜色和文案显示
- 在流水明细和列表中根据时区格式化展示时间

feat(wallet): 支持钱包账期账单区间时区格式化

- 允许传入账期时区参数格式化账单区间时间
- 在账期账单区间显示组件中显示对应时区格式
- 根据玩家配置自动获取并传递账期时区信息
- 条件渲染中信用盘区块支持账期时区参数传递

chore(i18n): 更新中文钱包模块多处文案

- 新增“释额 {{amount}}”等账期释额相关文案
- 修改“账期收款”为“账期还款释额”以符合业务描述

refactor(types): 扩展开奖结果支持多提供商结构

- 新增DrawResultProviderRow类型定义以描述提供商结果
- 在DrawResultListItem中增加provider_results字段支持多结果
- 在玩家信息类型中新增settlement_timezone字段表示账期时区
2026-07-09 15:48:36 +08:00

320 lines
12 KiB
TypeScript
Raw Blame History

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