feat(draws, config, deployment): 合并开奖结果/审核页,新增下注筛选并完善开注商配置
Some checks failed
lotteryadmin CI / build (push) Has been cancelled

This commit is contained in:
2026-07-10 17:04:42 +08:00
parent d6826d5a1c
commit 8232ac337d
24 changed files with 272 additions and 325 deletions

View File

@@ -55,14 +55,6 @@ import type { AdminBetProviderRow } from "@/types/api/admin-bet-provider";
import { OddsConfigDraftBar } from "@/modules/config/doc/odds-config-draft-bar";
import { OddsConfigPlayNav } from "@/modules/config/doc/odds-config-play-nav";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
buildOddsPlayFilterGroups,
filterOddsPlayTypesByCategory,
@@ -746,85 +738,10 @@ export function OddsConfigDocScreen({
return { scope, row, hint, sourceLabel, sourceClassName };
});
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, sourceLabel, sourceClassName }) => (
<TableRow key={scope}>
<TableCell className="font-medium">
<div className="flex flex-wrap items-center gap-1.5">
<span>{prizeScopeLabel(scope, t)}</span>
{hint ? <span className="text-xs font-normal text-muted-foreground">{hint}</span> : null}
{row ? (
<span className={cn("rounded-full border px-1.5 py-0.5 text-[11px] font-semibold", sourceClassName)}>
{sourceLabel}
</span>
) : null}
</div>
</TableCell>
<TableCell className="text-right">
{row ? (
canEditDraft ? (
<Input
type="text"
inputMode="decimal"
className="ml-auto h-9 w-full max-w-[9rem] text-base font-semibold"
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 className="ml-auto h-9 w-full max-w-[9rem] justify-center text-base font-semibold">
{oddsMultiplierLabel(row.odds_value)}
</ConfigReadonlyValue>
)
) : (
<span className="text-xs text-destructive">
{t("odds.missingScopeRow", { ns: "config", scope })}
</span>
)}
</TableCell>
</TableRow>
))}
<TableRow>
<TableCell className="font-medium">{t("odds.rebateRate", { ns: "config" })}</TableCell>
<TableCell className="text-right">
{canEditDraft ? (
<Input
type="text"
inputMode="decimal"
className="ml-auto h-9 w-full max-w-[9rem] text-base font-semibold"
disabled={saving}
value={rebatePercentUi}
placeholder={t("odds.placeholders.rebateRate", { ns: "config" })}
onChange={(e) => setRebateForPlayPercent(e.target.value)}
/>
) : (
<ConfigReadonlyValue className="ml-auto h-9 w-full max-w-[9rem] justify-center text-base font-semibold">
{rebatePercentUi}
</ConfigReadonlyValue>
)}
</TableCell>
</TableRow>
</TableBody>
</Table>
);
const classicOddsGrid = (
<div className="grid grid-cols-2 gap-x-4 gap-y-4 sm:grid-cols-3">
<div className="grid grid-cols-2 gap-3 xl:grid-cols-3">
{scopeEditorRows.map(({ scope, row, hint, sourceLabel, sourceClassName }) => (
<div key={scope} className="grid min-w-0 gap-1.5">
<div key={scope} className="grid min-w-0 gap-2 border border-border/60 bg-muted/20 p-3">
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
<Label className="text-xs font-medium text-muted-foreground">
{prizeScopeLabel(scope, t)}
@@ -861,7 +778,7 @@ export function OddsConfigDocScreen({
)}
</div>
))}
<div className="grid min-w-0 gap-1.5">
<div className="grid min-w-0 gap-2 border border-border/60 bg-muted/20 p-3">
<Label className="truncate text-xs font-medium text-muted-foreground">
{t("odds.rebateRate", { ns: "config" })}
</Label>
@@ -896,7 +813,7 @@ export function OddsConfigDocScreen({
/>
) : resolvedPlayCode ? (
<div className={cn(!mergedLayout && embedded ? "rounded-xl border border-border/60 bg-card p-4" : undefined)}>
{mergedLayout ? mergedOddsTable : classicOddsGrid}
{classicOddsGrid}
</div>
) : null}
</>
@@ -976,8 +893,8 @@ export function OddsConfigDocScreen({
return (
<div className="space-y-4">
{toolbarBlock}
<div className="grid gap-0 rounded-lg border border-border/60 lg:grid-cols-[minmax(0,13rem)_minmax(0,1fr)]">
<aside className="space-y-4 border-b border-border/50 px-4 py-4 lg:border-r lg:border-b-0">
<div className="grid gap-0 overflow-hidden rounded-lg border border-border/60 lg:grid-cols-[minmax(0,17rem)_minmax(0,1fr)]">
<aside className="space-y-4 border-b border-border/50 bg-muted/20 px-4 py-4 lg:border-r lg:border-b-0">
<ConfigChipGroup label={t("betProviders.title", { ns: "config", defaultValue: "开注商" })}>
<ConfigChip
active={providerCode === GLOBAL_PROVIDER_CODE}
@@ -1004,9 +921,12 @@ export function OddsConfigDocScreen({
/>
</aside>
<div className="min-w-0">
<div className="border-b border-border/50 px-4 py-2.5 sm:px-5">
<h3 className="text-base font-semibold">{activePlayLabel}</h3>
<p className="text-xs text-muted-foreground">{activeProviderLabel}</p>
<div className="flex items-center justify-between border-b border-border/50 px-4 py-3 sm:px-5">
<div>
<p className="text-xs font-medium text-muted-foreground">{activeProviderLabel}</p>
<h3 className="mt-0.5 text-lg font-bold">{activePlayLabel}</h3>
</div>
<span className="rounded-md bg-muted px-2 py-1 font-mono text-xs text-muted-foreground">{resolvedPlayCode}</span>
</div>
<div className="px-4 py-4 sm:px-5">{mainBlock}</div>
{isDraft && canManage ? (

View File

@@ -72,8 +72,7 @@ export function OddsConfigPlayNav({
))}
</ConfigChipGroup>
{/* 小屏:下拉快速切换玩法 */}
<div className="space-y-1.5 lg:hidden">
<div className="space-y-1.5">
<p className="text-sm font-medium">{t("odds.playType")}</p>
<Select
modal={false}
@@ -114,45 +113,6 @@ export function OddsConfigPlayNav({
</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-muted font-medium text-foreground"
: "text-foreground hover:bg-muted/60",
)}
onClick={() => onPlayCodeChange(type.play_code)}
aria-current={active ? "true" : undefined}
>
{playLabel(type.play_code)}
</button>
</li>
);
})}
</ul>
</div>
))}
</div>
)}
</nav>
</div>
);
}

