feat(api, agents, i18n): enhance settlement features and multi-language support

Added new types and API functions for settlement period summaries and credit ledgers, improving the management of agent settlements. Updated the admin console to reflect these changes, enhancing user experience with better navigation and data presentation. Additionally, expanded multi-language support by incorporating new translations in English, Nepali, and Chinese for settlement-related terms, ensuring consistency across the platform.
This commit is contained in:
2026-06-05 18:00:59 +08:00
parent 65eaeecf8c
commit af982bb9f7
73 changed files with 4307 additions and 2494 deletions

View File

@@ -0,0 +1,34 @@
import type { OddsItemRow } from "@/types/api/admin-config";
function oddsItemFingerprint(row: OddsItemRow): string {
return [
row.play_code,
row.prize_scope,
row.odds_value,
row.rebate_rate,
row.commission_rate,
row.currency_code,
].join("|");
}
/** 草稿行是否与已保存版本存在差异。 */
export function oddsDraftIsDirty(draftRows: OddsItemRow[], savedRows: OddsItemRow[]): boolean {
if (draftRows.length !== savedRows.length) {
return true;
}
const saved = new Map(savedRows.map((row) => [`${row.play_code}|${row.prize_scope}`, row]));
for (const draft of draftRows) {
const key = `${draft.play_code}|${draft.prize_scope}`;
const baseline = saved.get(key);
if (!baseline) {
return true;
}
if (oddsItemFingerprint(draft) !== oddsItemFingerprint(baseline)) {
return true;
}
}
return false;
}

View File

