feat(draws, config, deployment): 合并开奖结果/审核页,新增下注筛选并完善开注商配置
Some checks failed
lotteryadmin CI / build (push) Has been cancelled
Some checks failed
lotteryadmin CI / build (push) Has been cancelled
This commit is contained in:
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user