feat(bet-providers): 添加开注商管理功能,包括增删改查接口及前端页面
Some checks failed
lotteryadmin CI / build (push) Has been cancelled

This commit is contained in:
2026-07-08 15:56:41 +08:00
parent 70250696ea
commit 4f612ccbda
17 changed files with 472 additions and 6 deletions

View File

@@ -0,0 +1,26 @@
import { adminRequest } from "@/lib/admin-http";
import type {
AdminBetProviderCreatePayload,
AdminBetProviderListData,
AdminBetProviderRow,
AdminBetProviderUpdatePayload,
} from "@/types/api/admin-bet-provider";
const A = `/admin`;
export async function getAdminBetProviders(): Promise<AdminBetProviderListData> {
return adminRequest.get<AdminBetProviderListData>(`${A}/bet-providers`);
}
export async function postAdminBetProvider(
body: AdminBetProviderCreatePayload,
): Promise<AdminBetProviderRow> {
return adminRequest.post<AdminBetProviderRow>(`${A}/bet-providers`, body);
}
export async function putAdminBetProvider(
id: number,
body: AdminBetProviderUpdatePayload,
): Promise<AdminBetProviderRow> {
return adminRequest.put<AdminBetProviderRow>(`${A}/bet-providers/${id}`, body);
}

View File

