feat(integration): 新增 site_code 支持并增强玩家与注单管理界面

在后台玩家与注单相关 API 中新增 site_code 参数,支持按站点筛选数据。
更新 PlayersConsole 与 PlayerTicketsConsole UI 组件,新增站点选择筛选功能。
增强国际化支持,在英文与中文语言包中新增站点相关文案。
优化配置中心页面,新增跳转至集成站点管理的入口,提升后台导航体验。
This commit is contained in:
2026-05-27 13:36:44 +08:00
parent e87229c1b7
commit 5eabbcf0ee
17 changed files with 1126 additions and 3 deletions

View File

@@ -0,0 +1,66 @@
import { adminRequest } from "@/lib/admin-http";
import { API_V1_PREFIX } from "./paths";
import type {
AdminIntegrationSiteConnectivityResult,
AdminIntegrationSiteCreatePayload,
AdminIntegrationSiteDetail,
AdminIntegrationSiteListData,
AdminIntegrationSiteParameterSheet,
AdminIntegrationSiteUpdatePayload,
AdminIntegrationSiteWithSecrets,
} from "@/types/api/admin-integration-site";
const A = `${API_V1_PREFIX}/admin`;
export async function getAdminIntegrationSites(): Promise<AdminIntegrationSiteListData> {
return adminRequest.get<AdminIntegrationSiteListData>(`${A}/integration-sites`);
}
export async function getAdminIntegrationSite(id: number): Promise<AdminIntegrationSiteDetail> {
return adminRequest.get<AdminIntegrationSiteDetail>(`${A}/integration-sites/${id}`);
}
export async function postAdminIntegrationSite(
body: AdminIntegrationSiteCreatePayload,
): Promise<AdminIntegrationSiteWithSecrets> {
return adminRequest.post<AdminIntegrationSiteWithSecrets>(`${A}/integration-sites`, body);
}
export async function putAdminIntegrationSite(
id: number,
body: AdminIntegrationSiteUpdatePayload,
): Promise<AdminIntegrationSiteDetail> {
return adminRequest.put<AdminIntegrationSiteDetail>(`${A}/integration-sites/${id}`, body);
}
export async function postAdminIntegrationSiteRotateSecrets(
id: number,
): Promise<AdminIntegrationSiteWithSecrets> {
return adminRequest.post<AdminIntegrationSiteWithSecrets>(
`${A}/integration-sites/${id}/rotate-secrets`,
{},
);
}
export async function postAdminIntegrationSiteConnectivityTest(
id: number,
body: { site_player_id: string; currency_code?: string },
): Promise<AdminIntegrationSiteConnectivityResult> {
return adminRequest.post<AdminIntegrationSiteConnectivityResult>(
`${A}/integration-sites/${id}/connectivity-test`,
body,
);
}
export async function getAdminIntegrationSiteExport(
id: number,
format: "json" | "csv" = "json",
): Promise<AdminIntegrationSiteParameterSheet> {
return adminRequest.get<AdminIntegrationSiteParameterSheet>(
`${A}/integration-sites/${id}/export`,
{ params: { format } },
);
}

View File

@@ -17,6 +17,7 @@ export async function getAdminPlayers(params?: {
per_page?: number;
keyword?: string;
status?: number;
site_code?: string;
}): Promise<AdminPlayerListData> {
return adminRequest.get<AdminPlayerListData>(`${A}/players`, { params });
}

View File