@@ -55,11 +55,18 @@ import type {
OddsVersionDetail,
} from "@/types/api/admin-config";
import { ConfigWorkflowSection } from "@/modules/config/config-workflow-section";
import { OddsConfigDraftBar } from "@/modules/config/doc/odds-config-draft-bar";
import { oddsDraftIsDirty } from "@/modules/config/doc/odds-config-dirty";
import { OddsConfigPlayNav } from "@/modules/config/doc/odds-config-play-nav";
import { OddsConfigSummaryPanel } from "@/modules/config/doc/odds-config-summary-panel";
import {
OddsConfigSummaryPanel,
playRebatePercentFromScopes,
} from "@/modules/config/doc/odds-config-summary-panel";
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
buildOddsPlayFilterGroups,
filterOddsPlayTypesByCategory,
@@ -507,6 +514,13 @@ export function OddsConfigDocScreen({
? resolveAdminPlayTypeDisplayName(resolvedPlayCode, i18n.language, sortedTypes.find((t) => t.play_code === resolvedPlayCode))
: "—";
const isDirty = useMemo(() => {
if (!resolvedDetail || !isDraft) {
return false;
}
return oddsDraftIsDirty(resolvedDraftRows, resolvedDetail.items);
}, [isDraft, resolvedDetail, resolvedDraftRows]);
const filtersInner = (
<>
<ConfigChipGroup label={t("odds.category", { ns: "config" })}>
@@ -602,6 +616,7 @@ export function OddsConfigDocScreen({
loadingList={resolvedLoadingList}
loadingDetail={resolvedLoadingDetail}
saving={saving}
suppressDraftActions={mergedLayout && isDraft}
onRefresh={() => void refreshList()}
onNewDraft={() => void handleNewDraft()}
onSaveDraft={() => void handleSave()}
@@ -635,81 +650,169 @@ export function OddsConfigDocScreen({
/>
);
const rebateField = (
<div className="rounded-lg border border-border/60 bg-muted/20 p-4">
<div className="grid max-w-xs gap-1.5">
<Label htmlFor="odds-rebate-rate">{t("odds.rebateRate", { ns: "config" })}</Label>
{canEditDraft ? (
<Input
id="odds-rebate-rate"
type="text"
inputMode="decimal"
className="h-9 font-mono tabular-nums"
disabled={saving}
value={rebatePercentUi}
placeholder={t("odds.placeholders.rebateRate", { ns: "config" })}
onChange={(e) => setRebateForPlayPercent(e.target.value)}
/>
) : (
<ConfigReadonlyValue mono className="h-9 w-full max-w-xs justify-center">
{rebatePercentUi}
</ConfigReadonlyValue>
)}
</div>
<p className="mt-2 text-xs text-muted-foreground">{t("odds.rebateRateHint", { ns: "config" })}</p>
</div>
);
const scopeEditorRows = PRIZE_SCOPE_ORDER.map((scope) => {
const row = scopeRows[scope];
const hint = mergedLayout ? null : PRIZE_SCOPE_MULTIPLIER_HINT[scope];
const idx = row ? rowIndex(resolvedPlayCode, scope) : -1;
return { scope, row, hint, idx };
});
const mergedOddsTable = (
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("odds.table.prizeScope", { ns: "config" })}</TableHead>
<TableHead className="w-[10rem] text-right">{t("odds.table.multiplier", { ns: "config" })}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{scopeEditorRows.map(({ scope, row, hint, idx }) => (
<TableRow key={scope}>
<TableCell className="font-medium">
{prizeScopeLabel(scope, t)}
{hint ? <span className="ml-1 text-xs font-normal text-muted-foreground">{hint}</span> : null}
</TableCell>
<TableCell className="text-right">
{row && idx >= 0 ? (
canEditDraft ? (
<Input
type="text"
inputMode="decimal"
className="ml-auto h-9 w-full max-w-[9rem] font-mono tabular-nums"
disabled={saving}
value={oddsMultiplierLabel(row.odds_value)}
placeholder={t("odds.placeholders.multiplier", { ns: "config" })}
onChange={(e) =>
updateOddsForScope(scope, {
odds_value: parseOddsMultiplierInput(e.target.value),
})
}
/>
) : (
<ConfigReadonlyValue mono className="ml-auto h-9 w-full max-w-[9rem] justify-center">
{oddsMultiplierLabel(row.odds_value)}
</ConfigReadonlyValue>
)
) : (
<span className="text-xs text-destructive">
{t("odds.missingScopeRow", { ns: "config", scope })}
</span>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
const classicOddsGrid = (
<div className="grid grid-cols-2 gap-x-4 gap-y-4 sm:grid-cols-3">
{scopeEditorRows.map(({ scope, row, hint, idx }) => (
<div key={scope} className="grid min-w-0 gap-1.5">
<Label className="truncate text-xs font-medium text-muted-foreground">
{prizeScopeLabel(scope, t)}
{hint ? <span className="ml-1 font-normal">{hint}</span> : null}
</Label>
{row && idx >= 0 ? (
canEditDraft ? (
<Input
type="text"
inputMode="decimal"
className="h-9 w-full font-mono tabular-nums"
disabled={saving}
value={oddsMultiplierLabel(row.odds_value)}
placeholder={t("odds.placeholders.multiplier", { ns: "config" })}
onChange={(e) =>
updateOddsForScope(scope, {
odds_value: parseOddsMultiplierInput(e.target.value),
})
}
/>
) : (
<ConfigReadonlyValue mono className="h-9 w-full justify-center">
{oddsMultiplierLabel(row.odds_value)}
</ConfigReadonlyValue>
)
) : (
<p className="text-xs text-destructive">{t("odds.missingScopeRow", { ns: "config", scope })}</p>
)}
</div>
))}
<div className="grid min-w-0 gap-1.5">
<Label className="truncate text-xs font-medium text-muted-foreground">
{t("odds.rebateRate", { ns: "config" })}
</Label>
{canEditDraft ? (
<Input
type="text"
inputMode="decimal"
className="h-9 w-full font-mono tabular-nums"
disabled={saving}
value={rebatePercentUi}
placeholder={t("odds.placeholders.rebateRate", { ns: "config" })}
onChange={(e) => setRebateForPlayPercent(e.target.value)}
/>
) : (
<ConfigReadonlyValue mono className="h-9 w-full justify-center">
{rebatePercentUi}
</ConfigReadonlyValue>
)}
</div>
</div>
);
const mainBlock = (
<>
{resolvedError ? <p className="text-sm text-destructive">{resolvedError}</p> : null}
{resolvedLoadingDetail || resolvedLoadingTypes ? (
<AdminLoadingState
className={cn(mergedLayout ? "py-6" : "py-8")}
minHeight="6rem"
label={t("odds.loadingDetails", { ns: "config" })}
/>
) : resolvedPlayCode ? (
<div className={cn(!mergedLayout && embedded ? "rounded-xl border border-border/60 bg-card p-4" : undefined)}>
<div className="grid grid-cols-2 gap-x-4 gap-y-4 sm:grid-cols-3">
{PRIZE_SCOPE_ORDER.map((scope) => {
const row = scopeRows[scope];
const hint = embedded ? null : PRIZE_SCOPE_MULTIPLIER_HINT[scope];
const idx = row ? rowIndex(resolvedPlayCode, scope) : -1;
return (
<div key={scope} className="grid min-w-0 gap-1.5">
<Label className="truncate text-xs font-medium text-muted-foreground">
{prizeScopeLabel(scope, t)}
{hint ? <span className="ml-1 font-normal">{hint}</span> : null}
</Label>
{row && idx >= 0 ? (
canEditDraft ? (
<Input
type="text"
inputMode="decimal"
className="h-9 w-full font-mono tabular-nums"
disabled={saving}
value={oddsMultiplierLabel(row.odds_value)}
placeholder={t("odds.placeholders.multiplier", { ns: "config" })}
onChange={(e) =>
updateOddsForScope(scope, {
odds_value: parseOddsMultiplierInput(e.target.value),
})
}
/>
) : (
<ConfigReadonlyValue mono className="h-9 w-full justify-center">
{oddsMultiplierLabel(row.odds_value)}
</ConfigReadonlyValue>
)
) : (
<p className="text-xs text-destructive">{t("odds.missingScopeRow", { ns: "config", scope })}</p>
)}
</div>
);
})}
<div className="grid min-w-0 gap-1.5">
<Label className="truncate text-xs font-medium text-muted-foreground">
{t("odds.rebateRate", { ns: "config" })}
</Label>
{canEditDraft ? (
<Input
type="text"
inputMode="decimal"
className="h-9 w-full font-mono tabular-nums"
disabled={saving}
value={rebatePercentUi}
placeholder={t("odds.placeholders.rebateRate", { ns: "config" })}
onChange={(e) => setRebateForPlayPercent(e.target.value)}
/>
) : (
<ConfigReadonlyValue mono className="h-9 w-full justify-center">
{rebatePercentUi}
</ConfigReadonlyValue>
)}
</div>
{resolvedLoadingDetail || resolvedLoadingTypes ? (
<AdminLoadingState
className={cn(mergedLayout ? "py-6" : "py-8")}
minHeight="6rem"
label={t("odds.loadingDetails", { ns: "config" })}
/>
) : resolvedPlayCode ? (
<div className={cn(!mergedLayout && embedded ? "rounded-xl border border-border/60 bg-card p-4" : undefined)}>
{mergedLayout ? (
<div className="space-y-4">
{mergedOddsTable}
{rebateField}
</div>
{!embedded ? (
<p className="mt-3 text-xs text-muted-foreground">{t("odds.rebateRateHint", { ns: "config" })}</p>
) : null}
</div>
) : null}
) : (
<>
{classicOddsGrid}
{!embedded ? (
<p className="mt-3 text-xs text-muted-foreground">{t("odds.rebateRateHint", { ns: "config" })}</p>
) : null}
</>
)}
</div>
) : null}
</>
);
@@ -785,34 +888,51 @@ export function OddsConfigDocScreen({
if (embedded && mergedLayout) {
return (
<div className="space-y-6">
<div className="space-y-4">
<div className="overflow-hidden rounded-xl border border-border/60 bg-card">{toolbarBlock}</div>
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_min(100%,300px)] xl:items-start">
<div className="space-y-6">
<ConfigWorkflowSection step={1} title={t("odds.sections.playScope", { ns: "config" })}>
{filtersBlock}
</ConfigWorkflowSection>
<ConfigWorkflowSection
step={2}
title={t("odds.sections.oddsConfig", { ns: "config" })}
description={t("odds.currentSelection", {
ns: "config",
category: activeCatLabel,
play: activePlayLabel,
})}
>
{mainBlock}
</ConfigWorkflowSection>
{rebateSection}
<div className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_min(100%,260px)] xl:items-start">
<div className="overflow-hidden rounded-xl border border-border/60 bg-card">
<div className="grid gap-0 lg:grid-cols-[minmax(0,13rem)_minmax(0,1fr)]">
<aside className="border-b border-border/50 px-4 py-4 lg:border-r lg:border-b-0">
<OddsConfigPlayNav
catTab={catTab}
onCatTabChange={setCatTab}
onPlayCodeChange={setPlayCode}
types={sortedTypes}
resolvedPlayCode={resolvedPlayCode}
/>
</aside>
<div className="min-w-0">
<div className="border-b border-border/50 px-4 py-3 sm:px-5">
<h3 className="text-base font-semibold">{activePlayLabel}</h3>
<p className="text-sm text-muted-foreground">
{t("odds.currentSelection", {
ns: "config",
category: activeCatLabel,
play: activePlayLabel,
})}
</p>
</div>
<div className="px-4 py-4 sm:px-5">{mainBlock}</div>
{isDraft && canManage ? (
<OddsConfigDraftBar
isDirty={isDirty}
saving={saving}
loadingDetail={resolvedLoadingDetail}
onSave={() => void handleSave()}
onPublish={() => void requestPublishConfirm()}
/>
) : null}
</div>
</div>
</div>
<OddsConfigSummaryPanel
catTabLabel={activeCatLabel}
playLabel={activePlayLabel}
compact
detail={resolvedDetail}
draftRows={resolvedDraftRows}
types={sortedTypes}
scopeRows={scopeRows}
playRebatePercent={playRebatePercentFromScopes(scopeRows, PRIZE_SCOPE_ORDER)}
activeHead={activeHead ?? null}
/>
</div>
{dialogs}

View File

@@ -0,0 +1,51 @@
"use client";
import { Rocket, Save } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
type OddsConfigDraftBarProps = {
isDirty: boolean;
saving: boolean;
loadingDetail?: boolean;
onSave: () => void;
onPublish: () => void;
className?: string;
};
export function OddsConfigDraftBar({
isDirty,
saving,
loadingDetail = false,
onSave,
onPublish,
className,
}: OddsConfigDraftBarProps) {
const { t } = useTranslation("config");
const busy = saving || loadingDetail;
return (
<div
className={cn(
"sticky bottom-0 z-10 flex flex-col gap-3 border-t border-border/60 bg-card/95 px-4 py-3 backdrop-blur sm:flex-row sm:items-center sm:justify-between sm:px-5",
className,
)}
>
<p className="text-sm text-muted-foreground">
{isDirty ? t("odds.draftBar.unsaved") : t("odds.draftBar.saved")}
</p>
<div className="flex flex-wrap items-center gap-2">
<Button type="button" variant="outline" size="sm" disabled={busy} onClick={onSave}>
<Save className="size-3.5" aria-hidden />
{t("versionActions.saveDraft")}
</Button>
<Button type="button" size="sm" disabled={busy} onClick={onPublish}>
<Rocket className="size-3.5" aria-hidden />
{t("versionActions.publishCurrent")}
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,158 @@
"use client";
import { useTranslation } from "react-i18next";
import { ConfigChip, ConfigChipGroup } from "@/modules/config/config-chip-group";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
buildOddsPlayFilterGroups,
filterOddsPlayTypesByCategory,
type OddsCategoryTab,
} from "@/modules/config/doc/odds-play-type-groups";
import { resolveAdminPlayTypeDisplayName } from "@/lib/admin-play-types";
import { cn } from "@/lib/utils";
import type { AdminPlayTypeRow } from "@/types/api/admin-config";
const PLAY_SELECT_NONE = "__none__";
type OddsConfigPlayNavProps = {
catTab: OddsCategoryTab;
onCatTabChange: (tab: OddsCategoryTab) => void;
onPlayCodeChange: (code: string) => void;
types: AdminPlayTypeRow[];
resolvedPlayCode: string;
className?: string;
};
export function OddsConfigPlayNav({
catTab,
onCatTabChange,
onPlayCodeChange,
types,
resolvedPlayCode,
className,
}: OddsConfigPlayNavProps) {
const { t, i18n } = useTranslation("config");
const catTabs = [
{ id: "all" as const, label: t("odds.tabs.all") },
{ id: "d4" as const, label: "4D" },
{ id: "d3" as const, label: "3D" },
{ id: "d2" as const, label: "2D" },
];
const filteredTypes = filterOddsPlayTypesByCategory(catTab, types);
const playGroups = buildOddsPlayFilterGroups(catTab, types);
const playSelectValue = resolvedPlayCode || PLAY_SELECT_NONE;
const playLabel = (code: string): string => {
const row = types.find((item) => item.play_code === code);
return resolveAdminPlayTypeDisplayName(code, i18n.language, row);
};
return (
<div className={cn("space-y-4", className)}>
<ConfigChipGroup label={t("odds.category")}>
{catTabs.map((tab) => (
<ConfigChip
key={tab.id}
active={catTab === tab.id}
onClick={() => onCatTabChange(tab.id)}
>
{tab.label}
</ConfigChip>
))}
</ConfigChipGroup>
{/* 小屏:下拉快速切换玩法 */}
<div className="space-y-1.5 lg:hidden">
<p className="text-sm font-medium">{t("odds.playType")}</p>
<Select
modal={false}
value={playSelectValue}
onValueChange={(value) => {
if (value != null && value !== PLAY_SELECT_NONE) {
onPlayCodeChange(value);
}
}}
disabled={filteredTypes.length === 0}
>
<SelectTrigger className="h-9 w-full">
<SelectValue>
{(v) => {
const code = v == null || v === "" || v === PLAY_SELECT_NONE ? "" : String(v);
return code ? playLabel(code) : t("odds.playSelectPlaceholder");
}}
</SelectValue>
</SelectTrigger>
<SelectContent>
{playGroups.length > 0 ? (
playGroups.map((group) => (
<SelectGroup key={group.key}>
<SelectLabel>{t(`odds.playGroups.${group.key}`)}</SelectLabel>
{group.types.map((type) => (
<SelectItem key={type.play_code} value={type.play_code}>
{playLabel(type.play_code)}
</SelectItem>
))}
</SelectGroup>
))
) : (
<SelectItem value={PLAY_SELECT_NONE} disabled>
{t("odds.noPlayTypes")}
</SelectItem>
)}
</SelectContent>
</Select>
</div>
{/* 大屏:侧栏玩法列表,点选即切换 */}
<nav className="hidden lg:block" aria-label={t("odds.playType")}>
<p className="mb-2 text-sm font-medium">{t("odds.playType")}</p>
{filteredTypes.length === 0 ? (
<p className="text-sm text-muted-foreground">{t("odds.noPlayTypes")}</p>
) : (
<div className="max-h-[min(28rem,calc(100vh-16rem))] space-y-4 overflow-y-auto pr-1">
{playGroups.map((group) => (
<div key={group.key} className="space-y-1">
<p className="px-2 text-xs font-medium text-muted-foreground">
{t(`odds.playGroups.${group.key}`)}
</p>
<ul className="space-y-0.5">
{group.types.map((type) => {
const active = resolvedPlayCode === type.play_code;
return (
<li key={type.play_code}>
<button
type="button"
className={cn(
"w-full rounded-md px-2.5 py-2 text-left text-sm transition-colors",
active
? "bg-primary font-medium text-primary-foreground shadow-sm"
: "text-foreground hover:bg-muted/80",
)}
onClick={() => onPlayCodeChange(type.play_code)}
aria-current={active ? "true" : undefined}
>
{playLabel(type.play_code)}
</button>
</li>
);
})}
</ul>
</div>
))}
</div>
)}
</nav>
</div>
);
}

View File

@@ -5,28 +5,21 @@ import { useTranslation } from "react-i18next";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { ConfigStatusBadge } from "@/modules/config/config-status-badge";
import { inferRebatePercentFromDimension, rateToPercentUi } from "@/modules/config/doc/odds-rebate-rates";
import { rateToPercentUi } from "@/modules/config/doc/odds-rebate-rates";
import { prizeScopeLabel, type PrizeScopeCode } from "@/modules/config/doc/prize-scopes";
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
import { cn } from "@/lib/utils";
import type { AdminPlayTypeRow, OddsItemRow, OddsVersionDetail } from "@/types/api/admin-config";
function oddsMultiplierLabel(oddsValue: number): string {
return (oddsValue / 10000).toFixed(4);
}
type SummaryRow = {
label: string;
value: string;
};
import type { ConfigVersionSummary, OddsItemRow, OddsVersionDetail } from "@/types/api/admin-config";
type OddsConfigSummaryPanelProps = {
catTabLabel: string;
playLabel: string;
catTabLabel?: string;
playLabel?: string;
detail: OddsVersionDetail | null;
draftRows: OddsItemRow[];
types: AdminPlayTypeRow[];
scopeRows: Partial<Record<PrizeScopeCode, OddsItemRow>>;
playRebatePercent: string;
scopeRows?: Partial<Record<PrizeScopeCode, OddsItemRow>>;
playRebatePercent?: string;
activeHead?: ConfigVersionSummary | null;
/** 合并页:仅展示版本与操作提示,不重复主编辑区数值 */
compact?: boolean;
className?: string;
};
@@ -34,63 +27,32 @@ export function OddsConfigSummaryPanel({
catTabLabel,
playLabel,
detail,
draftRows,
types,
scopeRows,
playRebatePercent,
activeHead = null,
compact = false,
className,
}: OddsConfigSummaryPanelProps) {
const { t } = useTranslation("config");
const formatDt = useAdminDateTimeFormatter();
const isDraft = detail?.status === "draft";
const isActive = detail?.status === "active";
const rows: SummaryRow[] = [
{ label: t("odds.category"), value: catTabLabel },
{ label: t("odds.playType"), value: playLabel || "—" },
];
for (const scope of ["first", "second", "third", "starter", "consolation"] as PrizeScopeCode[]) {
const row = scopeRows[scope];
rows.push({
label: prizeScopeLabel(scope, t),
value: row ? oddsMultiplierLabel(row.odds_value) : "—",
});
}
rows.push({
label: t("odds.rebateRate"),
value: playRebatePercent,
});
rows.push(
{
label: t("rebate.fields.d2"),
value: inferRebatePercentFromDimension(2, draftRows, types),
},
{
label: t("rebate.fields.d3"),
value: inferRebatePercentFromDimension(3, draftRows, types),
},
{
label: t("rebate.fields.d4"),
value: inferRebatePercentFromDimension(4, draftRows, types),
},
);
const versionLabel = detail ? `v${detail.version_no}` : "—";
return (
<aside
className={cn(
"lg:sticky lg:top-24 lg:max-h-[calc(100vh-7rem)] lg:overflow-y-auto",
"xl:sticky xl:top-24 xl:max-h-[calc(100vh-7rem)] xl:overflow-y-auto",
className,
)}
>
<div className="overflow-hidden rounded-xl border border-border/60 bg-card">
<div className="flex items-center gap-2 border-b border-border/50 px-4 py-3.5">
<FileText className="size-4 text-primary" aria-hidden />
<h3 className="text-base font-semibold">{t("odds.summary.title")}</h3>
<h3 className="text-base font-semibold">
{compact ? t("odds.summary.contextTitle") : t("odds.summary.title")}
</h3>
</div>
<div className="space-y-4 px-4 py-4">
@@ -100,29 +62,50 @@ export function OddsConfigSummaryPanel({
{detail ? <ConfigStatusBadge status={detail.status} /> : null}
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm text-muted-foreground">{t("odds.summary.statusLabel")}</span>
{detail && !isDraft ? (
<span className="inline-flex items-center rounded-md border border-border/60 bg-muted/40 px-2 py-0.5 text-xs font-medium text-muted-foreground">
{t("odds.summary.readOnlyTag")}
</span>
) : isDraft ? (
<span className="inline-flex items-center rounded-md border border-primary/25 bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
{t("versionStatus.draft")}
</span>
) : (
<span className="text-sm"></span>
)}
</div>
{activeHead ? (
<div className="space-y-1 text-sm">
<p className="text-muted-foreground">{t("odds.summary.activeVersion")}</p>
<p className="font-mono font-medium">
v{activeHead.version_no}
{activeHead.effective_at ? ` · ${formatDt(activeHead.effective_at)}` : ""}
</p>
</div>
) : null}
<dl className="space-y-2.5">
{rows.map((row) => (
<div key={row.label} className="grid grid-cols-[minmax(0,1fr)_auto] gap-3 text-sm">
<dt className="text-muted-foreground">{row.label}</dt>
<dd className="font-mono text-right tabular-nums text-foreground">{row.value}</dd>
{!compact && catTabLabel && playLabel ? (
<dl className="space-y-2 text-sm">
<div className="grid grid-cols-[minmax(0,1fr)_auto] gap-3">
<dt className="text-muted-foreground">{t("odds.category")}</dt>
<dd>{catTabLabel}</dd>
</div>
))}
</dl>
<div className="grid grid-cols-[minmax(0,1fr)_auto] gap-3">
<dt className="text-muted-foreground">{t("odds.playType")}</dt>
<dd>{playLabel}</dd>
</div>
</dl>
) : null}
{!compact && scopeRows ? (
<dl className="space-y-2.5">
{(["first", "second", "third", "starter", "consolation"] as PrizeScopeCode[]).map((scope) => {
const row = scopeRows[scope];
return (
<div key={scope} className="grid grid-cols-[minmax(0,1fr)_auto] gap-3 text-sm">
<dt className="text-muted-foreground">{prizeScopeLabel(scope, t)}</dt>
<dd className="font-mono text-right tabular-nums text-foreground">
{row ? oddsMultiplierLabel(row.odds_value) : "—"}
</dd>
</div>
);
})}
{playRebatePercent ? (
<div className="grid grid-cols-[minmax(0,1fr)_auto] gap-3 text-sm">
<dt className="text-muted-foreground">{t("odds.rebateRate")}</dt>
<dd className="font-mono text-right tabular-nums text-foreground">{playRebatePercent}</dd>
</div>
) : null}
</dl>
) : null}
{detail && !isDraft ? (
<Alert className="border-sky-500/30 bg-sky-500/5 text-foreground">
@@ -131,6 +114,12 @@ export function OddsConfigSummaryPanel({
{t("odds.summary.readOnlyHint")}
</AlertDescription>
</Alert>
) : isDraft ? (
<Alert className="border-primary/25 bg-primary/5 text-foreground">
<AlertDescription className="text-xs leading-relaxed">
{t("odds.summary.draftHint")}
</AlertDescription>
</Alert>
) : isActive ? (
<Alert className="border-emerald-500/30 bg-emerald-500/5 text-foreground">
<AlertDescription className="text-xs leading-relaxed">
@@ -144,6 +133,10 @@ export function OddsConfigSummaryPanel({
);
}
function oddsMultiplierLabel(oddsValue: number): string {
return (oddsValue / 10000).toFixed(4);
}
/** 当前玩法在摘要中展示的回水百分比(与赔率区输入一致)。 */
export function playRebatePercentFromScopes(
scopeRows: Partial<Record<PrizeScopeCode, OddsItemRow>>,