refactor: 更新风险监控页面标题为国际化支持,优化风险池控制台的标题处理

This commit is contained in:
2026-05-22 16:11:36 +08:00
parent 51891548a5
commit 2d4a23968e
29 changed files with 491 additions and 94 deletions

View File

@@ -25,6 +25,7 @@ import { useAdminProfile } from "@/stores/admin-session";
import { cn } from "@/lib/utils";
import { drawResultSourceLabel, drawStatusLabel } from "./draw-display";
import { DrawStatusBadge } from "./draw-status-badge";
import {
PRD_DRAW_REOPEN_MANAGE,
@@ -33,12 +34,6 @@ import {
PRD_PAYOUT_REVIEW,
} from "./draw-prd";
function drawStatusText(status: string, t: (key: string) => string): string {
const key = `statusOptions.${status}`;
const translated = t(key);
return translated === key ? status : translated;
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="grid gap-1 sm:grid-cols-[10rem_1fr] sm:items-start">
@@ -123,13 +118,13 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) {
<div className="flex flex-col items-end gap-1 text-right">
<DrawStatusBadge
status={data.status}
label={drawStatusText(data.status, t)}
label={drawStatusLabel(data.status, t)}
/>
<p className="flex flex-wrap items-center justify-end gap-2 text-sm text-muted-foreground">
<span>{t("hallPreviewStatus", { status: "" }).replace(/\{\{status\}\}/, "").replace(/\s+$/, "")}</span>
<span>{t("hallPreviewStatusLabel")}</span>
<DrawStatusBadge
status={data.hall_preview_status}
label={drawStatusText(data.hall_preview_status, t)}
label={drawStatusLabel(data.hall_preview_status, t)}
/>
</p>
</div>
@@ -147,7 +142,7 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) {
</div>
<Separator />
<div className="grid gap-4 sm:grid-cols-2">
<Field label={t("resultSource")}>{data.result_source ?? "—"}</Field>
<Field label={t("resultSource")}>{drawResultSourceLabel(data.result_source, t)}</Field>
<Field label={t("currentResultVersion")}>{data.current_result_version}</Field>
<Field label={t("settleVersion")}>{data.settle_version}</Field>
<Field label={t("isReopened")}>{data.is_reopened ? t("yes") : t("no")}</Field>

View File

@@ -0,0 +1,62 @@
type DrawTranslate = (
key: string,
options?: { ns?: string; index?: number },
) => string;
/** 期号状态文案draws.statusOptions */
export function drawStatusLabel(status: string, t: DrawTranslate): string {
const key = `statusOptions.${status}`;
const translated = t(key, { ns: "draws" });
return translated === key ? status : translated;
}
/** 开奖结果来源draws.resultSourceOptions */
export function drawResultSourceLabel(
source: string | null | undefined,
t: DrawTranslate,
): string {
if (source == null || source === "") {
return "—";
}
const key = `resultSourceOptions.${source}`;
const translated = t(key, { ns: "draws" });
return translated === key ? source : translated;
}
/** 开奖结果批次状态draws.batchStatusOptions */
export function drawBatchStatusLabel(status: string, t: DrawTranslate): string {
const key = `batchStatusOptions.${status}`;
const translated = t(key, { ns: "draws" });
return translated === key ? status : translated;
}
/** 结算批次状态settlement.statusOptions */
export function settlementBatchStatusLabel(status: string, t: DrawTranslate): string {
const key = `statusOptions.${status}`;
const translated = t(key, { ns: "settlement" });
return translated === key ? status : translated;
}
/** 奖项类型 + 序号 → 展示名draws.resultSlots */
export function drawPrizeTypeLabel(
prizeType: string,
prizeIndex: number,
t: DrawTranslate,
): string {
if (prizeType === "first") {
return t("resultSlots.first", { ns: "draws" });
}
if (prizeType === "second") {
return t("resultSlots.second", { ns: "draws" });
}
if (prizeType === "third") {
return t("resultSlots.third", { ns: "draws" });
}
if (prizeType === "starter") {
return t("resultSlots.starter", { ns: "draws", index: prizeIndex + 1 });
}
if (prizeType === "consolation") {
return t("resultSlots.consolation", { ns: "draws", index: prizeIndex + 1 });
}
return prizeType;
}

View File

@@ -26,18 +26,16 @@ import { LotteryApiBizError } from "@/types/api/errors";
import type { AdminDrawFinanceSummaryData } from "@/types/api/admin-draw-finance";
import { toast } from "sonner";
import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog";
import { useExportLabels } from "@/hooks/use-export-labels";
import { formatAdminMinorUnits } from "@/lib/money";
import { drawStatusLabel, settlementBatchStatusLabel } from "./draw-display";
import { PRD_PAYOUT_MANAGE, PRD_PAYOUT_REVIEW } from "./draw-prd";
function drawStatusText(status: string, t: (key: string) => string): string {
const key = `statusOptions.${status}`;
const translated = t(key);
return translated === key ? status : translated;
}
export function DrawFinanceConsole({ drawId }: { drawId: string }): React.ReactElement {
const { t } = useTranslation(["draws", "common"]);
const { t } = useTranslation(["draws", "settlement", "common"]);
useAdminCurrencyCatalog();
const idNum = Number(drawId);
const profile = useAdminProfile();
const canRunSettlement = adminHasAnyPermission(profile?.permissions, [
@@ -96,6 +94,9 @@ export function DrawFinanceConsole({ drawId }: { drawId: string }): React.ReactE
return <p className="text-destructive text-sm">{err ?? t("states.noData", { ns: "common" })}</p>;
}
const currencyCode = data.currency_code ?? "NPR";
const formatMoney = (minor: number) => formatAdminMinorUnits(minor, currencyCode);
return (
<div className="space-y-6">
<Card>
@@ -110,7 +111,7 @@ export function DrawFinanceConsole({ drawId }: { drawId: string }): React.ReactE
<div>
<span className="text-muted-foreground">{t("status")}</span>
<p className="mt-1">
<DrawStatusBadge status={data.draw_status} label={drawStatusText(data.draw_status, t)} />
<DrawStatusBadge status={data.draw_status} label={drawStatusLabel(data.draw_status, t)} />
</p>
</div>
<div>
@@ -121,11 +122,11 @@ export function DrawFinanceConsole({ drawId }: { drawId: string }): React.ReactE
</div>
<div>
<span className="text-muted-foreground">{t("actualBet")}</span>
<p className="tabular-nums font-medium">{data.total_bet_minor}</p>
<p className="tabular-nums font-medium">{formatMoney(data.total_bet_minor)}</p>
</div>
<div>
<span className="text-muted-foreground">{t("currentPayout")}</span>
<p className="tabular-nums font-medium">{data.total_payout_minor}</p>
<p className="tabular-nums font-medium">{formatMoney(data.total_payout_minor)}</p>
</div>
<div>
<span className="text-muted-foreground">{t("grossProfit")}</span>
@@ -135,7 +136,7 @@ export function DrawFinanceConsole({ drawId }: { drawId: string }): React.ReactE
data.approx_house_gross_minor >= 0 ? "text-emerald-600" : "text-destructive",
)}
>
{data.approx_house_gross_minor}
{formatMoney(data.approx_house_gross_minor)}
</p>
</div>
</CardContent>
@@ -185,7 +186,7 @@ export function DrawFinanceConsole({ drawId }: { drawId: string }): React.ReactE
<TableHead className="text-right">{t("ticketCount")}</TableHead>
<TableHead className="text-right">{t("winCount")}</TableHead>
<TableHead className="text-right">{t("payoutTotal")}</TableHead>
<TableHead className="text-right">{t("jackpot")}</TableHead>
<TableHead className="text-right">{t("jackpotPayout")}</TableHead>
<TableHead>{t("finishedAt")}</TableHead>
</TableRow>
</TableHeader>
@@ -194,7 +195,9 @@ export function DrawFinanceConsole({ drawId }: { drawId: string }): React.ReactE
<TableRow key={b.id}>
<TableCell className="font-mono text-xs">{b.id}</TableCell>
<TableCell>
<AdminStatusBadge status={b.status}>{drawStatusText(b.status, t)}</AdminStatusBadge>
<AdminStatusBadge status={b.status}>
{settlementBatchStatusLabel(b.status, t)}
</AdminStatusBadge>
</TableCell>
<TableCell className="text-right tabular-nums text-xs">
{b.total_ticket_count}
@@ -203,10 +206,10 @@ export function DrawFinanceConsole({ drawId }: { drawId: string }): React.ReactE
{b.total_win_count}
</TableCell>
<TableCell className="text-right tabular-nums text-xs">
{b.total_payout_amount}
{formatMoney(b.total_payout_amount)}
</TableCell>
<TableCell className="text-right tabular-nums text-xs">
{b.total_jackpot_payout_amount}
{formatMoney(b.total_jackpot_payout_amount)}
</TableCell>
<TableCell className="font-mono text-[11px] text-muted-foreground">
{b.finished_at ?? "—"}

View File

@@ -23,6 +23,7 @@ import { useAdminProfile } from "@/stores/admin-session";
import { LotteryApiBizError } from "@/types/api/errors";
import type { AdminDrawBatchRow, AdminDrawBatchesData } from "@/types/api/admin-draws";
import { drawBatchStatusLabel, drawPrizeTypeLabel, drawStatusLabel } from "./draw-display";
import { PRD_DRAW_RESULT_MANAGE } from "./draw-prd";
export function DrawPublishConsole({ drawId, batchId }: { drawId: string; batchId: string }) {
@@ -73,7 +74,12 @@ export function DrawPublishConsole({ drawId, batchId }: { drawId: string; batchI
setPublishing(true);
try {
const res = await postAdminPublishResultBatch(idNum, batchNum);
toast.success(t("publishSuccess", { drawNo: res.draw_no, status: res.status }));
toast.success(
t("publishSuccess", {
drawNo: res.draw_no,
status: drawStatusLabel(res.status, t),
}),
);
await load();
} catch (e) {
const msg = e instanceof LotteryApiBizError ? e.message : t("publishFailed");
@@ -125,7 +131,9 @@ export function DrawPublishConsole({ drawId, batchId }: { drawId: string; batchI
{!canPublish && canManageDraw ? (
<Alert>
<AlertTitle>{t("cannotPublish")}</AlertTitle>
<AlertDescription>{t("cannotPublishDesc", { status: batch.status })}</AlertDescription>
<AlertDescription>
{t("cannotPublishDesc", { status: drawBatchStatusLabel(batch.status, t) })}
</AlertDescription>
</Alert>
) : null}
{canPublish ? (
@@ -147,7 +155,9 @@ export function DrawPublishConsole({ drawId, batchId }: { drawId: string; batchI
<TableBody>
{batch.items.map((it) => (
<TableRow key={`${it.prize_type}-${it.prize_index}`}>
<TableCell className="text-xs">{it.prize_type}</TableCell>
<TableCell className="text-xs">
{drawPrizeTypeLabel(it.prize_type, it.prize_index, t)}
</TableCell>
<TableCell className="font-mono text-xs">{it.prize_index}</TableCell>
<TableCell className="font-mono text-sm font-semibold">{it.number_4d}</TableCell>
</TableRow>

View File

@@ -21,6 +21,7 @@ import { useAdminProfile } from "@/stores/admin-session";
import { LotteryApiBizError } from "@/types/api/errors";
import type { AdminDrawBatchRow, AdminDrawBatchesData } from "@/types/api/admin-draws";
import { drawPrizeTypeLabel } from "./draw-display";
import { PRD_DRAW_RESULT_MANAGE } from "./draw-prd";
import { DrawStatusBadge } from "./draw-status-badge";
@@ -129,7 +130,9 @@ function BatchTable({ batch }: { batch: AdminDrawBatchRow }) {
<TableBody>
{batch.items.map((it) => (
<TableRow key={`${it.prize_type}-${it.prize_index}`}>
<TableCell className="text-xs">{it.prize_type}</TableCell>
<TableCell className="text-xs">
{drawPrizeTypeLabel(it.prize_type, it.prize_index, t)}
</TableCell>
<TableCell className="font-mono text-xs">{it.prize_index}</TableCell>
<TableCell className="font-mono text-sm font-semibold">{it.number_4d}</TableCell>
<TableCell className="hidden font-mono text-xs sm:table-cell">

View File

@@ -23,6 +23,7 @@ import { useAdminProfile } from "@/stores/admin-session";
import { LotteryApiBizError } from "@/types/api/errors";
import type { AdminDrawBatchesData } from "@/types/api/admin-draws";
import { drawStatusLabel } from "./draw-display";
import { PRD_DRAW_RESULT_MANAGE } from "./draw-prd";
import { DrawStatusBadge } from "./draw-status-badge";
@@ -129,13 +130,12 @@ export function DrawReviewConsole({ drawId }: { drawId: string }) {
<CardHeader>
<CardTitle className="text-lg">{t("manualResultEntry")}</CardTitle>
<p className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
{t("currentStatusAndDraft", {
status: data.draw_status,
}).split(data.draw_status)[0]}
<DrawStatusBadge status={data.draw_status} />
{t("currentStatusAndDraft", {
status: data.draw_status,
}).split(data.draw_status)[1] ?? ""}
<span>{t("currentStatusLabel")}</span>
<DrawStatusBadge
status={data.draw_status}
label={drawStatusLabel(data.draw_status, t)}
/>
<span>· {t("currentStatusDraftHint")}</span>
</p>
</CardHeader>
<CardContent className="space-y-4">

View File

@@ -27,7 +27,9 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog";
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
import { formatAdminMinorUnits } from "@/lib/money";
import { useExportLabels } from "@/hooks/use-export-labels";
import { adminHasAnyPermission } from "@/lib/admin-permissions";
import { cn } from "@/lib/utils";
@@ -35,6 +37,7 @@ import { useAdminProfile } from "@/stores/admin-session";
import { LotteryApiBizError } from "@/types/api/errors";
import type { AdminDrawListData, AdminDrawListItem } from "@/types/api/admin-draws";
import { drawStatusLabel } from "./draw-display";
import { PRD_DRAW_RESULT_MANAGE } from "./draw-prd";
import { DrawStatusBadge } from "./draw-status-badge";
@@ -67,7 +70,9 @@ function drawAdminStatusSelectLabel(raw: unknown, t: (key: string) => string): s
export function DrawsIndexConsole() {
const { t } = useTranslation(["draws", "common"]);
const exportLabels = useExportLabels("drawsList");
useAdminCurrencyCatalog();
const formatDt = useAdminDateTimeFormatter();
const defaultCurrency = "NPR";
const profile = useAdminProfile();
const canManageDraw = adminHasAnyPermission(profile?.permissions, [PRD_DRAW_RESULT_MANAGE]);
const [data, setData] = useState<AdminDrawListData | null>(null);
@@ -271,22 +276,28 @@ export function DrawsIndexConsole() {
<TableCell>
<DrawStatusBadge
status={row.status}
label={drawAdminStatusSelectLabel(row.status, t)}
label={drawStatusLabel(row.status, t)}
/>
</TableCell>
<TableCell className="text-right font-mono text-xs tabular-nums">
{row.total_bet_minor ?? "—"}
<TableCell className="text-right text-xs tabular-nums">
{row.total_bet_minor != null
? formatAdminMinorUnits(row.total_bet_minor, defaultCurrency)
: "—"}
</TableCell>
<TableCell className="text-right font-mono text-xs tabular-nums">
{row.total_payout_minor ?? "—"}
<TableCell className="text-right text-xs tabular-nums">
{row.total_payout_minor != null
? formatAdminMinorUnits(row.total_payout_minor, defaultCurrency)
: "—"}
</TableCell>
<TableCell
className={cn(
"text-right font-mono text-xs tabular-nums",
"text-right text-xs tabular-nums",
(row.profit_loss_minor ?? 0) < 0 ? "text-destructive" : "text-emerald-600",
)}
>
{row.profit_loss_minor ?? "—"}
{row.profit_loss_minor != null
? formatAdminMinorUnits(row.profit_loss_minor, defaultCurrency)
: "—"}
</TableCell>
<TableCell className="text-right">
<Link

View File

@@ -21,6 +21,12 @@ import {
import { getAdminAuditLogs } from "@/api/admin-audit";
import { getAdminPlayTypes } from "@/api/admin-config";
import { useAdminPlayCodeLabel, useAdminPlayTypeCatalog } from "@/hooks/use-admin-play-type-catalog";
import {
getAdminPlayTypesLoadPromise,
getCachedAdminPlayTypes,
resolveAdminPlayTypeDisplayName,
} from "@/lib/admin-play-types";
import { getAdminDraws, getAdminDrawFinanceSummary } from "@/api/admin-draws";
import { getAdminPlayers } from "@/api/admin-player";
import {
@@ -358,8 +364,10 @@ function resultRowCount(result: ReportResult | null): number {
}
export function ReportsConsole() {
const { t } = useTranslation(["reports", "common"]);
const { t, i18n } = useTranslation(["reports", "common"]);
useAdminCurrencyCatalog();
useAdminPlayTypeCatalog();
const playCodeLabel = useAdminPlayCodeLabel();
const formatTs = useAdminDateTimeFormatter();
const [selectedKey, setSelectedKey] = useState<ReportKey>(REPORTS[0].key);
const [filters, setFilters] = useState<ReportFilters>(emptyFilters);
@@ -388,17 +396,20 @@ export function ReportsConsole() {
const loadPlayOptions = useCallback(async () => {
try {
const payload = await getAdminPlayTypes();
await getAdminPlayTypesLoadPromise(getAdminPlayTypes);
setPlayOptions(
payload.items.map((item) => ({
getCachedAdminPlayTypes().map((item) => ({
code: item.play_code,
label: optionText(item.display_name_zh, item.play_code),
label: optionText(
resolveAdminPlayTypeDisplayName(item.play_code, i18n.language, item),
item.play_code,
),
})),
);
} catch {
setPlayOptions([]);
}
}, []);
}, [i18n.language]);
useEffect(() => {
void loadPlayOptions();
@@ -1056,7 +1067,7 @@ export function ReportsConsole() {
<TableCell className="font-mono text-xs">#{item.id}</TableCell>
<TableCell>{item.action_type}</TableCell>
<TableCell className="text-right">{formatPlainMoney(item.amount, result.raw.currency_code)}</TableCell>
<TableCell>{item.play_code || "-"}</TableCell>
<TableCell>{playCodeLabel(item.play_code)}</TableCell>
<TableCell>{item.ticket_no || "-"}</TableCell>
<TableCell>{item.player_id || "-"}</TableCell>
<TableCell>{item.source_reason || "-"}</TableCell>
@@ -1115,7 +1126,7 @@ export function ReportsConsole() {
if (result.key === "play_dimension") {
return result.raw.map((item) => (
<TableRow key={`${item.play_code}-${item.dimension}`}>
<TableCell className="font-medium">{item.play_code}</TableCell>
<TableCell className="font-medium">{playCodeLabel(item.play_code)}</TableCell>
<TableCell>{item.dimension}D</TableCell>
<TableCell className="text-right">{formatPlainMoney(item.total_bet_minor, "NPR")}</TableCell>
<TableCell className="text-right">{formatPlainMoney(item.total_payout_minor, "NPR")}</TableCell>
@@ -1130,7 +1141,7 @@ export function ReportsConsole() {
if (result.key === "rebate_commission") {
return result.raw.map((item) => (
<TableRow key={item.play_code}>
<TableCell className="font-medium">{item.play_code}</TableCell>
<TableCell className="font-medium">{playCodeLabel(item.play_code)}</TableCell>
<TableCell>{item.order_count}</TableCell>
<TableCell className="text-right">{formatPlainMoney(item.total_rebate_minor, "NPR")}</TableCell>
<TableCell className="text-right">{item.ticket_item_count}</TableCell>

View File

@@ -0,0 +1,27 @@
type RiskTranslate = (key: string, options?: { ns?: string }) => string;
/** 风控占用流水来源risk.sourceReasonOptions */
export function riskSourceReasonLabel(
reason: string | null | undefined,
t: RiskTranslate,
): string {
if (reason == null || reason === "") {
return "—";
}
const key = `sourceReasonOptions.${reason}`;
const translated = t(key, { ns: "risk" });
return translated === key ? reason : translated;
}
export type RiskPoolsPageTitleKey = "hotPageTitle" | "soldOutPageTitle" | "allPoolsPageTitle";
/** 锁定 / 释放动作risk.lock / risk.release */
export function riskActionTypeLabel(action: string, t: RiskTranslate): string {
if (action === "lock") {
return t("lock", { ns: "risk" });
}
if (action === "release") {
return t("release", { ns: "risk" });
}
return action;
}

View File

@@ -27,33 +27,30 @@ import {
TableRow,
} from "@/components/ui/table";
import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog";
import { useAdminPlayCodeLabel } from "@/hooks/use-admin-play-type-catalog";
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
import { riskActionTypeLabel, riskSourceReasonLabel } from "@/modules/risk/risk-display";
import { formatAdminMinorUnits } from "@/lib/money";
import { LotteryApiBizError } from "@/types/api/errors";
import type { AdminRiskLockLogListData, AdminRiskLockLogRow } from "@/types/api/admin-risk";
const ACTION_ALL = "__all__";
function riskActionLabel(
function riskActionFilterLabel(
value: string,
t: (key: string) => string,
): string {
if (value === ACTION_ALL) {
return t("noLimit");
}
if (value === "lock") {
return t("lock");
}
if (value === "release") {
return t("release");
}
return value;
return riskActionTypeLabel(value, t);
}
export function RiskLockLogsConsole({ drawId }: { drawId: number }) {
const { t } = useTranslation(["risk", "common"]);
const exportLabels = useExportLabels("riskLockLogs");
useAdminCurrencyCatalog();
const playCodeLabel = useAdminPlayCodeLabel();
const formatDt = useAdminDateTimeFormatter();
const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(10);
@@ -129,7 +126,7 @@ export function RiskLockLogsConsole({ drawId }: { drawId: number }) {
}}
>
<SelectTrigger id="risk-log-action" size="sm" className="w-full sm:w-40">
<SelectValue>{riskActionLabel(draftAction, t)}</SelectValue>
<SelectValue>{riskActionFilterLabel(draftAction, t)}</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value={ACTION_ALL}>{t("noLimit")}</SelectItem>
@@ -185,16 +182,16 @@ export function RiskLockLogsConsole({ drawId }: { drawId: number }) {
</TableCell>
<TableCell className="font-mono text-sm">{row.normalized_number}</TableCell>
<TableCell className="text-sm">
{riskActionLabel(row.action_type, t)}
{riskActionTypeLabel(row.action_type, t)}
</TableCell>
<TableCell className="text-right text-sm tabular-nums">
{formatAdminMinorUnits(row.amount, data?.currency_code ?? "NPR")}
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{row.source_reason ?? "—"}
{riskSourceReasonLabel(row.source_reason, t)}
</TableCell>
<TableCell className="font-mono text-xs">{row.ticket_no ?? "—"}</TableCell>
<TableCell className="text-xs">{row.play_code ?? "—"}</TableCell>
<TableCell className="text-xs">{playCodeLabel(row.play_code)}</TableCell>
</TableRow>
))}
</TableBody>

View File

@@ -18,8 +18,10 @@ import {
TableRow,
} from "@/components/ui/table";
import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog";
import { useAdminPlayCodeLabel } from "@/hooks/use-admin-play-type-catalog";
import { useExportLabels } from "@/hooks/use-export-labels";
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
import { riskActionTypeLabel, riskSourceReasonLabel } from "@/modules/risk/risk-display";
import { formatAdminMinorUnits } from "@/lib/money";
import { cn } from "@/lib/utils";
import { LotteryApiBizError } from "@/types/api/errors";
@@ -35,6 +37,7 @@ export function RiskPoolDetailConsole({
const { t } = useTranslation(["risk", "common"]);
const exportLabels = useExportLabels("riskPoolDetail", { number: number4d });
useAdminCurrencyCatalog();
const playCodeLabel = useAdminPlayCodeLabel();
const formatDt = useAdminDateTimeFormatter();
const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(10);
@@ -172,15 +175,15 @@ export function RiskPoolDetailConsole({
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
{row.created_at ? formatDt(row.created_at) : "—"}
</TableCell>
<TableCell className="text-sm">{row.action_type}</TableCell>
<TableCell className="text-sm">{riskActionTypeLabel(row.action_type, t)}</TableCell>
<TableCell className="text-right text-sm tabular-nums">
{formatAdminMinorUnits(row.amount, currencyCode)}
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{row.source_reason ?? "—"}
{riskSourceReasonLabel(row.source_reason, t)}
</TableCell>
<TableCell className="font-mono text-xs">{row.ticket_no ?? "—"}</TableCell>
<TableCell className="text-xs">{row.play_code ?? "—"}</TableCell>
<TableCell className="text-xs">{playCodeLabel(row.play_code)}</TableCell>
</TableRow>
))}
</TableBody>

View File

@@ -37,6 +37,7 @@ import { useExportLabels } from "@/hooks/use-export-labels";
import { formatAdminMinorUnits } from "@/lib/money";
import { cn } from "@/lib/utils";
import { LotteryApiBizError } from "@/types/api/errors";
import type { RiskPoolsPageTitleKey } from "@/modules/risk/risk-display";
import type { AdminRiskPoolListData, AdminRiskPoolRow } from "@/types/api/admin-risk";
const SORT_OPTIONS: { value: "usage_desc" | "locked_desc" | "remaining_asc" | "number_asc"; label: string }[] =
@@ -59,7 +60,9 @@ type RiskFilter = "all" | "sold_out" | "high_risk";
type RiskPoolsConsoleProps = {
drawId: number;
title: string;
/** @deprecated 优先使用 titleKey */
title?: string;
titleKey?: RiskPoolsPageTitleKey;
soldOutOnly: boolean;
defaultSort: "usage_desc" | "locked_desc" | "remaining_asc" | "number_asc";
allowSortChange?: boolean;
@@ -68,11 +71,13 @@ type RiskPoolsConsoleProps = {
export function RiskPoolsConsole({
drawId,
title,
titleKey,
soldOutOnly,
defaultSort,
allowSortChange = false,
}: RiskPoolsConsoleProps) {
const { t } = useTranslation(["risk", "common"]);
const pageTitle = titleKey ? t(titleKey) : (title ?? t("poolsTitle"));
const exportLabels = useExportLabels("riskPools");
useAdminCurrencyCatalog();
const [sort, setSort] = useState(defaultSort);
@@ -145,7 +150,7 @@ export function RiskPoolsConsole({
return (
<Card className="admin-list-card">
<CardHeader className="admin-list-header space-y-3">
<CardTitle className="admin-list-title">{title}</CardTitle>
<CardTitle className="admin-list-title">{pageTitle}</CardTitle>
<div className="admin-list-toolbar">
<div className="admin-list-field">
<Label htmlFor="risk-pool-number" className="sm:w-16 sm:shrink-0">
@@ -216,7 +221,7 @@ export function RiskPoolsConsole({
<div className="admin-list-actions">
<AdminTableExportButton
tableId={`risk-pools-table-${drawId}`}
filename={title ?? exportLabels.filename}
filename={pageTitle ?? exportLabels.filename}
sheetName={exportLabels.sheetName}
/>
</div>

View File

@@ -13,6 +13,7 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { LotteryApiBizError } from "@/types/api/errors";
const DRAW_GROUP = "draw";
@@ -24,10 +25,16 @@ const DRAW_KEYS = {
AUTO_SETTLEMENT: "settlement.auto_run_on_tick",
} as const;
const FRONTEND_GROUP = "frontend";
const FRONTEND_KEYS = {
PLAY_RULES_HTML: "frontend.play_rules_html",
} as const;
interface RuntimeDraft {
requireManualReview: boolean;
cooldownMinutes: string;
autoSettlement: boolean;
playRulesHtml: string;
}
function BinaryChoice({
@@ -75,11 +82,13 @@ export function SystemSettingsScreen() {
requireManualReview: false,
cooldownMinutes: "15",
autoSettlement: true,
playRulesHtml: "",
});
const [saved, setSaved] = useState<RuntimeDraft>({
requireManualReview: false,
cooldownMinutes: "15",
autoSettlement: true,
playRulesHtml: "",
});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
@@ -88,13 +97,14 @@ export function SystemSettingsScreen() {
const load = useCallback(async () => {
setLoading(true);
try {
const [drawRes, settlementRes] = await Promise.all([
const [drawRes, settlementRes, frontendRes] = await Promise.all([
getAdminSettings(DRAW_GROUP),
getAdminSettings(SETTLEMENT_GROUP),
getAdminSettings(FRONTEND_GROUP),
]);
const kv: Record<string, unknown> = {};
for (const item of [...drawRes.items, ...settlementRes.items]) {
for (const item of [...drawRes.items, ...settlementRes.items, ...frontendRes.items]) {
kv[item.key] = item.value;
}
@@ -102,6 +112,7 @@ export function SystemSettingsScreen() {
requireManualReview: Boolean(kv[DRAW_KEYS.REQUIRE_MANUAL_REVIEW] ?? false),
cooldownMinutes: String(kv[DRAW_KEYS.COOLDOWN_MINUTES] ?? 15),
autoSettlement: Boolean(kv[DRAW_KEYS.AUTO_SETTLEMENT] ?? true),
playRulesHtml: String(kv[FRONTEND_KEYS.PLAY_RULES_HTML] ?? ""),
};
setDraft(nextDraft);
setSaved(nextDraft);
@@ -133,6 +144,7 @@ export function SystemSettingsScreen() {
Math.max(0, Number.parseInt(draft.cooldownMinutes || "0", 10) || 0),
);
await updateAdminSetting(DRAW_KEYS.AUTO_SETTLEMENT, draft.autoSettlement);
await updateAdminSetting(FRONTEND_KEYS.PLAY_RULES_HTML, draft.playRulesHtml);
toast.success(t("system.saveSuccess", { ns: "config" }));
setSaved(draft);
setDirty(false);
@@ -233,6 +245,48 @@ export function SystemSettingsScreen() {
</div>
</section>
<section className="space-y-4 border-t border-border/60 pt-6">
<div className="space-y-1">
<h3 className="text-base font-semibold">{t("system.frontendConfig", { ns: "config", defaultValue: "前端配置" })}</h3>
</div>
<div className="space-y-5 rounded-2xl border border-border/60 bg-muted/10 px-4 py-4">
<div className="grid gap-2">
<Label htmlFor="play-rules-html" className="text-sm font-medium">
{t("system.fields.playRulesHtml", { ns: "config", defaultValue: "玩法规则 HTML 内容" })}
</Label>
<p className="text-xs text-muted-foreground">
{t("system.fields.playRulesHtmlDesc", { ns: "config", defaultValue: "该内容将直接在玩家端的玩法规则页面作为 HTML 渲染。留空则显示前端默认提示。" })}
</p>
<Textarea
id="play-rules-html"
value={draft.playRulesHtml}
onChange={(e) => updateDraft("playRulesHtml", e.target.value)}
disabled={loading || saving}
className="font-mono text-xs min-h-[200px]"
placeholder="<div>...</div>"
/>
</div>
<div className="flex items-center gap-4 pt-2">
<Button onClick={() => void handleSave()} disabled={!dirty || loading || saving}>
{saving ? t("saving", { ns: "adminUsers" }) : t("actions.save", { ns: "adminUsers" })}
</Button>
{dirty && (
<Button
variant="outline"
onClick={() => {
setDraft(saved);
setDirty(false);
}}
>
{t("system.discard", { ns: "config" })}
</Button>
)}
</div>
</div>
</section>
<section className="space-y-4 border-t border-border/60 pt-6">
<div className="space-y-1">
<h3 className="text-base font-semibold">{t("wallet.title", { ns: "config" })}</h3>

View File

@@ -37,6 +37,7 @@ import {
} from "@/components/ui/table";
import { Textarea } from "@/components/ui/textarea";
import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog";
import { useAdminPlayCodeLabel } from "@/hooks/use-admin-play-type-catalog";
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
import { adminHasAnyPermission } from "@/lib/admin-permissions";
import { formatAdminMinorUnits } from "@/lib/money";
@@ -72,6 +73,7 @@ export function SettlementBatchDetailsConsole({ batchId }: Props) {
const { t } = useTranslation(["settlement", "common"]);
const profile = useAdminProfile();
useAdminCurrencyCatalog();
const playCodeLabel = useAdminPlayCodeLabel();
const canReviewSettlement = adminHasAnyPermission(profile?.permissions, [PRD_PAYOUT_REVIEW]);
const canManagePayout = adminHasAnyPermission(profile?.permissions, [PRD_PAYOUT_MANAGE]);
const formatDt = useAdminDateTimeFormatter();
@@ -333,7 +335,7 @@ export function SettlementBatchDetailsConsole({ batchId }: Props) {
{details.items.map((r) => (
<TableRow key={r.id}>
<TableCell className="font-mono text-xs">{r.ticket_no ?? "—"}</TableCell>
<TableCell className="font-mono text-xs">{r.play_code ?? "—"}</TableCell>
<TableCell className="text-xs">{playCodeLabel(r.play_code)}</TableCell>
<TableCell className="max-w-[10rem] truncate text-xs">
{r.player_username ?? r.site_player_id ?? r.player_id ?? "—"}
</TableCell>

View File

@@ -28,6 +28,7 @@ import {
TableRow,
} from "@/components/ui/table";
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
import { useAdminPlayCodeLabel } from "@/hooks/use-admin-play-type-catalog";
import { LotteryApiBizError } from "@/types/api/errors";
import type { AdminTicketItemsData } from "@/types/api/admin-tickets";
import { ChevronDown } from "lucide-react";
@@ -60,13 +61,18 @@ const emptyTicketFilters: TicketFilters = {
statuses: [],
};
function ticketStatusText(value: string, t: (key: string) => string): string {
type TicketTranslateFn = (
key: string,
options?: { count?: number },
) => string;
function ticketStatusText(value: string, t: TicketTranslateFn): string {
const key = `statusOptions.${value}`;
const translated = t(key);
return translated === key ? value : translated;
}
function ticketStatusSummary(statuses: string[], t: (key: string) => string): string {
function ticketStatusSummary(statuses: string[], t: TicketTranslateFn): string {
if (statuses.length === 0) {
return t("statusOptions.all");
}
@@ -80,6 +86,7 @@ function ticketStatusSummary(statuses: string[], t: (key: string) => string): st
export function PlayerTicketsConsole(): React.ReactElement {
const { t } = useTranslation(["tickets", "common"]);
const playCodeLabel = useAdminPlayCodeLabel();
const exportLabels = useExportLabels("tickets");
const formatTs = useAdminDateTimeFormatter();
const [draft, setDraft] = useState<TicketFilters>(emptyTicketFilters);
@@ -328,7 +335,7 @@ export function PlayerTicketsConsole(): React.ReactElement {
</TableCell>
<TableCell className="font-mono text-xs">{row.order_no ?? "—"}</TableCell>
<TableCell className="font-mono text-xs">{row.draw_no ?? "—"}</TableCell>
<TableCell className="text-xs">{row.play_code}</TableCell>
<TableCell className="text-xs">{playCodeLabel(row.play_code)}</TableCell>
<TableCell className="font-mono text-xs">{row.original_number ?? "—"}</TableCell>
<TableCell className="text-right tabular-nums text-xs">
{row.total_bet_amount_formatted}