@@ -11,6 +11,7 @@ export type TicketItemsListQuery = {
per_page?: number;
player_id?: number;
player_account?: string;
site_code?: string;
draw_no?: string;
status?: string[];
number?: string;

View File

@@ -0,0 +1,14 @@
import { ModuleScaffold } from "@/components/admin/module-scaffold";
import { IntegrationSitesConsole } from "@/modules/integration/integration-sites-console";
import { buildPageMetadata } from "@/lib/page-metadata";
import type { Metadata } from "next";
export const metadata: Metadata = buildPageMetadata("config", "integrationSites.title");
export default function AdminIntegrationSitesPage() {
return (
<ModuleScaffold>
<IntegrationSitesConsole />
</ModuleScaffold>
);
}

View File

@@ -0,0 +1,66 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { getAdminIntegrationSites } from "@/api/admin-integration-sites";
import { adminHasAnyPermission } from "@/lib/admin-permissions";
import { useAdminProfile } from "@/stores/admin-session";
export type AdminSiteCodeOption = {
code: string;
name: string;
};
/**
* 接入站点下拉(已按当前管理员站点权限过滤)。
*/
export function useAdminSiteCodeOptions(): {
sites: AdminSiteCodeOption[];
loading: boolean;
canChooseSite: boolean;
reload: () => Promise<void>;
} {
const profile = useAdminProfile();
const canLoad = adminHasAnyPermission(profile?.permissions, [
"prd.integration.view",
"prd.integration.manage",
]);
const [sites, setSites] = useState<AdminSiteCodeOption[]>([]);
const [loading, setLoading] = useState(false);
const reload = useCallback(async () => {
if (!canLoad) {
setSites([]);
return;
}
setLoading(true);
try {
const data = await getAdminIntegrationSites();
setSites(
data.items.map((row) => ({
code: row.code,
name: row.name,
})),
);
} catch {
setSites([]);
} finally {
setLoading(false);
}
}, [canLoad]);
useEffect(() => {
queueMicrotask(() => {
void reload();
});
}, [reload]);
return {
sites,
loading,
canChooseSite: canLoad && sites.length > 0,
reload,
};
}

View File

@@ -29,7 +29,77 @@
"jackpotTitle": "Jackpot",
"jackpotDesc": "Pool parameters and ledger records",
"riskCapTitle": "Risk cap rules",
"riskCapDesc": "Per-number payout caps and occupancy"
"riskCapDesc": "Per-number payout caps and occupancy",
"integrationTitle": "Integration sites",
"integrationDesc": "site_code, JWT secrets, partner wallet URL, iframe allowlist"
},
"integrationSites": {
"title": "Integration sites",
"description": "Maintain partner integration settings in admin. site_code cannot be changed after creation.",
"create": "New site",
"edit": "Edit",
"save": "Save",
"saving": "Saving…",
"cancel": "Cancel",
"copy": "Copy",
"loading": "Loading…",
"empty": "No integration sites",
"loadFailed": "Failed to load integration sites",
"saveFailed": "Save failed",
"createSuccess": "Created site {{code}}",
"updateSuccess": "Updated site {{code}}",
"connectivityTest": "Test connectivity",
"connectivityTitle": "Partner wallet connectivity",
"connectivityDescription": "Call the balance API for site {{code}} using a test player.",
"connectivityPlayerId": "Test site_player_id",
"connectivityRun": "Run test",
"connectivityRunning": "Testing…",
"connectivitySuccess": "Connectivity OK",
"connectivityFailed": "Connectivity failed",
"exportParams": "Export params",
"exportSuccess": "Exported parameter sheet for {{code}}",
"exportFailed": "Export failed",
"rotateSecrets": "Rotate secrets",
"rotateSuccess": "Rotated secrets for {{code}}",
"rotateFailed": "Failed to rotate secrets",
"rotateConfirmTitle": "Rotate secrets?",
"rotateConfirmDescription": "New SSO and wallet keys will be generated for {{code}}. Old keys stop working immediately.",
"rotateConfirm": "Rotate",
"secretsTitle": "Save these secrets now",
"secretsDescription": "Secrets for {{code}} are shown only once.",
"secretsDismiss": "I have saved them",
"copied": "Copied {{field}}",
"copyFailed": "Copy failed",
"noPermission": "No permission to view integration sites",
"codeImmutable": "site_code cannot be changed after creation",
"statusEnabled": "Enabled",
"statusDisabled": "Disabled",
"dialogCreateTitle": "New integration site",
"dialogEditTitle": "Edit integration site",
"dialogDescription": "Default wallet paths are fine unless the partner uses custom URLs.",
"form": {
"required": "Site name is required",
"codeRequired": "site_code is required"
},
"columns": {
"code": "site_code",
"name": "Name",
"status": "Status",
"walletUrl": "Wallet API",
"actions": "Actions"
},
"fields": {
"code": "site_code",
"name": "Site name",
"currency": "Default currency",
"status": "Status",
"walletApiUrl": "Partner wallet base URL",
"lotteryH5BaseUrl": "Lottery H5 base URL (optional)",
"iframeOrigins": "iframe allowlist (one origin per line)",
"notes": "Notes",
"ssoSecret": "SSO secret",
"walletApiKey": "Wallet API key"
}
},
"versionStatus": {
"active": "Active",

View File

@@ -3,6 +3,8 @@
"listTitle": "Player list",
"createPlayer": "Create player",
"searchPlaceholder": "Search by player ID / username / nickname",
"filterSite": "Site",
"filterAllSites": "All sites",
"search": "Search",
"refresh": "Refresh",
"loadFailed": "Failed to load player list",

View File

@@ -1,6 +1,8 @@
{
"title": "Ticket list",
"playerTicketQuery": "Ticket query",
"filterSite": "Site",
"filterAllSites": "All sites",
"playerId": "Player ID / account",
"invalidPlayerId": "Enter a valid player ID or account",
"playerIdPlaceholder": "Leave blank for all tickets; enter player ID or account",

View File

@@ -29,7 +29,77 @@
"jackpotTitle": "奖池",
"jackpotDesc": "奖池参数与进账流水",
"riskCapTitle": "限额版本",
"riskCapDesc": "号码赔付封顶与占用视图"
"riskCapDesc": "号码赔付封顶与占用视图",
"integrationTitle": "主站接入站点",
"integrationDesc": "site_code、JWT 密钥、主站钱包 URL 与 iframe 白名单"
},
"integrationSites": {
"title": "主站接入站点",
"description": "由运营在后台维护各主站对接参数并通过权限控制谁能查看或修改。site_code 创建后不可修改。",
"create": "新建站点",
"edit": "编辑",
"save": "保存",
"saving": "保存中…",
"cancel": "取消",
"copy": "复制",
"loading": "加载中…",
"empty": "暂无接入站点",
"loadFailed": "加载接入站点失败",
"saveFailed": "保存失败",
"createSuccess": "已创建站点 {{code}}",
"updateSuccess": "已更新站点 {{code}}",
"connectivityTest": "联通检测",
"connectivityTitle": "主站钱包联通检测",
"connectivityDescription": "使用测试玩家调用站点 {{code}} 的 balance 接口。",
"connectivityPlayerId": "测试 site_player_id",
"connectivityRun": "开始检测",
"connectivityRunning": "检测中…",
"connectivitySuccess": "联通成功",
"connectivityFailed": "联通失败",
"exportParams": "导出参数表",
"exportSuccess": "已导出 {{code}} 参数表",
"exportFailed": "导出失败",
"rotateSecrets": "重置密钥",
"rotateSuccess": "已重置站点 {{code}} 的密钥",
"rotateFailed": "重置密钥失败",
"rotateConfirmTitle": "确认重置密钥?",
"rotateConfirmDescription": "将重新生成站点 {{code}} 的 SSO 与钱包密钥,旧密钥立即失效。",
"rotateConfirm": "确认重置",
"secretsTitle": "请妥善保存密钥",
"secretsDescription": "站点 {{code}} 的密钥仅显示一次,关闭后无法再次查看完整内容。",
"secretsDismiss": "我已保存",
"copied": "已复制 {{field}}",
"copyFailed": "复制失败",
"noPermission": "当前账号无接入站点查看权限",
"codeImmutable": "site_code 创建后不可修改",
"statusEnabled": "启用",
"statusDisabled": "停用",
"dialogCreateTitle": "新建接入站点",
"dialogEditTitle": "编辑接入站点",
"dialogDescription": "钱包路径使用默认值即可,除非主站 URL 规范不同。",
"form": {
"required": "请填写站点名称",
"codeRequired": "请填写 site_code"
},
"columns": {
"code": "site_code",
"name": "名称",
"status": "状态",
"walletUrl": "钱包 API",
"actions": "操作"
},
"fields": {
"code": "site_code",
"name": "站点名称",
"currency": "默认币种",
"status": "状态",
"walletApiUrl": "主站钱包根 URL",
"lotteryH5BaseUrl": "彩票 H5 基址(可选)",
"iframeOrigins": "iframe 白名单(每行一个 origin",
"notes": "备注",
"ssoSecret": "SSO 密钥",
"walletApiKey": "钱包 API 密钥"
}
},
"versionStatus": {
"active": "生效中",

View File

@@ -3,6 +3,8 @@
"listTitle": "玩家列表",
"createPlayer": "新建玩家",
"searchPlaceholder": "按玩家 ID / 用户名 / 昵称搜索",
"filterSite": "主站站点",
"filterAllSites": "全部站点",
"search": "搜索",
"refresh": "刷新",
"loadFailed": "加载玩家列表失败",

View File

@@ -1,6 +1,8 @@
{
"title": "注单列表",
"playerTicketQuery": "注单查询",
"filterSite": "主站站点",
"filterAllSites": "全部站点",
"playerId": "玩家 ID / 账号",
"invalidPlayerId": "请输入有效玩家 ID 或账号",
"playerIdPlaceholder": "留空显示全部,可输入玩家 ID 或账号",

View File

@@ -24,6 +24,7 @@ const EXACT_ROUTES: Record<string, PageTitleSpec> = {
"/admin/settings/currencies": { ns: "config", key: "currencies.title" },
"/admin/currencies": { ns: "config", key: "currencies.title" },
"/admin/config": { ns: "config", key: "hub.title" },
"/admin/config/integration-sites": { ns: "config", key: "integrationSites.title" },
"/admin/rules/plays": { ns: "config", key: "nav.rulesPlaysTitle" },
"/admin/rules/odds": { ns: "config", key: "nav.rulesOddsTitle" },
"/admin/jackpot": { ns: "jackpot", key: "configTitle" },

View File

@@ -41,6 +41,12 @@ const HUB_CARDS: HubCard[] = [
descKey: "hub.riskCapDesc",
requiredAny: ["prd.risk_cap.manage", "prd.risk_cap.view"],
},
{
href: "/admin/config/integration-sites",
titleKey: "hub.integrationTitle",
descKey: "hub.integrationDesc",
requiredAny: ["prd.integration.view", "prd.integration.manage"],
},
];
export function ConfigHubScreen() {

View File

@@ -0,0 +1,677 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import {
getAdminIntegrationSite,
getAdminIntegrationSiteExport,
getAdminIntegrationSites,
postAdminIntegrationSite,
postAdminIntegrationSiteConnectivityTest,
postAdminIntegrationSiteRotateSecrets,
putAdminIntegrationSite,
} from "@/api/admin-integration-sites";
import { AdminPageCard } from "@/components/admin/admin-page-card";
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Textarea } from "@/components/ui/textarea";
import { adminHasAnyPermission } from "@/lib/admin-permissions";
import { useAdminProfile } from "@/stores/admin-session";
import { LotteryApiBizError } from "@/types/api/errors";
import type {
AdminIntegrationSiteConnectivityResult,
AdminIntegrationSiteRow,
AdminIntegrationSiteSecrets,
AdminIntegrationSiteWithSecrets,
} from "@/types/api/admin-integration-site";
type FormState = {
code: string;
name: string;
currency_code: string;
status: number;
wallet_api_url: string;
wallet_debit_path: string;
wallet_credit_path: string;
wallet_balance_path: string;
wallet_timeout_seconds: string;
iframe_allowed_origins: string;
lottery_h5_base_url: string;
notes: string;
};
const EMPTY_FORM: FormState = {
code: "",
name: "",
currency_code: "NPR",
status: 1,
wallet_api_url: "",
wallet_debit_path: "/wallet/debit-for-lottery",
wallet_credit_path: "/wallet/credit-from-lottery",
wallet_balance_path: "/wallet/balance",
wallet_timeout_seconds: "10",
iframe_allowed_origins: "",
lottery_h5_base_url: "",
notes: "",
};
function originsToText(origins: string[] | undefined): string {
return (origins ?? []).join("\n");
}
function textToOrigins(text: string): string[] {
return text
.split(/[\n,]+/)
.map((s) => s.trim())
.filter(Boolean);
}
function rowToForm(row: AdminIntegrationSiteRow & Partial<FormState>): FormState {
return {
code: row.code,
name: row.name,
currency_code: row.currency_code,
status: row.status,
wallet_api_url: row.wallet_api_url ?? "",
wallet_debit_path: row.wallet_debit_path ?? "/wallet/debit-for-lottery",
wallet_credit_path: row.wallet_credit_path ?? "/wallet/credit-from-lottery",
wallet_balance_path: row.wallet_balance_path ?? "/wallet/balance",
wallet_timeout_seconds: String(row.wallet_timeout_seconds ?? 10),
iframe_allowed_origins: originsToText(row.iframe_allowed_origins as string[] | undefined),
lottery_h5_base_url: row.lottery_h5_base_url ?? "",
notes: row.notes ?? "",
};
}
function formToPayload(form: FormState, includeCode: boolean) {
const base = {
name: form.name.trim(),
currency_code: form.currency_code.trim() || "NPR",
status: form.status,
wallet_api_url: form.wallet_api_url.trim() || null,
wallet_debit_path: form.wallet_debit_path.trim(),
wallet_credit_path: form.wallet_credit_path.trim(),
wallet_balance_path: form.wallet_balance_path.trim(),
wallet_timeout_seconds: Number.parseInt(form.wallet_timeout_seconds, 10) || 10,
iframe_allowed_origins: textToOrigins(form.iframe_allowed_origins),
lottery_h5_base_url: form.lottery_h5_base_url.trim() || null,
notes: form.notes.trim() || null,
};
if (includeCode) {
return { code: form.code.trim(), ...base };
}
return base;
}
export function IntegrationSitesConsole() {
const { t } = useTranslation("config");
const profile = useAdminProfile();
const canView = adminHasAnyPermission(profile?.permissions, [
"prd.integration.view",
"prd.integration.manage",
]);
const canManage = adminHasAnyPermission(profile?.permissions, ["prd.integration.manage"]);
const [items, setItems] = useState<AdminIntegrationSiteRow[]>([]);
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<FormState>(EMPTY_FORM);
const [rotateTarget, setRotateTarget] = useState<AdminIntegrationSiteRow | null>(null);
const [rotateBusy, setRotateBusy] = useState(false);
const [secretsDialog, setSecretsDialog] = useState<{
siteCode: string;
secrets: AdminIntegrationSiteSecrets;
} | null>(null);
const [connectivityTarget, setConnectivityTarget] = useState<AdminIntegrationSiteRow | null>(
null,
);
const [connectivityPlayerId, setConnectivityPlayerId] = useState("10001");
const [connectivityCurrency, setConnectivityCurrency] = useState("NPR");
const [connectivityBusy, setConnectivityBusy] = useState(false);
const [connectivityResult, setConnectivityResult] =
useState<AdminIntegrationSiteConnectivityResult | null>(null);
const [exportBusyId, setExportBusyId] = useState<number | null>(null);
const load = useCallback(async () => {
if (!canView) {
setItems([]);
setLoading(false);
return;
}
setLoading(true);
try {
const data = await getAdminIntegrationSites();
setItems(data.items);
} catch (error) {
toast.error(
error instanceof LotteryApiBizError ? error.message : t("integrationSites.loadFailed"),
);
} finally {
setLoading(false);
}
}, [canView, t]);
useEffect(() => {
queueMicrotask(() => {
void load();
});
}, [load]);
function openCreate(): void {
setMode("create");
setEditingId(null);
setForm(EMPTY_FORM);
setDialogOpen(true);
}
async function openEdit(row: AdminIntegrationSiteRow): Promise<void> {
setMode("edit");
setEditingId(row.id);
setDialogOpen(true);
try {
const detail = await getAdminIntegrationSite(row.id);
setForm(rowToForm(detail));
} catch (error) {
toast.error(
error instanceof LotteryApiBizError ? error.message : t("integrationSites.loadFailed"),
);
setDialogOpen(false);
}
}
function updateForm<K extends keyof FormState>(key: K, value: FormState[K]): void {
setForm((prev) => ({ ...prev, [key]: value }));
}
function showSecretsOnce(result: AdminIntegrationSiteWithSecrets): void {
if (result.secrets) {
setSecretsDialog({ siteCode: result.code, secrets: result.secrets });
}
}
async function handleSubmit(): Promise<void> {
if (!canManage) return;
if (form.name.trim() === "") {
toast.error(t("integrationSites.form.required"));
return;
}
if (mode === "create" && form.code.trim() === "") {
toast.error(t("integrationSites.form.codeRequired"));
return;
}
setSaving(true);
try {
if (mode === "create") {
const created = await postAdminIntegrationSite(formToPayload(form, true));
toast.success(t("integrationSites.createSuccess", { code: created.code }));
showSecretsOnce(created);
} else if (editingId !== null) {
await putAdminIntegrationSite(editingId, formToPayload(form, false));
toast.success(t("integrationSites.updateSuccess", { code: form.code }));
}
setDialogOpen(false);
await load();
} catch (error) {
toast.error(
error instanceof LotteryApiBizError ? error.message : t("integrationSites.saveFailed"),
);
} finally {
setSaving(false);
}
}
async function confirmRotate(): Promise<void> {
if (!rotateTarget || !canManage) return;
setRotateBusy(true);
try {
const result = await postAdminIntegrationSiteRotateSecrets(rotateTarget.id);
toast.success(t("integrationSites.rotateSuccess", { code: rotateTarget.code }));
setRotateTarget(null);
showSecretsOnce(result);
await load();
} catch (error) {
toast.error(
error instanceof LotteryApiBizError ? error.message : t("integrationSites.rotateFailed"),
);
} finally {
setRotateBusy(false);
}
}
function openConnectivity(row: AdminIntegrationSiteRow): void {
setConnectivityTarget(row);
setConnectivityPlayerId("10001");
setConnectivityCurrency(row.currency_code || "NPR");
setConnectivityResult(null);
}
async function runConnectivityTest(): Promise<void> {
if (!connectivityTarget) return;
setConnectivityBusy(true);
setConnectivityResult(null);
try {
const result = await postAdminIntegrationSiteConnectivityTest(connectivityTarget.id, {
site_player_id: connectivityPlayerId.trim(),
currency_code: connectivityCurrency.trim() || undefined,
});
setConnectivityResult(result);
if (result.probe.success) {
toast.success(t("integrationSites.connectivitySuccess"));
} else {
toast.error(result.probe.message ?? t("integrationSites.connectivityFailed"));
}
} catch (error) {
toast.error(
error instanceof LotteryApiBizError
? error.message
: t("integrationSites.connectivityFailed"),
);
} finally {
setConnectivityBusy(false);
}
}
async function exportParameterSheet(row: AdminIntegrationSiteRow): Promise<void> {
setExportBusyId(row.id);
try {
const sheet = await getAdminIntegrationSiteExport(row.id);
const blob = new Blob([JSON.stringify(sheet, null, 2)], {
type: "application/json;charset=utf-8",
});
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = `integration-${row.code}.json`;
anchor.click();
URL.revokeObjectURL(url);
toast.success(t("integrationSites.exportSuccess", { code: row.code }));
} catch (error) {
toast.error(
error instanceof LotteryApiBizError ? error.message : t("integrationSites.exportFailed"),
);
} finally {
setExportBusyId(null);
}
}
async function copyText(label: string, value: string): Promise<void> {
try {
await navigator.clipboard.writeText(value);
toast.success(t("integrationSites.copied", { field: label }));
} catch {
toast.error(t("integrationSites.copyFailed"));
}
}
if (!canView) {
return (
<AdminPageCard title={t("integrationSites.title")}>
<p className="text-sm text-muted-foreground">{t("integrationSites.noPermission")}</p>
</AdminPageCard>
);
}
return (
<>
<AdminPageCard
title={t("integrationSites.title")}
description={t("integrationSites.description")}
actions={
canManage ? (
<Button type="button" onClick={openCreate}>
{t("integrationSites.create")}
</Button>
) : null
}
>
{loading ? (
<p className="text-sm text-muted-foreground">{t("integrationSites.loading")}</p>
) : items.length === 0 ? (
<p className="text-sm text-muted-foreground">{t("integrationSites.empty")}</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("integrationSites.columns.code")}</TableHead>
<TableHead>{t("integrationSites.columns.name")}</TableHead>
<TableHead>{t("integrationSites.columns.status")}</TableHead>
<TableHead>{t("integrationSites.columns.walletUrl")}</TableHead>
<TableHead className="text-right">{t("integrationSites.columns.actions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((row) => (
<TableRow key={row.id}>
<TableCell className="font-mono text-xs">{row.code}</TableCell>
<TableCell>{row.name}</TableCell>
<TableCell>
<AdminStatusBadge
tone={row.status === 1 ? "success" : "muted"}
label={
row.status === 1
? t("integrationSites.statusEnabled")
: t("integrationSites.statusDisabled")
}
/>
</TableCell>
<TableCell className="max-w-[240px] truncate text-xs text-muted-foreground">
{row.wallet_api_url ?? "—"}
</TableCell>
<TableCell className="text-right">
<div className="flex flex-wrap justify-end gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => openConnectivity(row)}
>
{t("integrationSites.connectivityTest")}
</Button>
<Button
type="button"
variant="outline"
size="sm"
disabled={exportBusyId === row.id}
onClick={() => void exportParameterSheet(row)}
>
{t("integrationSites.exportParams")}
</Button>
<Button type="button" variant="outline" size="sm" onClick={() => void openEdit(row)}>
{t("integrationSites.edit")}
</Button>
{canManage ? (
<Button
type="button"
variant="destructive"
size="sm"
onClick={() => setRotateTarget(row)}
>
{t("integrationSites.rotateSecrets")}
</Button>
) : null}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</AdminPageCard>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="max-h-[90vh] max-w-lg overflow-y-auto">
<DialogHeader>
<DialogTitle>
{mode === "create"
? t("integrationSites.dialogCreateTitle")
: t("integrationSites.dialogEditTitle")}
</DialogTitle>
<DialogDescription>{t("integrationSites.dialogDescription")}</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-2">
<div className="grid gap-2">
<Label htmlFor="is-code">{t("integrationSites.fields.code")}</Label>
<Input
id="is-code"
value={form.code}
disabled={mode === "edit"}
onChange={(e) => updateForm("code", e.target.value)}
placeholder="partner-a"
/>
{mode === "edit" ? (
<p className="text-xs text-muted-foreground">{t("integrationSites.codeImmutable")}</p>
) : null}
</div>
<div className="grid gap-2">
<Label htmlFor="is-name">{t("integrationSites.fields.name")}</Label>
<Input
id="is-name"
value={form.name}
onChange={(e) => updateForm("name", e.target.value)}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="is-currency">{t("integrationSites.fields.currency")}</Label>
<Input
id="is-currency"
value={form.currency_code}
onChange={(e) => updateForm("currency_code", e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="is-status">{t("integrationSites.fields.status")}</Label>
<select
id="is-status"
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm"
value={form.status}
onChange={(e) => updateForm("status", Number(e.target.value))}
>
<option value={1}>{t("integrationSites.statusEnabled")}</option>
<option value={0}>{t("integrationSites.statusDisabled")}</option>
</select>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="is-wallet-url">{t("integrationSites.fields.walletApiUrl")}</Label>
<Input
id="is-wallet-url"
value={form.wallet_api_url}
onChange={(e) => updateForm("wallet_api_url", e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="is-h5">{t("integrationSites.fields.lotteryH5BaseUrl")}</Label>
<Input
id="is-h5"
value={form.lottery_h5_base_url}
onChange={(e) => updateForm("lottery_h5_base_url", e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="is-origins">{t("integrationSites.fields.iframeOrigins")}</Label>
<Textarea
id="is-origins"
rows={3}
value={form.iframe_allowed_origins}
onChange={(e) => updateForm("iframe_allowed_origins", e.target.value)}
placeholder="https://www.example.com"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="is-notes">{t("integrationSites.fields.notes")}</Label>
<Textarea
id="is-notes"
rows={2}
value={form.notes}
onChange={(e) => updateForm("notes", e.target.value)}
/>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setDialogOpen(false)}>
{t("integrationSites.cancel")}
</Button>
{canManage ? (
<Button type="button" disabled={saving} onClick={() => void handleSubmit()}>
{saving ? t("integrationSites.saving") : t("integrationSites.save")}
</Button>
) : null}
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={rotateTarget !== null} onOpenChange={(open) => !open && setRotateTarget(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("integrationSites.rotateConfirmTitle")}</DialogTitle>
<DialogDescription>
{t("integrationSites.rotateConfirmDescription", {
code: rotateTarget?.code ?? "",
})}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setRotateTarget(null)}>
{t("integrationSites.cancel")}
</Button>
<Button
type="button"
variant="destructive"
disabled={rotateBusy}
onClick={() => void confirmRotate()}
>
{rotateBusy ? t("integrationSites.saving") : t("integrationSites.rotateConfirm")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog
open={connectivityTarget !== null}
onOpenChange={(open) => {
if (!open) {
setConnectivityTarget(null);
setConnectivityResult(null);
}
}}
>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>{t("integrationSites.connectivityTitle")}</DialogTitle>
<DialogDescription>
{t("integrationSites.connectivityDescription", {
code: connectivityTarget?.code ?? "",
})}
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-2">
<div className="grid gap-2">
<Label htmlFor="ct-player">{t("integrationSites.connectivityPlayerId")}</Label>
<Input
id="ct-player"
value={connectivityPlayerId}
onChange={(e) => setConnectivityPlayerId(e.target.value)}
placeholder="10001"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="ct-currency">{t("integrationSites.fields.currency")}</Label>
<Input
id="ct-currency"
value={connectivityCurrency}
onChange={(e) => setConnectivityCurrency(e.target.value)}
/>
</div>
{connectivityResult ? (
<div className="rounded-md border bg-muted/40 p-3 text-xs font-mono whitespace-pre-wrap break-all">
{JSON.stringify(connectivityResult.probe, null, 2)}
</div>
) : null}
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setConnectivityTarget(null)}>
{t("integrationSites.cancel")}
</Button>
<Button
type="button"
disabled={connectivityBusy || connectivityPlayerId.trim() === ""}
onClick={() => void runConnectivityTest()}
>
{connectivityBusy
? t("integrationSites.connectivityRunning")
: t("integrationSites.connectivityRun")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={secretsDialog !== null} onOpenChange={(open) => !open && setSecretsDialog(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("integrationSites.secretsTitle")}</DialogTitle>
<DialogDescription>
{t("integrationSites.secretsDescription", { code: secretsDialog?.siteCode ?? "" })}
</DialogDescription>
</DialogHeader>
{secretsDialog ? (
<div className="space-y-4">
<div className="space-y-2">
<Label>{t("integrationSites.fields.ssoSecret")}</Label>
<div className="flex gap-2">
<Input readOnly value={secretsDialog.secrets.sso_jwt_secret} className="font-mono text-xs" />
<Button
type="button"
variant="outline"
onClick={() =>
void copyText(
t("integrationSites.fields.ssoSecret"),
secretsDialog.secrets.sso_jwt_secret,
)
}
>
{t("integrationSites.copy")}
</Button>
</div>
</div>
<div className="space-y-2">
<Label>{t("integrationSites.fields.walletApiKey")}</Label>
<div className="flex gap-2">
<Input readOnly value={secretsDialog.secrets.wallet_api_key} className="font-mono text-xs" />
<Button
type="button"
variant="outline"
onClick={() =>
void copyText(
t("integrationSites.fields.walletApiKey"),
secretsDialog.secrets.wallet_api_key,
)
}
>
{t("integrationSites.copy")}
</Button>
</div>
</div>
</div>
) : null}
<DialogFooter>
<Button type="button" onClick={() => setSecretsDialog(null)}>
{t("integrationSites.secretsDismiss")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -50,6 +50,7 @@ import {
TableRow,
} from "@/components/ui/table";
import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog";
import { useAdminSiteCodeOptions } from "@/hooks/use-admin-site-code-options";
import { formatAdminMinorUnits } from "@/lib/money";
import { LotteryApiBizError } from "@/types/api/errors";
import type { AdminPlayerRow, AdminPlayerWalletRow } from "@/types/api/admin-player";
@@ -87,8 +88,11 @@ export function PlayersConsole(): React.ReactElement {
const canFreezePlayers = adminHasAnyPermission(profile?.permissions, [PRD_PLAYER_FREEZE_MANAGE]);
const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(10);
const { sites: siteOptions, canChooseSite } = useAdminSiteCodeOptions();
const [keyword, setKeyword] = useState("");
const [query, setQuery] = useState("");
const [siteCode, setSiteCode] = useState("");
const [appliedSiteCode, setAppliedSiteCode] = useState("");
const [items, setItems] = useState<AdminPlayerRow[]>([]);
const [total, setTotal] = useState(0);
@@ -124,6 +128,7 @@ export function PlayersConsole(): React.ReactElement {
page,
per_page: perPage,
keyword: query.trim() || undefined,
site_code: appliedSiteCode.trim() || undefined,
});
setItems(data.items);
setTotal(data.meta.total);
@@ -137,7 +142,7 @@ export function PlayersConsole(): React.ReactElement {
} finally {
setLoading(false);
}
}, [page, perPage, query, t]);
}, [page, perPage, query, appliedSiteCode, t]);
useEffect(() => {
queueMicrotask(() => {
@@ -297,6 +302,24 @@ export function PlayersConsole(): React.ReactElement {
) : null}
</div>
<div className="admin-list-toolbar">
{canChooseSite ? (
<div className="admin-list-field">
<Label className="sm:w-20 sm:shrink-0">{t("filterSite")}</Label>
<Select value={siteCode || "__all__"} onValueChange={(v) => setSiteCode(v === "__all__" ? "" : v)}>
<SelectTrigger className="w-full sm:w-[12rem]">
<SelectValue placeholder={t("filterAllSites")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">{t("filterAllSites")}</SelectItem>
{siteOptions.map((site) => (
<SelectItem key={site.code} value={site.code}>
{site.code} {site.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
) : null}
<div className="admin-list-field xl:min-w-0">
<Label htmlFor="player-search" className="sm:w-20 sm:shrink-0">
{t("search")}
@@ -311,6 +334,7 @@ export function PlayersConsole(): React.ReactElement {
if (e.key === "Enter") {
setPage(1);
setQuery(keyword.trim());
setAppliedSiteCode(siteCode.trim());
}
}}
/>
@@ -326,6 +350,7 @@ export function PlayersConsole(): React.ReactElement {
onClick={() => {
setPage(1);
setQuery(keyword.trim());
setAppliedSiteCode(siteCode.trim());
}}
>
{t("search")}

View File

@@ -5,6 +5,7 @@ import { useExportLabels } from "@/hooks/use-export-labels";
import { useTranslation } from "react-i18next";
import { getAdminTicketItems } from "@/api/admin-tickets";
import { useAdminSiteCodeOptions } from "@/hooks/use-admin-site-code-options";
import { AdminDateRangeField } from "@/components/admin/admin-date-range-field";
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
@@ -19,6 +20,13 @@ import {
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
@@ -48,6 +56,7 @@ const TICKET_STATUS_OPTIONS = [
] as const;
type TicketFilters = {
siteCode: string;
playerQuery: string;
drawNo: string;
numberKeyword: string;
@@ -57,6 +66,7 @@ type TicketFilters = {
};
const emptyTicketFilters: TicketFilters = {
siteCode: "",
playerQuery: "",
drawNo: "",
numberKeyword: "",
@@ -90,6 +100,7 @@ function ticketStatusSummary(statuses: string[], t: TicketTranslateFn): string {
export function PlayerTicketsConsole(): React.ReactElement {
const { t } = useTranslation(["tickets", "common"]);
const { sites: siteOptions, canChooseSite } = useAdminSiteCodeOptions();
const playCodeLabel = useAdminPlayCodeLabel();
const exportLabels = useExportLabels("tickets");
const formatTs = useAdminDateTimeFormatter();
@@ -118,6 +129,7 @@ export function PlayerTicketsConsole(): React.ReactElement {
page,
per_page: perPage,
...query,
site_code: applied.siteCode.trim() || undefined,
draw_no: applied.drawNo.trim() || undefined,
status: applied.statuses.length > 0 ? applied.statuses : undefined,
number: applied.numberKeyword.trim() || undefined,
@@ -143,6 +155,7 @@ export function PlayerTicketsConsole(): React.ReactElement {
setErr(null);
setApplied({
...draft,
siteCode: draft.siteCode.trim(),
playerQuery: draft.playerQuery.trim(),
drawNo: draft.drawNo.trim(),
numberKeyword: draft.numberKeyword.trim(),
@@ -173,6 +186,32 @@ export function PlayerTicketsConsole(): React.ReactElement {
</CardHeader>
<CardContent className="admin-list-content">
<div className="admin-list-toolbar">
{canChooseSite ? (
<div className="admin-list-field">
<Label className="sm:shrink-0">{t("filterSite")}</Label>
<Select
value={draft.siteCode || "__all__"}
onValueChange={(v) =>
setDraft((current) => ({
...current,
siteCode: v === "__all__" ? "" : v,
}))
}
>
<SelectTrigger className="w-full sm:w-[12rem]">
<SelectValue placeholder={t("filterAllSites")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">{t("filterAllSites")}</SelectItem>
{siteOptions.map((site) => (
<SelectItem key={site.code} value={site.code}>
{site.code} {site.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
) : null}
<div className="admin-list-field min-w-[12rem] flex-1 sm:max-w-md">
<Label htmlFor="pt-player" className="sm:shrink-0">
{t("playerId")}

View File

@@ -0,0 +1,79 @@
export type AdminIntegrationSiteRow = {
id: number;
code: string;
name: string;
currency_code: string;
status: number;
wallet_api_url: string | null;
wallet_timeout_seconds: number;
has_sso_secret: boolean;
has_wallet_api_key: boolean;
sso_secret_masked: string | null;
wallet_api_key_masked: string | null;
updated_at: string | null;
};
export type AdminIntegrationSiteDetail = AdminIntegrationSiteRow & {
wallet_debit_path: string;
wallet_credit_path: string;
wallet_balance_path: string;
iframe_allowed_origins: string[];
lottery_h5_base_url: string | null;
notes: string | null;
is_default: boolean;
created_at: string | null;
};
export type AdminIntegrationSiteSecrets = {
sso_jwt_secret: string;
wallet_api_key: string;
};
export type AdminIntegrationSiteListData = {
items: AdminIntegrationSiteRow[];
};
export type AdminIntegrationSiteCreatePayload = {
code: string;
name: string;
currency_code?: string;
status?: number;
wallet_api_url?: string | null;
wallet_debit_path?: string;
wallet_credit_path?: string;
wallet_balance_path?: string;
wallet_timeout_seconds?: number;
iframe_allowed_origins?: string[];
lottery_h5_base_url?: string | null;
notes?: string | null;
};
export type AdminIntegrationSiteUpdatePayload = Omit<AdminIntegrationSiteCreatePayload, "code">;
export type AdminIntegrationSiteWithSecrets = AdminIntegrationSiteDetail & {
secrets?: AdminIntegrationSiteSecrets;
secrets_display_once?: boolean;
};
export type AdminIntegrationSiteConnectivityProbe = {
success: boolean;
main_balance_minor: number | null;
currency_code: string;
request_url: string;
http_status: number | null;
message: string | null;
response_preview?: Record<string, unknown> | null;
};
export type AdminIntegrationSiteConnectivityResult = {
site_code: string;
site_player_id: string;
player_source: "database" | "synthetic";
probe: AdminIntegrationSiteConnectivityProbe;
};
export type AdminIntegrationSiteParameterSheet = Record<string, unknown> & {
site_code: string;
name: string;
security_note: string;
};