- 在赔率行数据结构和接口中新增 provider_code 字段支持多开注商 - 管理后台赔率配置页新增开注商筛选,支持查看和编辑指定开注商赔率 - 未指定专属赔率时,自动继承通用(Global)赔率并显示继承标识 - 新增赔率快照展示组件,显示票据下注时锁定的开注商赔率快照 - 优化赔率保存流程,支持基于开注商区分并批量更新赔率数据 - 更新多语言文案,增加开注商与赔率来源标识翻译 - 调整环境配置文档和默认域名为新的开注商域名地址 - 优化界面标签样式,清晰区分通用、专属和继承赔率来源 - Tickets模块新增赔率快照数据类型及详细展示表格 - 修复管理后台及票据详情页部分链接和操作按钮,提升用户体验
This commit is contained in:
@@ -2,6 +2,7 @@ import type { OddsItemRow } from "@/types/api/admin-config";
|
||||
|
||||
function oddsItemFingerprint(row: OddsItemRow): string {
|
||||
return [
|
||||
row.provider_code ?? "GLOBAL",
|
||||
row.play_code,
|
||||
row.prize_scope,
|
||||
row.odds_value,
|
||||
@@ -17,10 +18,10 @@ export function oddsDraftIsDirty(draftRows: OddsItemRow[], savedRows: OddsItemRo
|
||||
return true;
|
||||
}
|
||||
|
||||
const saved = new Map(savedRows.map((row) => [`${row.play_code}|${row.prize_scope}`, row]));
|
||||
const saved = new Map(savedRows.map((row) => [`${row.provider_code ?? "GLOBAL"}|${row.play_code}|${row.prize_scope}`, row]));
|
||||
|
||||
for (const draft of draftRows) {
|
||||
const key = `${draft.play_code}|${draft.prize_scope}`;
|
||||
const key = `${draft.provider_code ?? "GLOBAL"}|${draft.play_code}|${draft.prize_scope}`;
|
||||
const baseline = saved.get(key);
|
||||
if (!baseline) {
|
||||
return true;
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
publishOddsVersion,
|
||||
putOddsItems,
|
||||
} from "@/api/admin-config";
|
||||
import { getAdminBetProviders } from "@/api/admin-bet-providers";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ConfigChip, ConfigChipGroup } from "@/modules/config/config-chip-group";
|
||||
import { ConfigDocPage, ConfigDocToolbar } from "@/modules/config/config-doc-page";
|
||||
@@ -50,6 +51,7 @@ import type {
|
||||
OddsItemRow,
|
||||
OddsVersionDetail,
|
||||
} from "@/types/api/admin-config";
|
||||
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";
|
||||
@@ -74,6 +76,7 @@ import {
|
||||
} from "@/modules/config/doc/prize-scopes";
|
||||
|
||||
type CatTab = OddsCategoryTab;
|
||||
const GLOBAL_PROVIDER_CODE = "GLOBAL";
|
||||
|
||||
function oddsMultiplierLabel(oddsValue: number): string {
|
||||
return (oddsValue / 10000).toFixed(4);
|
||||
@@ -112,6 +115,7 @@ export function OddsConfigDocScreen({
|
||||
const profile = useAdminProfile();
|
||||
const canManage = adminHasAnyPermission(profile?.permissions, [PRD_ODDS_MANAGE, PRD_REBATE_MANAGE]);
|
||||
const [types, setTypes] = useState<AdminPlayTypeRow[]>([]);
|
||||
const [betProviders, setBetProviders] = useState<AdminBetProviderRow[]>([]);
|
||||
const [list, setList] = useState<ConfigVersionSummary[]>([]);
|
||||
const [internalSelectedId, setInternalSelectedId] = useState("");
|
||||
const selectedId = workspace?.selectedId ?? controlledVersionId ?? internalSelectedId;
|
||||
@@ -135,6 +139,7 @@ export function OddsConfigDocScreen({
|
||||
const resolvedError = workspace?.error ?? error;
|
||||
|
||||
const [catTab, setCatTab] = useState<CatTab>("all");
|
||||
const [providerCode, setProviderCode] = useState<string>(GLOBAL_PROVIDER_CODE);
|
||||
/** User-selected play type. Empty means none selected yet and falls back to the first item in the category. */
|
||||
const [playCode, setPlayCode] = useState<string>("");
|
||||
|
||||
@@ -157,6 +162,15 @@ export function OddsConfigDocScreen({
|
||||
}
|
||||
}, [tRef]);
|
||||
|
||||
const refreshBetProviders = useCallback(async () => {
|
||||
try {
|
||||
const data = await getAdminBetProviders();
|
||||
setBetProviders(data.items);
|
||||
} catch {
|
||||
setBetProviders([]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const refreshList = useCallback(async () => {
|
||||
setLoadingList(true);
|
||||
setError(null);
|
||||
@@ -177,8 +191,15 @@ export function OddsConfigDocScreen({
|
||||
if (workspace) {
|
||||
return;
|
||||
}
|
||||
void Promise.all([refreshTypes(), refreshList()]);
|
||||
}, [workspace]);
|
||||
void Promise.all([refreshTypes(), refreshList(), refreshBetProviders()]);
|
||||
}, [refreshBetProviders, workspace]);
|
||||
|
||||
useAsyncEffect(() => {
|
||||
if (!workspace) {
|
||||
return;
|
||||
}
|
||||
void refreshBetProviders();
|
||||
}, [refreshBetProviders, workspace]);
|
||||
|
||||
const loadDetail = useCallback(async (id: number) => {
|
||||
setLoadingDetail(true);
|
||||
@@ -284,13 +305,32 @@ export function OddsConfigDocScreen({
|
||||
if (row.play_code !== resolvedPlayCode) {
|
||||
continue;
|
||||
}
|
||||
const rowProviderCode = row.provider_code ?? GLOBAL_PROVIDER_CODE;
|
||||
if (rowProviderCode !== providerCode) {
|
||||
continue;
|
||||
}
|
||||
const scope = row.prize_scope as PrizeScopeCode;
|
||||
if (PRIZE_SCOPE_ORDER.includes(scope)) {
|
||||
rows[scope] = row;
|
||||
}
|
||||
}
|
||||
if (providerCode !== GLOBAL_PROVIDER_CODE) {
|
||||
for (const row of resolvedDraftRows) {
|
||||
if (row.play_code !== resolvedPlayCode) {
|
||||
continue;
|
||||
}
|
||||
const rowProviderCode = row.provider_code ?? GLOBAL_PROVIDER_CODE;
|
||||
if (rowProviderCode !== GLOBAL_PROVIDER_CODE) {
|
||||
continue;
|
||||
}
|
||||
const scope = row.prize_scope as PrizeScopeCode;
|
||||
if (PRIZE_SCOPE_ORDER.includes(scope) && !rows[scope]) {
|
||||
rows[scope] = { ...row, id: 0, provider_code: providerCode };
|
||||
}
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}, [resolvedDraftRows, resolvedPlayCode]);
|
||||
}, [providerCode, resolvedDraftRows, resolvedPlayCode]);
|
||||
|
||||
const rebatePercentUi = useMemo(() => {
|
||||
const first = PRIZE_SCOPE_ORDER.map((s) => scopeRows[s]).find(Boolean);
|
||||
@@ -300,8 +340,13 @@ export function OddsConfigDocScreen({
|
||||
return ratioToPercentUi(String(first.rebate_rate));
|
||||
}, [scopeRows]);
|
||||
|
||||
function rowIndex(play_code: string, prize_scope: string): number {
|
||||
return resolvedDraftRows.findIndex((r) => r.play_code === play_code && r.prize_scope === prize_scope);
|
||||
function rowIndex(play_code: string, prize_scope: string, nextProviderCode = providerCode): number {
|
||||
return resolvedDraftRows.findIndex(
|
||||
(r) =>
|
||||
(r.provider_code ?? GLOBAL_PROVIDER_CODE) === nextProviderCode &&
|
||||
r.play_code === play_code &&
|
||||
r.prize_scope === prize_scope,
|
||||
);
|
||||
}
|
||||
|
||||
function updateOddsRow(idx: number, patch: Partial<OddsItemRow>) {
|
||||
@@ -312,35 +357,73 @@ export function OddsConfigDocScreen({
|
||||
const idx = rowIndex(resolvedPlayCode, scope);
|
||||
if (idx >= 0) {
|
||||
updateOddsRow(idx, patch);
|
||||
return;
|
||||
}
|
||||
|
||||
const globalIdx = rowIndex(resolvedPlayCode, scope, GLOBAL_PROVIDER_CODE);
|
||||
const globalRow = globalIdx >= 0 ? resolvedDraftRows[globalIdx] : null;
|
||||
if (!globalRow) {
|
||||
return;
|
||||
}
|
||||
setResolvedDraftRows((prev) => [
|
||||
...prev,
|
||||
{ ...globalRow, id: 0, provider_code: providerCode, ...patch },
|
||||
]);
|
||||
}
|
||||
|
||||
function setRebateForPlayPercent(percentStr: string) {
|
||||
const p = Number.parseFloat(percentStr);
|
||||
const rate = Number.isFinite(p) ? p / 100 : 0;
|
||||
setResolvedDraftRows((prev) =>
|
||||
prev.map((r) =>
|
||||
r.play_code === resolvedPlayCode ? { ...r, rebate_rate: String(rate) } : r,
|
||||
),
|
||||
{
|
||||
let rows = prev.map((r) =>
|
||||
r.play_code === resolvedPlayCode && (r.provider_code ?? GLOBAL_PROVIDER_CODE) === providerCode
|
||||
? { ...r, rebate_rate: String(rate) }
|
||||
: r,
|
||||
);
|
||||
|
||||
if (providerCode !== GLOBAL_PROVIDER_CODE) {
|
||||
const hasProviderRows = rows.some(
|
||||
(r) => r.play_code === resolvedPlayCode && (r.provider_code ?? GLOBAL_PROVIDER_CODE) === providerCode,
|
||||
);
|
||||
if (!hasProviderRows) {
|
||||
const copies = rows
|
||||
.filter(
|
||||
(r) =>
|
||||
r.play_code === resolvedPlayCode &&
|
||||
(r.provider_code ?? GLOBAL_PROVIDER_CODE) === GLOBAL_PROVIDER_CODE,
|
||||
)
|
||||
.map((r) => ({ ...r, id: 0, provider_code: providerCode, rebate_rate: String(rate) }));
|
||||
rows = [...rows, ...copies];
|
||||
}
|
||||
}
|
||||
|
||||
return rows;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function buildOddsItemsPayload(rows: OddsItemRow[]) {
|
||||
return rows.map((r) => ({
|
||||
play_code: r.play_code,
|
||||
provider_code: r.provider_code ?? GLOBAL_PROVIDER_CODE,
|
||||
prize_scope: r.prize_scope,
|
||||
dimension: r.dimension ?? null,
|
||||
odds_value: r.odds_value,
|
||||
rebate_rate: Number.parseFloat(String(r.rebate_rate)) || 0,
|
||||
commission_rate: Number.parseFloat(String(r.commission_rate)) || 0,
|
||||
currency_code: r.currency_code,
|
||||
extra_config_json: r.extra_config_json,
|
||||
}));
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!resolvedDetail || !canEditDraft) {
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload = resolvedDraftRows.map((r) => ({
|
||||
play_code: r.play_code,
|
||||
prize_scope: r.prize_scope,
|
||||
odds_value: r.odds_value,
|
||||
rebate_rate: Number.parseFloat(String(r.rebate_rate)) || 0,
|
||||
commission_rate: Number.parseFloat(String(r.commission_rate)) || 0,
|
||||
currency_code: r.currency_code,
|
||||
extra_config_json: r.extra_config_json,
|
||||
}));
|
||||
const d = await putOddsItems(resolvedDetail.id, payload);
|
||||
const d = await putOddsItems(resolvedDetail.id, buildOddsItemsPayload(resolvedDraftRows));
|
||||
if (workspace) {
|
||||
workspace.applyDetail(d);
|
||||
} else {
|
||||
@@ -362,7 +445,8 @@ export function OddsConfigDocScreen({
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const d = await publishOddsVersion(resolvedDetail.id);
|
||||
const saved = await putOddsItems(resolvedDetail.id, buildOddsItemsPayload(resolvedDraftRows));
|
||||
const d = await publishOddsVersion(saved.id);
|
||||
if (workspace) {
|
||||
workspace.applyDetail(d);
|
||||
} else {
|
||||
@@ -479,8 +563,28 @@ export function OddsConfigDocScreen({
|
||||
const selectedPlay = resolvedPlayCode;
|
||||
|
||||
return PRIZE_SCOPE_ORDER.map((scope) => {
|
||||
const next = resolvedDraftRows.find((r) => r.play_code === selectedPlay && r.prize_scope === scope);
|
||||
const old = activeCompareRows.find((r) => r.play_code === selectedPlay && r.prize_scope === scope);
|
||||
const next = resolvedDraftRows.find(
|
||||
(r) =>
|
||||
(r.provider_code ?? GLOBAL_PROVIDER_CODE) === providerCode &&
|
||||
r.play_code === selectedPlay &&
|
||||
r.prize_scope === scope,
|
||||
) ?? resolvedDraftRows.find(
|
||||
(r) =>
|
||||
(r.provider_code ?? GLOBAL_PROVIDER_CODE) === GLOBAL_PROVIDER_CODE &&
|
||||
r.play_code === selectedPlay &&
|
||||
r.prize_scope === scope,
|
||||
);
|
||||
const old = activeCompareRows.find(
|
||||
(r) =>
|
||||
(r.provider_code ?? GLOBAL_PROVIDER_CODE) === providerCode &&
|
||||
r.play_code === selectedPlay &&
|
||||
r.prize_scope === scope,
|
||||
) ?? activeCompareRows.find(
|
||||
(r) =>
|
||||
(r.provider_code ?? GLOBAL_PROVIDER_CODE) === GLOBAL_PROVIDER_CODE &&
|
||||
r.play_code === selectedPlay &&
|
||||
r.prize_scope === scope,
|
||||
);
|
||||
return {
|
||||
scope,
|
||||
label: prizeScopeLabel(scope, t),
|
||||
@@ -488,7 +592,7 @@ export function OddsConfigDocScreen({
|
||||
newValue: next?.odds_value ?? null,
|
||||
};
|
||||
});
|
||||
}, [activeCompareRows, resolvedDetail, resolvedDraftRows, resolvedPlayCode, t]);
|
||||
}, [activeCompareRows, providerCode, resolvedDetail, resolvedDraftRows, resolvedPlayCode, t]);
|
||||
|
||||
const catTabs: { id: CatTab; label: string }[] = [
|
||||
{ id: "all", label: t("odds.tabs.all", { ns: "config" }) },
|
||||
@@ -500,9 +604,29 @@ export function OddsConfigDocScreen({
|
||||
const activePlayLabel = resolvedPlayCode
|
||||
? resolveAdminPlayTypeDisplayName(resolvedPlayCode, i18n.language, sortedTypes.find((t) => t.play_code === resolvedPlayCode))
|
||||
: "—";
|
||||
const activeProviderLabel = providerCode === GLOBAL_PROVIDER_CODE
|
||||
? t("odds.globalProvider", { ns: "config", defaultValue: "通用赔率" })
|
||||
: (betProviders.find((provider) => provider.code === providerCode)?.name ?? providerCode);
|
||||
|
||||
const filtersInner = (
|
||||
<>
|
||||
<ConfigChipGroup label={t("betProviders.title", { ns: "config", defaultValue: "开注商" })}>
|
||||
<ConfigChip
|
||||
active={providerCode === GLOBAL_PROVIDER_CODE}
|
||||
onClick={() => setProviderCode(GLOBAL_PROVIDER_CODE)}
|
||||
>
|
||||
{t("odds.globalProvider", { ns: "config", defaultValue: "通用赔率" })}
|
||||
</ConfigChip>
|
||||
{betProviders.map((provider) => (
|
||||
<ConfigChip
|
||||
key={provider.code}
|
||||
active={providerCode === provider.code}
|
||||
onClick={() => setProviderCode(provider.code)}
|
||||
>
|
||||
{provider.name}
|
||||
</ConfigChip>
|
||||
))}
|
||||
</ConfigChipGroup>
|
||||
<ConfigChipGroup label={t("odds.category", { ns: "config" })}>
|
||||
{catTabs.map((tab) => (
|
||||
<ConfigChip
|
||||
@@ -608,8 +732,18 @@ export function OddsConfigDocScreen({
|
||||
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 hasProviderRow = providerCode === GLOBAL_PROVIDER_CODE || rowIndex(resolvedPlayCode, scope) >= 0;
|
||||
const sourceLabel = providerCode === GLOBAL_PROVIDER_CODE
|
||||
? t("odds.scopeSource.global", { ns: "config" })
|
||||
: hasProviderRow
|
||||
? t("odds.scopeSource.provider", { ns: "config" })
|
||||
: t("odds.scopeSource.inherited", { ns: "config" });
|
||||
const sourceClassName = providerCode === GLOBAL_PROVIDER_CODE
|
||||
? "border-slate-200 bg-slate-50 text-slate-600"
|
||||
: hasProviderRow
|
||||
? "border-emerald-200 bg-emerald-50 text-emerald-700"
|
||||
: "border-amber-200 bg-amber-50 text-amber-700";
|
||||
return { scope, row, hint, sourceLabel, sourceClassName };
|
||||
});
|
||||
|
||||
const mergedOddsTable = (
|
||||
@@ -621,14 +755,21 @@ export function OddsConfigDocScreen({
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{scopeEditorRows.map(({ scope, row, hint, idx }) => (
|
||||
{scopeEditorRows.map(({ scope, row, hint, sourceLabel, sourceClassName }) => (
|
||||
<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}
|
||||
<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 && idx >= 0 ? (
|
||||
{row ? (
|
||||
canEditDraft ? (
|
||||
<Input
|
||||
type="text"
|
||||
@@ -682,13 +823,20 @@ export function OddsConfigDocScreen({
|
||||
|
||||
const classicOddsGrid = (
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-4 sm:grid-cols-3">
|
||||
{scopeEditorRows.map(({ scope, row, hint, idx }) => (
|
||||
{scopeEditorRows.map(({ scope, row, hint, sourceLabel, sourceClassName }) => (
|
||||
<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 ? (
|
||||
<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)}
|
||||
{hint ? <span className="ml-1 font-normal">{hint}</span> : null}
|
||||
</Label>
|
||||
{row ? (
|
||||
<span className={cn("rounded-full border px-1.5 py-0.5 text-[11px] font-semibold", sourceClassName)}>
|
||||
{sourceLabel}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{row ? (
|
||||
canEditDraft ? (
|
||||
<Input
|
||||
type="text"
|
||||
@@ -829,7 +977,24 @@ export function OddsConfigDocScreen({
|
||||
<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="border-b border-border/50 px-4 py-4 lg:border-r lg:border-b-0">
|
||||
<aside className="space-y-4 border-b border-border/50 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}
|
||||
onClick={() => setProviderCode(GLOBAL_PROVIDER_CODE)}
|
||||
>
|
||||
{t("odds.globalProvider", { ns: "config", defaultValue: "通用赔率" })}
|
||||
</ConfigChip>
|
||||
{betProviders.map((provider) => (
|
||||
<ConfigChip
|
||||
key={provider.code}
|
||||
active={providerCode === provider.code}
|
||||
onClick={() => setProviderCode(provider.code)}
|
||||
>
|
||||
{provider.name}
|
||||
</ConfigChip>
|
||||
))}
|
||||
</ConfigChipGroup>
|
||||
<OddsConfigPlayNav
|
||||
catTab={catTab}
|
||||
onCatTabChange={setCatTab}
|
||||
@@ -841,6 +1006,7 @@ export function OddsConfigDocScreen({
|
||||
<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>
|
||||
<div className="px-4 py-4 sm:px-5">{mainBlock}</div>
|
||||
{isDraft && canManage ? (
|
||||
|
||||
@@ -325,6 +325,20 @@ export function RebateConfigDocScreen({
|
||||
});
|
||||
}
|
||||
|
||||
function buildOddsItemsPayload(rows: OddsItemRow[]) {
|
||||
return rows.map((r) => ({
|
||||
provider_code: r.provider_code ?? "GLOBAL",
|
||||
play_code: r.play_code,
|
||||
prize_scope: r.prize_scope,
|
||||
dimension: r.dimension ?? null,
|
||||
odds_value: r.odds_value,
|
||||
rebate_rate: Number.parseFloat(String(r.rebate_rate)) || 0,
|
||||
commission_rate: Number.parseFloat(String(r.commission_rate)) || 0,
|
||||
currency_code: r.currency_code,
|
||||
extra_config_json: r.extra_config_json,
|
||||
}));
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!resolvedDetail || !canEditDraft) {
|
||||
return;
|
||||
@@ -332,16 +346,7 @@ export function RebateConfigDocScreen({
|
||||
setSaving(true);
|
||||
try {
|
||||
const nextRows = applyDimensionPercentsToRows(resolvedDraftRows);
|
||||
const payload = nextRows.map((r) => ({
|
||||
play_code: r.play_code,
|
||||
prize_scope: r.prize_scope,
|
||||
odds_value: r.odds_value,
|
||||
rebate_rate: Number.parseFloat(String(r.rebate_rate)) || 0,
|
||||
commission_rate: Number.parseFloat(String(r.commission_rate)) || 0,
|
||||
currency_code: r.currency_code,
|
||||
extra_config_json: r.extra_config_json,
|
||||
}));
|
||||
const d = await putOddsItems(resolvedDetail.id, payload);
|
||||
const d = await putOddsItems(resolvedDetail.id, buildOddsItemsPayload(nextRows));
|
||||
const rows = d.items.map((it) => ({ ...it }));
|
||||
if (workspace) {
|
||||
workspace.applyDetail(d);
|
||||
@@ -367,7 +372,9 @@ export function RebateConfigDocScreen({
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const d = await publishOddsVersion(resolvedDetail.id);
|
||||
const nextRows = applyDimensionPercentsToRows(resolvedDraftRows);
|
||||
const saved = await putOddsItems(resolvedDetail.id, buildOddsItemsPayload(nextRows));
|
||||
const d = await publishOddsVersion(saved.id);
|
||||
const rows = d.items.map((it) => ({ ...it }));
|
||||
if (workspace) {
|
||||
workspace.applyDetail(d);
|
||||
|
||||
@@ -448,16 +448,24 @@ export function PlayerTicketsConsole(): React.ReactElement {
|
||||
<TableCell className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
|
||||
<AdminRowActionsMenu
|
||||
actions={
|
||||
row.player_id
|
||||
? [
|
||||
{
|
||||
key: "view-player",
|
||||
label: t("viewPlayer"),
|
||||
icon: Eye,
|
||||
href: adminPlayerDetailPath(row.player_id),
|
||||
},
|
||||
]
|
||||
: []
|
||||
[
|
||||
{
|
||||
key: "view-ticket-detail",
|
||||
label: t("viewTicketDetail"),
|
||||
icon: Eye,
|
||||
href: ticketDetailPath(row.ticket_no),
|
||||
},
|
||||
...(row.player_id
|
||||
? [
|
||||
{
|
||||
key: "view-player",
|
||||
label: t("viewPlayer"),
|
||||
icon: Eye,
|
||||
href: adminPlayerDetailPath(row.player_id),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
|
||||
@@ -29,7 +29,7 @@ import { adminPlayerDetailPath } from "@/lib/admin-player-paths";
|
||||
import { formatAdminMinorUnits } from "@/lib/money";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
import type { AdminTicketItemDetail } from "@/types/api/admin-tickets";
|
||||
import type { AdminTicketItemDetail, AdminTicketOddsSnapshotRow } from "@/types/api/admin-tickets";
|
||||
|
||||
function ticketStatusText(value: string, t: (key: string) => string): string {
|
||||
const key = `statusOptions.${value}`;
|
||||
@@ -41,6 +41,22 @@ function providerLabel(providerName?: string | null, providerCode?: string | nul
|
||||
return providerName || providerCode || "—";
|
||||
}
|
||||
|
||||
function oddsSnapshotRows(value: unknown): AdminTicketOddsSnapshotRow[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return value.filter((row): row is AdminTicketOddsSnapshotRow => typeof row === "object" && row !== null);
|
||||
}
|
||||
|
||||
function oddsMultiplierLabel(value?: number | null): string {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
return "—";
|
||||
}
|
||||
|
||||
return (value / 10000).toFixed(4);
|
||||
}
|
||||
|
||||
export function TicketDetailConsole({ ticketNo }: { ticketNo: string }) {
|
||||
const { t } = useTranslation(["tickets", "common"]);
|
||||
const tRef = useTranslationRef(["tickets", "common"]);
|
||||
@@ -97,6 +113,7 @@ export function TicketDetailConsole({ ticketNo }: { ticketNo: string }) {
|
||||
}
|
||||
|
||||
const currencyCode = data?.currency_code ?? "NPR";
|
||||
const oddsRows = oddsSnapshotRows(data?.odds_snapshot_json);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
@@ -178,6 +195,53 @@ export function TicketDetailConsole({ ticketNo }: { ticketNo: string }) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="admin-list-card">
|
||||
<CardHeader className="admin-list-header">
|
||||
<CardTitle className="admin-list-title">{t("oddsSnapshotTitle")}</CardTitle>
|
||||
<CardDescription className="text-xs">{t("oddsSnapshotHint")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="admin-list-content">
|
||||
<div className="admin-table-shell">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("provider", { defaultValue: "开注商" })}</TableHead>
|
||||
<TableHead>{t("prizeScope")}</TableHead>
|
||||
<TableHead className="text-center">{t("oddsValue")}</TableHead>
|
||||
<TableHead className="text-center">{t("rebateRate")}</TableHead>
|
||||
<TableHead className="text-center">{t("commissionRate")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading && !data ? <AdminTableLoadingRow colSpan={5} /> : null}
|
||||
{!loading && oddsRows.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="py-6 text-center text-sm text-muted-foreground">
|
||||
{t("oddsSnapshotEmpty")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : null}
|
||||
{oddsRows.map((row, index) => (
|
||||
<TableRow key={`${row.provider_code ?? "GLOBAL"}-${row.prize_scope ?? "scope"}-${index}`}>
|
||||
<TableCell className="font-mono text-xs">{row.provider_code ?? "GLOBAL"}</TableCell>
|
||||
<TableCell className="font-mono text-xs">{row.prize_scope ?? "—"}</TableCell>
|
||||
<TableCell className="text-center font-semibold tabular-nums">
|
||||
{oddsMultiplierLabel(row.odds_value ?? null)}
|
||||
</TableCell>
|
||||
<TableCell className="text-center font-mono text-xs">
|
||||
{row.rebate_rate ?? "—"}
|
||||
</TableCell>
|
||||
<TableCell className="text-center font-mono text-xs">
|
||||
{row.commission_rate ?? "—"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="admin-list-card">
|
||||
<CardHeader className="admin-list-header">
|
||||
<CardTitle className="admin-list-title">{t("combinationsTitle")}</CardTitle>
|
||||
|
||||
Reference in New Issue
Block a user