@@ -13,6 +13,7 @@ export type TicketItemsListQuery = {
site_code?: string;
agent_node_id?: number;
draw_no?: string;
provider_code?: string;
status?: string[];
number?: string;
start_date?: string;

View File

@@ -0,0 +1,15 @@
import { AdminPermissionGate } from "@/components/admin/admin-permission-gate";
import { BetProviderSettingsPanel } from "@/modules/config/bet-provider-settings-panel";
import { buildPageMetadata } from "@/lib/page-metadata";
import { PRD_BET_PROVIDER_MANAGE } from "@/lib/admin-prd";
import type { Metadata } from "next";
export const metadata: Metadata = buildPageMetadata("config", "betProviders.title");
export default function AdminBetProvidersPage() {
return (
<AdminPermissionGate requiredAny={[PRD_BET_PROVIDER_MANAGE]}>
<BetProviderSettingsPanel />
</AdminPermissionGate>
);
}

View File

@@ -58,6 +58,8 @@
"currenciesTitle": "Currencies",
"jackpotDesc": "Pool parameters and ledger records",
"jackpotTitle": "Jackpot",
"betProvidersDesc": "Manage selectable betting providers",
"betProvidersTitle": "Bet providers",
"oddsDesc": "Odds matrix and base rebate in one version stream",
"oddsTitle": "Odds & base rebate",
"playsDesc": "Play switches, limits, and rule copy",
@@ -68,6 +70,9 @@
"settingsTitle": "System settings",
"title": "Operations configuration"
},
"betProviders": {
"title": "Bet providers"
},
"integrationSites": {
"adminAccountCreated": "Created site admin account {{username}}",
"adminAccountSectionDescription": "Creating a site will also create one admin account bound to that site.",

View File

@@ -58,6 +58,8 @@
"currenciesTitle": "मुद्रा व्यवस्थापन",
"jackpotDesc": "पूल प्यारामिटर र लेजर",
"jackpotTitle": "ज्याकपट",
"betProvidersDesc": "छनोट गर्न मिल्ने बेट प्रदायक व्यवस्थापन",
"betProvidersTitle": "बेट प्रदायक",
"oddsDesc": "बाधा म्याट्रिक्स र रिबेट, एउटै संस्करण",
"oddsTitle": "बाधा र रिबेट",
"playsDesc": "खेल स्विच, सीमा र नियम पाठ",
@@ -68,6 +70,9 @@
"settingsTitle": "प्रणाली सेटिङ",
"title": "सञ्चालन कन्फिगरेसन अवलोकन"
},
"betProviders": {
"title": "बेट प्रदायक"
},
"integrationSites": {
"adminAccountCreated": "साइट एडमिन खाता {{username}} सिर्जना भयो",
"adminAccountSectionDescription": "साइट सिर्जना गर्दा त्यस साइटसँग बाँधिएको एउटा एडमिन खाता पनि सिर्जना हुन्छ।",

View File

@@ -29,6 +29,8 @@
"oddsDesc": "赔率矩阵与基础回水,版本一体发布",
"jackpotTitle": "奖池",
"jackpotDesc": "奖池参数与进账流水",
"betProvidersTitle": "开注商管理",
"betProvidersDesc": "维护玩家可勾选的下注公司",
"riskCapTitle": "限额版本",
"riskCapDesc": "号码赔付封顶与占用视图",
"integrationTitle": "接入站点",
@@ -38,6 +40,9 @@
"currenciesTitle": "币种管理",
"currenciesDesc": "维护支持的币种与精度"
},
"betProviders": {
"title": "开注商管理"
},
"integrationSites": {
"title": "接入站点",
"description": "由运营在后台维护各主站对接参数并通过权限控制谁能查看或修改。site_code 创建后不可修改。",

View File

@@ -24,6 +24,7 @@ export const PRD_DRAW_RESULT_VIEW = "prd.draw_result.view" as const;
export const PRD_DRAW_REOPEN_MANAGE = "prd.draw_reopen.manage" as const;
export const PRD_PLAY_SWITCH_MANAGE = "prd.play_switch.manage" as const;
export const PRD_BET_PROVIDER_MANAGE = "prd.bet_provider.manage" as const;
export const PRD_ODDS_MANAGE = "prd.odds.manage" as const;
export const PRD_REBATE_MANAGE = "prd.rebate.manage" as const;
export const PRD_REBATE_VIEW = "prd.rebate.view" as const;
@@ -169,6 +170,7 @@ export const PRD_INTEGRATION_ACCESS_ANY = [PRD_INTEGRATION_VIEW, PRD_INTEGRATION
/** 运营配置首页 */
export const PRD_CONFIG_HUB_ACCESS_ANY = [
...PRD_RULES_PLAYS_ACCESS_ANY,
PRD_BET_PROVIDER_MANAGE,
...PRD_RULES_ODDS_ACCESS_ANY,
...PRD_JACKPOT_ACCESS_ANY,
...PRD_RISK_CAP_ACCESS_ANY,

View File

@@ -1,5 +1,6 @@
import type { LucideIcon } from "lucide-react";
import {
Building2,
CalendarClock,
ClipboardList,
Coins,
@@ -34,6 +35,7 @@ export const adminNavIconBySegment: Record<AdminNavItem["segment"], LucideIcon>
players: Users,
draws: CalendarClock,
rules_plays: ClipboardList,
bet_providers: Building2,
rules_odds: Percent,
jackpot: Trophy,
risk_cap: ShieldAlert,

View File

@@ -14,6 +14,7 @@ export type AdminNavSegment =
| "players"
| "draws"
| "rules_plays"
| "bet_providers"
| "rules_odds"
| "jackpot"
| "risk_cap"

View File

@@ -0,0 +1,308 @@
"use client";
import { Pencil } from "lucide-react";
import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import {
getAdminBetProviders,
postAdminBetProvider,
putAdminBetProvider,
} from "@/api/admin-bet-providers";
import { AdminPageCard } from "@/components/admin/admin-page-card";
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
import { AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { useAsyncEffect } from "@/hooks/use-async-effect";
import { useTranslationRef } from "@/hooks/use-translation-ref";
import { LotteryApiBizError } from "@/types/api/errors";
import type { AdminBetProviderRow } from "@/types/api/admin-bet-provider";
type ProviderFormState = {
code: string;
name: string;
is_enabled: boolean;
sort_order: string;
};
const EMPTY_FORM: ProviderFormState = {
code: "",
name: "",
is_enabled: true,
sort_order: "0",
};
function toFormState(row: AdminBetProviderRow): ProviderFormState {
return {
code: row.code,
name: row.name,
is_enabled: row.is_enabled,
sort_order: String(row.sort_order),
};
}
function parseSortOrder(value: string): number {
const parsed = Number(value);
return Number.isFinite(parsed) && parsed >= 0 ? Math.floor(parsed) : 0;
}
export function BetProviderSettingsPanel() {
const { t } = useTranslation(["config", "adminUsers"]);
const tRef = useTranslationRef(["config", "adminUsers"]);
const [items, setItems] = useState<AdminBetProviderRow[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [dialogOpen, setDialogOpen] = useState(false);
const [mode, setMode] = useState<"create" | "edit">("create");
const [editingId, setEditingId] = useState<number | null>(null);
const [form, setForm] = useState<ProviderFormState>(EMPTY_FORM);
const load = useCallback(async () => {
setLoading(true);
try {
const data = await getAdminBetProviders();
setItems(data.items);
} catch (error) {
toast.error(
error instanceof LotteryApiBizError
? error.message
: tRef.current("betProviders.loadFailed", {
ns: "config",
defaultValue: "加载开注商失败",
}),
);
} finally {
setLoading(false);
}
}, [tRef]);
useAsyncEffect(() => {
void load();
}, []);
function openCreate(): void {
setMode("create");
setEditingId(null);
setForm(EMPTY_FORM);
setDialogOpen(true);
}
function openEdit(row: AdminBetProviderRow): void {
setMode("edit");
setEditingId(row.id);
setForm(toFormState(row));
setDialogOpen(true);
}
function updateForm<K extends keyof ProviderFormState>(key: K, value: ProviderFormState[K]): void {
setForm((prev) => ({ ...prev, [key]: value }));
}
async function handleSubmit(): Promise<void> {
const name = form.name.trim();
const sortOrder = parseSortOrder(form.sort_order);
if (name === "" || (mode === "create" && form.code.trim() === "")) {
toast.error(t("betProviders.form.required", { ns: "config", defaultValue: "请填写代码和名称" }));
return;
}
setSaving(true);
try {
if (mode === "create") {
await postAdminBetProvider({
code: form.code.trim().toUpperCase(),
name,
is_enabled: form.is_enabled,
sort_order: sortOrder,
});
toast.success(t("betProviders.createSuccess", { ns: "config", defaultValue: "已创建开注商" }));
} else if (editingId !== null) {
await putAdminBetProvider(editingId, {
name,
is_enabled: form.is_enabled,
sort_order: sortOrder,
});
toast.success(t("betProviders.updateSuccess", { ns: "config", defaultValue: "已更新开注商" }));
}
setDialogOpen(false);
await load();
} catch (error) {
toast.error(
error instanceof LotteryApiBizError
? error.message
: t("betProviders.saveFailed", { ns: "config", defaultValue: "保存开注商失败" }),
);
} finally {
setSaving(false);
}
}
return (
<>
<AdminPageCard
title={t("betProviders.title", { ns: "config", defaultValue: "开注商管理" })}
description={t("betProviders.description", {
ns: "config",
defaultValue: "维护玩家下注时可勾选的公司;一期仅控制显示与启用状态,不独立配置赔率。",
})}
actions={<Button onClick={openCreate}>{t("betProviders.create", { ns: "config", defaultValue: "新增开注商" })}</Button>}
>
<div className="admin-table-shell">
<Table id="admin-bet-providers-table">
<TableHeader>
<TableRow>
<TableHead className="whitespace-nowrap">{t("betProviders.table.code", { ns: "config", defaultValue: "代码" })}</TableHead>
<TableHead>{t("betProviders.table.name", { ns: "config", defaultValue: "名称" })}</TableHead>
<TableHead className="whitespace-nowrap">{t("betProviders.table.enabled", { ns: "config", defaultValue: "状态" })}</TableHead>
<TableHead className="text-center">{t("betProviders.table.sort", { ns: "config", defaultValue: "排序" })}</TableHead>
<TableHead className="sticky right-0 z-20 w-14 bg-muted text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
{t("betProviders.table.actions", { ns: "config", defaultValue: "操作" })}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={5} className="text-center text-sm text-muted-foreground">
{t("betProviders.loading", { ns: "config", defaultValue: "加载中..." })}
</TableCell>
</TableRow>
) : items.length === 0 ? (
<AdminTableNoResourceRow colSpan={5} cellClassName="text-center" />
) : (
items.map((row) => (
<TableRow key={row.id}>
<TableCell className="font-mono font-semibold">{row.code}</TableCell>
<TableCell>
{row.name}
{row.is_default ? (
<span className="ml-2 rounded-full bg-muted px-2 py-0.5 text-[11px] text-muted-foreground">
{t("betProviders.default", { ns: "config", defaultValue: "默认" })}
</span>
) : null}
</TableCell>
<TableCell>
<AdminStatusBadge status={row.is_enabled ? "enabled" : "disabled"}>
{row.is_enabled
? t("system.states.enabled", { ns: "config" })
: t("system.states.disabled", { ns: "config" })}
</AdminStatusBadge>
</TableCell>
<TableCell className="text-center font-mono text-xs">{row.sort_order}</TableCell>
<TableCell className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
<AdminRowActionsMenu
actions={[
{
key: "edit",
label: t("betProviders.edit", { ns: "config", defaultValue: "编辑" }),
icon: Pencil,
onClick: () => openEdit(row),
},
]}
/>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</AdminPageCard>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent showCloseButton className="sm:max-w-md">
<DialogHeader>
<DialogTitle>
{t(mode === "create" ? "betProviders.dialog.createTitle" : "betProviders.dialog.editTitle", {
ns: "config",
defaultValue: mode === "create" ? "新增开注商" : "编辑开注商",
})}
</DialogTitle>
<DialogDescription>
{t("betProviders.dialog.description", {
ns: "config",
defaultValue: "代码创建后不可修改;停用后玩家端不可再选择。",
})}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="provider-code">{t("betProviders.form.code", { ns: "config", defaultValue: "代码" })}</Label>
<Input
id="provider-code"
value={form.code}
placeholder="SG"
onChange={(e) => updateForm("code", e.target.value.toUpperCase())}
disabled={saving || mode === "edit"}
/>
</div>
<div className="space-y-2">
<Label htmlFor="provider-name">{t("betProviders.form.name", { ns: "config", defaultValue: "名称" })}</Label>
<Input
id="provider-name"
value={form.name}
placeholder="Singapore"
onChange={(e) => updateForm("name", e.target.value)}
disabled={saving}
/>
</div>
<div className="space-y-2">
<Label htmlFor="provider-sort">{t("betProviders.form.sort", { ns: "config", defaultValue: "排序" })}</Label>
<Input
id="provider-sort"
type="number"
min={0}
value={form.sort_order}
onChange={(e) => updateForm("sort_order", e.target.value)}
disabled={saving}
/>
</div>
<div className="flex items-center justify-between rounded-xl border border-border/70 p-3">
<div className="space-y-1">
<p className="text-sm font-medium">{t("betProviders.form.enabled", { ns: "config", defaultValue: "启用" })}</p>
<p className="text-xs text-muted-foreground">
{t("betProviders.form.enabledHint", { ns: "config", defaultValue: "关闭后不会出现在玩家端可选公司里" })}
</p>
</div>
<Switch
checked={form.is_enabled}
onCheckedChange={(checked) => updateForm("is_enabled", checked)}
disabled={saving}
aria-label={t("betProviders.form.enabled", { ns: "config", defaultValue: "启用" })}
/>
</div>
<div className="flex justify-end gap-3">
<Button variant="outline" onClick={() => setDialogOpen(false)} disabled={saving}>
{t("actions.cancel", { ns: "adminUsers" })}
</Button>
<Button onClick={() => void handleSubmit()} disabled={saving}>
{saving ? t("saving", { ns: "adminUsers" }) : t("actions.save", { ns: "adminUsers" })}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -6,7 +6,12 @@ import { ChevronRight } from "lucide-react";
import { ModuleScaffold } from "@/components/admin/module-scaffold";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { PRD_INTEGRATION_ACCESS_ANY, PRD_SETTINGS_ACCESS_ANY, PRD_CURRENCY_MANAGE } from "@/lib/admin-prd";
import {
PRD_BET_PROVIDER_MANAGE,
PRD_CURRENCY_MANAGE,
PRD_INTEGRATION_ACCESS_ANY,
PRD_SETTINGS_ACCESS_ANY,
} from "@/lib/admin-prd";
import { useAdminProfile } from "@/stores/admin-session";
import { adminHasAnyPermission } from "@/lib/admin-permissions";
@@ -36,6 +41,12 @@ const HUB_CARDS: HubCard[] = [
descKey: "hub.jackpotDesc",
requiredAny: ["prd.jackpot.manage", "prd.jackpot.view"],
},
{
href: "/admin/config/bet-providers",
titleKey: "hub.betProvidersTitle",
descKey: "hub.betProvidersDesc",
requiredAny: [PRD_BET_PROVIDER_MANAGE],
},
{
href: "/admin/risk/cap",
titleKey: "hub.riskCapTitle",

View File

@@ -58,6 +58,10 @@ function ticketStatusText(status: string, t: (key: string, opts?: { ns?: string
return translated === key ? status : translated;
}
function providerLabel(providerName?: string | null, providerCode?: string | null): string {
return providerName || providerCode || "—";
}
function walletStatusLabel(status: string, t: (key: string, opts?: { ns?: string }) => string): string {
switch (status) {
case "processing":
@@ -438,6 +442,7 @@ export function PlayerDetailConsole({ playerId }: { playerId: number }) {
<TableHead>{t("ticketNo", { ns: "tickets" })}</TableHead>
<TableHead>{t("orderNo", { ns: "tickets" })}</TableHead>
<TableHead>{t("drawNo", { ns: "tickets" })}</TableHead>
<TableHead>{t("provider", { ns: "tickets", defaultValue: "开注商" })}</TableHead>
<TableHead>{t("playCode", { ns: "tickets" })}</TableHead>
<TableHead>{t("number", { ns: "tickets" })}</TableHead>
<TableHead className="text-center">{t("actualDeduct", { ns: "tickets" })}</TableHead>
@@ -450,7 +455,7 @@ export function PlayerDetailConsole({ playerId }: { playerId: number }) {
</TableHeader>
<TableBody>
{ticketsLoading && tickets.length === 0 ? (
<AdminTableLoadingRow colSpan={10} />
<AdminTableLoadingRow colSpan={12} />
) : null}
{tickets.map((row) => (
<TableRow key={row.ticket_no}>
@@ -464,6 +469,9 @@ export function PlayerDetailConsole({ playerId }: { playerId: number }) {
</TableCell>
<TableCell className="font-mono text-xs">{row.order_no ?? "—"}</TableCell>
<TableCell className="font-mono text-xs">{row.draw_no ?? "—"}</TableCell>
<TableCell className="font-mono text-xs">
{providerLabel(row.provider_name, row.provider_code)}
</TableCell>
<TableCell className="text-xs">{playCodeLabel(row.play_code)}</TableCell>
<TableCell className="font-mono text-xs">{row.original_number ?? "—"}</TableCell>
<TableCell className="text-center text-xs tabular-nums">
@@ -491,7 +499,7 @@ export function PlayerDetailConsole({ playerId }: { playerId: number }) {
</TableRow>
))}
{!ticketsLoading && tickets.length === 0 ? (
<AdminTableNoResourceRow colSpan={10} className="text-muted-foreground" />
<AdminTableNoResourceRow colSpan={12} className="text-muted-foreground" />
) : null}
</TableBody>
</Table>

View File

@@ -61,6 +61,7 @@ const TICKET_STATUS_OPTIONS = [
type TicketFilters = {
playerQuery: string;
drawNo: string;
providerCode: string;
numberKeyword: string;
startDate: string;
endDate: string;
@@ -70,6 +71,7 @@ type TicketFilters = {
const emptyTicketFilters: TicketFilters = {
playerQuery: "",
drawNo: "",
providerCode: "",
numberKeyword: "",
startDate: "",
endDate: "",
@@ -103,6 +105,10 @@ function ticketDetailPath(ticketNo: string): string {
return `/admin/tickets/${encodeURIComponent(ticketNo)}`;
}
function providerLabel(providerName?: string | null, providerCode?: string | null): string {
return providerName || providerCode || "—";
}
function TicketFilterField({
id,
label,
@@ -164,6 +170,7 @@ export function PlayerTicketsConsole(): React.ReactElement {
per_page: perPage,
...query,
draw_no: applied.drawNo.trim() || undefined,
provider_code: applied.providerCode.trim().toUpperCase() || undefined,
status: applied.statuses.length > 0 ? applied.statuses : undefined,
number: applied.numberKeyword.trim() || undefined,
start_date: applied.startDate || undefined,
@@ -188,6 +195,7 @@ export function PlayerTicketsConsole(): React.ReactElement {
...draft,
playerQuery: draft.playerQuery.trim(),
drawNo: draft.drawNo.trim(),
providerCode: draft.providerCode.trim().toUpperCase(),
numberKeyword: draft.numberKeyword.trim(),
});
setPage(1);
@@ -263,6 +271,22 @@ export function PlayerTicketsConsole(): React.ReactElement {
}}
/>
</TicketFilterField>
<TicketFilterField id="pt-provider" label={t("provider", { defaultValue: "开注商" })}>
<Input
id="pt-provider"
className="h-8 w-full font-mono uppercase"
placeholder="SG / MY / TH"
value={draft.providerCode}
onChange={(e) =>
setDraft((current) => ({ ...current, providerCode: e.target.value.toUpperCase() }))
}
onKeyDown={(e) => {
if (e.key === "Enter") {
runSearch();
}
}}
/>
</TicketFilterField>
<TicketFilterField id="pt-date-range" label={t("placedDateRange")}>
<AdminDateRangeField
id="pt-date-range"
@@ -322,7 +346,7 @@ export function PlayerTicketsConsole(): React.ReactElement {
</div>
</div>
{applied.playerQuery || applied.drawNo || applied.numberKeyword || applied.startDate || applied.endDate || applied.statuses.length > 0 ? (
{applied.playerQuery || applied.drawNo || applied.providerCode || applied.numberKeyword || applied.startDate || applied.endDate || applied.statuses.length > 0 ? (
<p className="text-muted-foreground text-sm">
{applied.playerQuery ? (
<>
@@ -337,6 +361,12 @@ export function PlayerTicketsConsole(): React.ReactElement {
{t("drawNo")}<span className="font-mono">{applied.drawNo}</span>
</>
) : null}
{applied.providerCode ? (
<>
{" · "}
{t("provider", { defaultValue: "开注商" })}<span className="font-mono">{applied.providerCode}</span>
</>
) : null}
</p>
) : null}
@@ -353,6 +383,7 @@ export function PlayerTicketsConsole(): React.ReactElement {
<TableHead>{t("fundingMode", { ns: "players" })}</TableHead>
<TableHead>{t("orderNo")}</TableHead>
<TableHead>{t("drawNo")}</TableHead>
<TableHead>{t("provider", { defaultValue: "开注商" })}</TableHead>
<TableHead>{t("playCode")}</TableHead>
<TableHead>{t("number")}</TableHead>
<TableHead className="text-center">{t("betAmount")}</TableHead>
@@ -367,9 +398,9 @@ export function PlayerTicketsConsole(): React.ReactElement {
</TableHeader>
<TableBody>
{loading && !data ? (
<AdminTableLoadingRow colSpan={18} />
<AdminTableLoadingRow colSpan={19} />
) : !data || data.items.length === 0 ? (
<AdminTableNoResourceRow colSpan={18} />
<AdminTableNoResourceRow colSpan={19} />
) : (
data.items.map((row) => {
const winLabel = row.jackpot_win_amount > 0
@@ -392,6 +423,9 @@ export function PlayerTicketsConsole(): React.ReactElement {
</TableCell>
<TableCell className="font-mono text-xs">{row.order_no ?? "—"}</TableCell>
<TableCell className="font-mono text-xs">{row.draw_no ?? "—"}</TableCell>
<TableCell className="font-mono text-xs">
{providerLabel(row.provider_name, row.provider_code)}
</TableCell>
<TableCell className="text-xs">{playCodeLabel(row.play_code)}</TableCell>
<TableCell className="font-mono text-xs">{row.original_number ?? "—"}</TableCell>
<TableCell className="text-center tabular-nums text-xs">

View File

@@ -37,6 +37,10 @@ function ticketStatusText(value: string, t: (key: string) => string): string {
return translated === key ? value : translated;
}
function providerLabel(providerName?: string | null, providerCode?: string | null): string {
return providerName || providerCode || "—";
}
export function TicketDetailConsole({ ticketNo }: { ticketNo: string }) {
const { t } = useTranslation(["tickets", "common"]);
const tRef = useTranslationRef(["tickets", "common"]);
@@ -126,6 +130,12 @@ export function TicketDetailConsole({ ticketNo }: { ticketNo: string }) {
<span className="text-muted-foreground">{t("number")}</span>
<span className="font-mono font-medium">{data.original_number ?? "—"}</span>
</p>
<p>
<span className="text-muted-foreground">{t("provider", { defaultValue: "开注商" })}</span>
<span className="font-mono font-medium">
{providerLabel(data.provider_name, data.provider_code)}
</span>
</p>
<p>
<span className="text-muted-foreground">{t("combinationCount")}</span>
<span className="font-semibold tabular-nums">{data.combination_count}</span>

View File

@@ -0,0 +1,27 @@
export type AdminBetProviderRow = {
id: number;
code: string;
name: string;
is_enabled: boolean;
sort_order: number;
is_default: boolean;
created_at: string | null;
updated_at: string | null;
};
export type AdminBetProviderListData = {
items: AdminBetProviderRow[];
};
export type AdminBetProviderCreatePayload = {
code: string;
name: string;
is_enabled?: boolean;
sort_order?: number;
};
export type AdminBetProviderUpdatePayload = {
name?: string;
is_enabled?: boolean;
sort_order?: number;
};

View File

@@ -3,6 +3,8 @@ export type AdminPlayerTicketItemRow = {
order_no: string | null;
draw_no: string | null;
currency_code: string | null;
provider_code?: string | null;
provider_name?: string | null;
play_code: string;
original_number: string | null;
/** @deprecated 历史字段,单位为 minor优先使用 total_bet_amount_minor */

View File

@@ -14,6 +14,8 @@ export type AdminTicketItemRow = {
order_no: string | null;
draw_no: string | null;
currency_code: string | null;
provider_code?: string | null;
provider_name?: string | null;
play_code: string;
original_number: string | null;
/** @deprecated 历史字段,单位为 minor优先使用 total_bet_amount_minor */
@@ -63,6 +65,8 @@ export type AdminTicketItemDetail = {
agent_node_id: number | null;
agent_code: string | null;
agent_name: string | null;
provider_code?: string | null;
provider_name?: string | null;
play_code: string;
dimension: number | null;
digit_slot: number | null;