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:
34
src/modules/config/doc/odds-config-dirty.ts
Normal file
34
src/modules/config/doc/odds-config-dirty.ts
Normal 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;
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
51
src/modules/config/doc/odds-config-draft-bar.tsx
Normal file
51
src/modules/config/doc/odds-config-draft-bar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
158
src/modules/config/doc/odds-config-play-nav.tsx
Normal file
158
src/modules/config/doc/odds-config-play-nav.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>>,
|
||||
|
||||
Reference in New Issue
Block a user