feat(bet-providers): 添加开注商管理功能,包括增删改查接口及前端页面
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:
26
src/api/admin-bet-providers.ts
Normal file
26
src/api/admin-bet-providers.ts
Normal 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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
15
src/app/admin/(shell)/config/bet-providers/page.tsx
Normal file
15
src/app/admin/(shell)/config/bet-providers/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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.",
|
||||
|
||||
@@ -58,6 +58,8 @@
|
||||
"currenciesTitle": "मुद्रा व्यवस्थापन",
|
||||
"jackpotDesc": "पूल प्यारामिटर र लेजर",
|
||||
"jackpotTitle": "ज्याकपट",
|
||||
"betProvidersDesc": "छनोट गर्न मिल्ने बेट प्रदायक व्यवस्थापन",
|
||||
"betProvidersTitle": "बेट प्रदायक",
|
||||
"oddsDesc": "बाधा म्याट्रिक्स र रिबेट, एउटै संस्करण",
|
||||
"oddsTitle": "बाधा र रिबेट",
|
||||
"playsDesc": "खेल स्विच, सीमा र नियम पाठ",
|
||||
@@ -68,6 +70,9 @@
|
||||
"settingsTitle": "प्रणाली सेटिङ",
|
||||
"title": "सञ्चालन कन्फिगरेसन अवलोकन"
|
||||
},
|
||||
"betProviders": {
|
||||
"title": "बेट प्रदायक"
|
||||
},
|
||||
"integrationSites": {
|
||||
"adminAccountCreated": "साइट एडमिन खाता {{username}} सिर्जना भयो",
|
||||
"adminAccountSectionDescription": "साइट सिर्जना गर्दा त्यस साइटसँग बाँधिएको एउटा एडमिन खाता पनि सिर्जना हुन्छ।",
|
||||
|
||||
@@ -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 创建后不可修改。",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -14,6 +14,7 @@ export type AdminNavSegment =
|
||||
| "players"
|
||||
| "draws"
|
||||
| "rules_plays"
|
||||
| "bet_providers"
|
||||
| "rules_odds"
|
||||
| "jackpot"
|
||||
| "risk_cap"
|
||||
|
||||
308
src/modules/config/bet-provider-settings-panel.tsx
Normal file
308
src/modules/config/bet-provider-settings-panel.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
27
src/types/api/admin-bet-provider.ts
Normal file
27
src/types/api/admin-bet-provider.ts
Normal 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;
|
||||
};
|
||||
@@ -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 */
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user