"use client"; import { Copy, Download, Link2, Pencil, ShieldAlert, Trash2 } from "lucide-react"; import { useCallback, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { useAsyncEffect } from "@/hooks/use-async-effect"; import { useTranslationRef } from "@/hooks/use-translation-ref"; import { toast } from "sonner"; import { deleteAdminIntegrationSite, getAdminIntegrationSite, getAdminIntegrationSiteExport, getAdminIntegrationSites, getAdminIntegrationSiteSecrets, postAdminIntegrationSite, postAdminIntegrationSiteConnectivityTest, postAdminIntegrationSiteRotateSecrets, putAdminIntegrationSite, } from "@/api/admin-integration-sites"; import { AdminPageCard } from "@/components/admin/admin-page-card"; import { AdminPageGuide } from "@/components/admin/admin-page-guide"; import { ADMIN_DOC_LINKS } from "@/lib/admin-doc-links"; import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state"; import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu"; 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 { AdminLoadingState } from "@/components/admin/admin-loading-state"; import { adminHasAnyPermission } from "@/lib/admin-permissions"; import { getAdminPageBundle } from "@/lib/admin-permission-bundles"; import { useAdminProfile } from "@/stores/admin-session"; import { LotteryApiBizError } from "@/types/api/errors"; import type { AdminIntegrationSiteCreatePayload, AdminIntegrationSiteConnectivityResult, AdminIntegrationSiteUpdatePayload, AdminIntegrationSiteRow, AdminIntegrationSiteDetail, AdminIntegrationSiteSecrets, AdminIntegrationSiteWithSecrets, } from "@/types/api/admin-integration-site"; function CopyIconButton({ label, onClick, disabled, busy, }: { label: string; onClick: () => void; disabled?: boolean; busy?: boolean; }): React.ReactElement { return ( ); } function MaskedValueWithCopy({ configured, masked, copyLabel, canCopy, copying, onCopy, }: { configured: boolean; masked: string | null; copyLabel: string; canCopy: boolean; copying: boolean; onCopy: () => void; }): React.ReactElement { return (
{configured ? (masked ?? "••••••••") : "—"} {configured && canCopy ? ( ) : null}
); } type FormState = { code: string; name: string; admin_username: string; admin_nickname: string; admin_password: string; admin_email: 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: "", admin_username: "", admin_nickname: "", admin_password: "", admin_email: "", 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: AdminIntegrationSiteDetail): FormState { return { code: row.code, name: row.name, admin_username: "", admin_nickname: "", admin_password: "", admin_email: "", 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: true): AdminIntegrationSiteCreatePayload; function formToPayload(form: FormState, includeCode: false): AdminIntegrationSiteUpdatePayload; function formToPayload( form: FormState, includeCode: boolean, ): AdminIntegrationSiteCreatePayload | AdminIntegrationSiteUpdatePayload { 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(), admin_account: { username: form.admin_username.trim(), nickname: form.admin_nickname.trim(), password: form.admin_password, email: form.admin_email.trim() || null, }, ...base, }; } return base; } type IntegrationSitesConsoleProps = { /** 为 true 时仅超管可新建站点(默认有 integration.site.manage 即可创建)。 */ restrictCreateToSuperAdmin?: boolean; }; export function IntegrationSitesConsole({ restrictCreateToSuperAdmin = false, }: IntegrationSitesConsoleProps = {}) { const { t } = useTranslation("config"); const tRef = useTranslationRef("config"); const profile = useAdminProfile(); const canManage = adminHasAnyPermission( profile?.permissions, getAdminPageBundle("integration-sites", "manage"), ); const canCreate = canManage && (!restrictCreateToSuperAdmin || profile?.is_super_admin === true); const [items, setItems] = useState([]); 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(null); const [form, setForm] = useState(EMPTY_FORM); const [rotateTarget, setRotateTarget] = useState(null); const [deleteTarget, setDeleteTarget] = useState(null); const [deleteBusy, setDeleteBusy] = useState(false); const [rotateBusy, setRotateBusy] = useState(false); const [secretsDialog, setSecretsDialog] = useState<{ siteCode: string; secrets: AdminIntegrationSiteSecrets; } | null>(null); const [connectivityTarget, setConnectivityTarget] = useState( null, ); const [connectivityPlayerId, setConnectivityPlayerId] = useState("10001"); const [connectivityCurrency, setConnectivityCurrency] = useState("NPR"); const [connectivityBusy, setConnectivityBusy] = useState(false); const [connectivityResult, setConnectivityResult] = useState(null); const [exportBusyId, setExportBusyId] = useState(null); const [secretCopyBusyKey, setSecretCopyBusyKey] = useState(null); const secretsCacheRef = useRef(new Map()); const load = useCallback(async () => { setLoading(true); secretsCacheRef.current.clear(); try { const data = await getAdminIntegrationSites(); setItems(data.items); } catch (error) { toast.error( error instanceof LotteryApiBizError ? error.message : tRef.current("integrationSites.loadFailed"), ); } finally { setLoading(false); } }, [tRef]); useAsyncEffect(() => { void load(); }, []); function openCreate(): void { setMode("create"); setEditingId(null); setForm(EMPTY_FORM); setDialogOpen(true); } async function openEdit(row: AdminIntegrationSiteRow): Promise { 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(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 { 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; } if (mode === "create" && form.admin_username.trim() === "") { toast.error(t("integrationSites.form.adminUsernameRequired")); return; } if (mode === "create" && form.admin_nickname.trim() === "") { toast.error(t("integrationSites.form.adminNicknameRequired")); return; } if (mode === "create" && form.admin_password.trim().length < 8) { toast.error(t("integrationSites.form.adminPasswordRequired")); return; } setSaving(true); try { if (mode === "create") { const created = await postAdminIntegrationSite(formToPayload(form, true)); toast.success(t("integrationSites.createSuccess", { code: created.code })); if (created.admin_user?.username) { toast.success( t("integrationSites.adminAccountCreated", { username: created.admin_user.username, defaultValue: "已同时创建站点后台账号 {{username}}", }), ); } 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 { if (!rotateTarget || !canManage) return; setRotateBusy(true); try { const result = await postAdminIntegrationSiteRotateSecrets(rotateTarget.id); toast.success(t("integrationSites.rotateSuccess", { code: rotateTarget.code })); setRotateTarget(null); secretsCacheRef.current.delete(rotateTarget.id); showSecretsOnce(result); await load(); } catch (error) { toast.error( error instanceof LotteryApiBizError ? error.message : t("integrationSites.rotateFailed"), ); } finally { setRotateBusy(false); } } async function confirmDelete(): Promise { if (!deleteTarget || !canManage) return; setDeleteBusy(true); try { await deleteAdminIntegrationSite(deleteTarget.id); toast.success(t("integrationSites.deleteSuccess", { code: deleteTarget.code })); secretsCacheRef.current.delete(deleteTarget.id); setDeleteTarget(null); await load(); } catch (error) { toast.error( error instanceof LotteryApiBizError ? error.message : t("integrationSites.deleteFailed"), ); } finally { setDeleteBusy(false); } } function openConnectivity(row: AdminIntegrationSiteRow): void { setConnectivityTarget(row); setConnectivityPlayerId("10001"); setConnectivityCurrency(row.currency_code || "NPR"); setConnectivityResult(null); } async function runConnectivityTest(): Promise { 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 { 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 { try { await navigator.clipboard.writeText(value); toast.success(t("integrationSites.copied", { field: label })); } catch { toast.error(t("integrationSites.copyFailed")); } } async function resolveSiteSecrets(siteId: number): Promise { const cached = secretsCacheRef.current.get(siteId); if (cached) { return cached; } const secrets = await getAdminIntegrationSiteSecrets(siteId); secretsCacheRef.current.set(siteId, secrets); return secrets; } async function copySiteSecret( row: AdminIntegrationSiteRow, field: "sso" | "wallet", ): Promise { if (!canManage) { toast.error(t("integrationSites.secretCopyRequiresManage")); return; } const configured = field === "sso" ? row.has_sso_secret : row.has_wallet_api_key; if (!configured) { toast.error(t("integrationSites.secretNotConfigured")); return; } const busyKey = `${row.id}:${field}`; setSecretCopyBusyKey(busyKey); try { const secrets = await resolveSiteSecrets(row.id); const value = field === "sso" ? secrets.sso_jwt_secret : secrets.wallet_api_key; if (!value) { toast.error(t("integrationSites.secretNotConfigured")); return; } await copyText( field === "sso" ? t("integrationSites.fields.ssoSecret") : t("integrationSites.fields.walletApiKey"), value, ); } catch (error) { toast.error( error instanceof LotteryApiBizError ? error.message : t("integrationSites.copyFailed"), ); } finally { setSecretCopyBusyKey(null); } } return ( <> {t("integrationSites.create")} ) : null } > {loading ? ( ) : items.length === 0 ? ( {canCreate ? ( ) : null} ) : (
{t("integrationSites.columns.code")} {t("integrationSites.columns.name")} {t("integrationSites.columns.currency")} {t("integrationSites.columns.status")} {t("integrationSites.columns.lineRoot")} {t("integrationSites.columns.walletUrl")} {t("integrationSites.columns.ssoSecret")} {t("integrationSites.columns.walletApiKey")} {t("integrationSites.columns.actions")} {items.map((row) => (
{row.code} void copyText(t("integrationSites.columns.code"), row.code)} />
{row.name} {row.currency_code} {row.status === 1 ? t("integrationSites.statusEnabled") : t("integrationSites.statusDisabled")} {row.has_line_root ? t("integrationSites.lineRootBound") : t("integrationSites.lineRootUnbound")}
{row.wallet_api_url ?? "—"} {row.wallet_api_url ? ( void copyText( t("integrationSites.columns.walletUrl"), row.wallet_api_url ?? "", ) } /> ) : null}
void copySiteSecret(row, "sso")} /> void copySiteSecret(row, "wallet")} /> openConnectivity(row), }, { key: "export", label: t("integrationSites.exportParams"), icon: Download, disabled: exportBusyId === row.id, onClick: () => void exportParameterSheet(row), }, { key: "edit", label: t("integrationSites.edit"), icon: Pencil, hidden: !canManage, onClick: () => void openEdit(row), }, { key: "rotate", label: t("integrationSites.rotateSecrets"), icon: ShieldAlert, destructive: true, hidden: !canManage, onClick: () => setRotateTarget(row), }, { key: "delete", label: t("integrationSites.delete"), icon: Trash2, destructive: true, hidden: !canManage, onClick: () => setDeleteTarget(row), }, ]} />
))}
)}
{mode === "create" ? t("integrationSites.dialogCreateTitle") : t("integrationSites.dialogEditTitle")} {t("integrationSites.dialogDescription")}
updateForm("code", e.target.value)} placeholder={t("integrationSites.placeholders.code")} /> {mode === "edit" ? (

{t("integrationSites.codeImmutable")}

) : null}
updateForm("name", e.target.value)} />
{mode === "create" ? ( <>

{t("integrationSites.adminAccountSectionTitle", { defaultValue: "站点后台管理账号", })}

{t("integrationSites.adminAccountSectionDescription", { defaultValue: "创建站点时将同步创建一个绑定该站点的后台管理账号。", })}

updateForm("admin_username", e.target.value)} />
updateForm("admin_nickname", e.target.value)} />
updateForm("admin_password", e.target.value)} />
updateForm("admin_email", e.target.value)} />
) : null}
updateForm("currency_code", e.target.value)} />
updateForm("wallet_api_url", e.target.value)} />