View File

@@ -4,6 +4,12 @@ export type OddsCategoryTab = "all" | "d4" | "d3" | "d2";
export type OddsPlayFilterGroupKey = "bigSmall" | "combo4" | "number3" | "number2" | "other";
const TRADITIONAL_CODES = new Set([
"big", "small", "pos_4a", "pos_4b", "pos_4c", "pos_4d", "pos_4e", "four_any", "four_top", "four_lower",
"pos_3a", "pos_3lower", "pos_3b", "pos_3c", "pos_3d", "pos_3e",
"pos_2a", "pos_2b", "pos_2c", "pos_2d", "pos_2e", "pos_2any",
]);
type GroupDef = {
key: OddsPlayFilterGroupKey;
match: (row: AdminPlayTypeRow) => boolean;
@@ -39,11 +45,10 @@ export function filterOddsPlayTypesByCategory(
tab: OddsCategoryTab,
types: AdminPlayTypeRow[],
): AdminPlayTypeRow[] {
if (tab === "all") {
return types;
}
const traditional = types.filter((type) => TRADITIONAL_CODES.has(type.play_code));
if (tab === "all") return traditional;
const dim = tab === "d4" ? 4 : tab === "d3" ? 3 : 2;
return types.filter((t) => t.dimension === dim);
return traditional.filter((t) => t.dimension === dim);
}
export function buildOddsPlayFilterGroups(

View File

@@ -86,6 +86,12 @@ type PlayBatchSwitchGroup = {
match: (row: PlayConfigItemRow) => boolean;
};
const TRADITIONAL_PLAY_CODES = new Set([
"big", "small", "pos_4a", "pos_4b", "pos_4c", "pos_4d", "pos_4e", "four_any", "four_top", "four_lower",
"pos_3a", "pos_3lower", "pos_3b", "pos_3c", "pos_3d", "pos_3e",
"pos_2a", "pos_2b", "pos_2c", "pos_2d", "pos_2e", "pos_2any",
]);
const PLAY_BATCH_SWITCH_GROUPS: PlayBatchSwitchGroup[] = [
{
key: "d2",
@@ -267,7 +273,7 @@ export function PlayConfigDocScreen() {
const orderedRows = useMemo(
() =>
[...draftRows].sort(
draftRows.filter((row) => TRADITIONAL_PLAY_CODES.has(row.play_code)).sort(
(a, b) => a.display_order - b.display_order || a.play_code.localeCompare(b.play_code),
),
[draftRows],

View File

@@ -67,6 +67,11 @@ import {
import { PRIZE_SCOPE_ORDER } from "@/modules/config/doc/prize-scopes";
const APPLY_REBATE_TO_PAYOUT_KEY = "settlement.apply_rebate_to_payout";
const TRADITIONAL_PLAY_CODES = new Set([
"big", "small", "pos_4a", "pos_4b", "pos_4c", "pos_4d", "pos_4e", "four_any", "four_top", "four_lower",
"pos_3a", "pos_3lower", "pos_3b", "pos_3c", "pos_3d", "pos_3e",
"pos_2a", "pos_2b", "pos_2c", "pos_2d", "pos_2e", "pos_2any",
]);
function dimensionDistinctPrimaryScopePercents(
dim: 2 | 3 | 4,
@@ -195,9 +200,10 @@ export function RebateConfigDocScreen({
if (!workspace) {
return;
}
setP2(inferRebatePercentFromDimension(2, workspace.draftRows, workspace.types));
setP3(inferRebatePercentFromDimension(3, workspace.draftRows, workspace.types));
setP4(inferRebatePercentFromDimension(4, workspace.draftRows, workspace.types));
const traditional = workspace.types.filter((type) => TRADITIONAL_PLAY_CODES.has(type.play_code));
setP2(inferRebatePercentFromDimension(2, workspace.draftRows, traditional));
setP3(inferRebatePercentFromDimension(3, workspace.draftRows, traditional));
setP4(inferRebatePercentFromDimension(4, workspace.draftRows, traditional));
}, [workspace?.draftRows, workspace?.types, workspace]);
async function handleWinEnjoyChange(checked: boolean): Promise<void> {
@@ -294,7 +300,7 @@ export function RebateConfigDocScreen({
return false;
}
for (const dim of [2, 3, 4] as const) {
if (dimensionDistinctPrimaryScopePercents(dim, resolvedDraftRows, resolvedTypes).size > 1) {
if (dimensionDistinctPrimaryScopePercents(dim, resolvedDraftRows, resolvedTypes.filter((type) => TRADITIONAL_PLAY_CODES.has(type.play_code))).size > 1) {
return true;
}
}
@@ -318,6 +324,9 @@ export function RebateConfigDocScreen({
const rate3 = Number.isFinite(r3) ? r3 / 100 : 0;
const rate4 = Number.isFinite(r4) ? r4 / 100 : 0;
return rows.map((row) => {
if (!TRADITIONAL_PLAY_CODES.has(row.play_code)) {
return row;
}
const t = typesByCode.get(row.play_code);
const dim = (t?.dimension ?? 2) as 2 | 3 | 4;
const rate = dim === 4 ? rate4 : dim === 3 ? rate3 : rate2;