Files
lotteryAdmin/src/modules/integration/integration-sites-console.tsx
kang 641c87ff50 feat(docs, agents, risk): enhance documentation, API queries, and UI components
Updated the public documentation site with improved layout and accessibility, including new sections for client integration and admin guides. Enhanced API queries by adding 'active_only' and 'group_by' parameters for better data filtering in risk management. Refined UI components for agent management, ensuring consistent styling and improved user experience across the application. Added localization support for new documentation content in English and Nepali.
2026-06-15 17:21:50 +08:00

1035 lines
38 KiB
TypeScript

"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 (
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0"
disabled={disabled || busy}
aria-label={label}
title={label}
onClick={onClick}
>
<Copy className="h-3.5 w-3.5" />
</Button>
);
}
function MaskedValueWithCopy({
configured,
masked,
copyLabel,
canCopy,
copying,
onCopy,
}: {
configured: boolean;
masked: string | null;
copyLabel: string;
canCopy: boolean;
copying: boolean;
onCopy: () => void;
}): React.ReactElement {
return (
<div className="flex min-w-[7.5rem] max-w-[11rem] items-center gap-0.5">
<span className="truncate font-mono text-xs text-muted-foreground">
{configured ? (masked ?? "••••••••") : "—"}
</span>
{configured && canCopy ? (
<CopyIconButton label={copyLabel} onClick={onCopy} busy={copying} />
) : null}
</div>
);
}
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<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 [deleteTarget, setDeleteTarget] = useState<AdminIntegrationSiteRow | null>(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<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 [secretCopyBusyKey, setSecretCopyBusyKey] = useState<string | null>(null);
const secretsCacheRef = useRef(new Map<number, AdminIntegrationSiteSecrets>());
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<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;
}
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<void> {
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<void> {
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<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"));
}
}
async function resolveSiteSecrets(siteId: number): Promise<AdminIntegrationSiteSecrets> {
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<void> {
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 (
<>
<AdminPageGuide
guide={t("integrationSites.pageGuide")}
docHref={ADMIN_DOC_LINKS.siteSetup}
className="mb-4"
/>
<AdminPageCard
title={t("integrationSites.title")}
description={t("integrationSites.description")}
actions={
canCreate ? (
<Button type="button" onClick={openCreate}>
{t("integrationSites.create")}
</Button>
) : null
}
>
{loading ? (
<AdminLoadingState minHeight="8rem" label={t("integrationSites.loading")} />
) : items.length === 0 ? (
<AdminNoResourceState message={t("integrationSites.empty")}>
{canCreate ? (
<Button type="button" size="sm" onClick={openCreate}>
{t("integrationSites.create")}
</Button>
) : null}
</AdminNoResourceState>
) : (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("integrationSites.columns.code")}</TableHead>
<TableHead>{t("integrationSites.columns.name")}</TableHead>
<TableHead>{t("integrationSites.columns.currency")}</TableHead>
<TableHead>{t("integrationSites.columns.status")}</TableHead>
<TableHead>{t("integrationSites.columns.lineRoot")}</TableHead>
<TableHead>{t("integrationSites.columns.walletUrl")}</TableHead>
<TableHead>{t("integrationSites.columns.ssoSecret")}</TableHead>
<TableHead>{t("integrationSites.columns.walletApiKey")}</TableHead>
<TableHead className="sticky right-0 z-20 bg-muted w-14 text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">{t("integrationSites.columns.actions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((row) => (
<TableRow key={row.id}>
<TableCell>
<div className="flex max-w-[10rem] items-center gap-0.5">
<span className="truncate font-mono text-xs">{row.code}</span>
<CopyIconButton
label={t("integrationSites.copy")}
onClick={() => void copyText(t("integrationSites.columns.code"), row.code)}
/>
</div>
</TableCell>
<TableCell>{row.name}</TableCell>
<TableCell className="font-mono text-xs">{row.currency_code}</TableCell>
<TableCell>
<AdminStatusBadge
tone={row.status === 1 ? "success" : "neutral"}
>
{row.status === 1
? t("integrationSites.statusEnabled")
: t("integrationSites.statusDisabled")}
</AdminStatusBadge>
</TableCell>
<TableCell>
<AdminStatusBadge tone={row.has_line_root ? "success" : "neutral"}>
{row.has_line_root
? t("integrationSites.lineRootBound")
: t("integrationSites.lineRootUnbound")}
</AdminStatusBadge>
</TableCell>
<TableCell>
<div className="flex max-w-[14rem] items-center gap-0.5">
<span className="truncate text-xs text-muted-foreground">
{row.wallet_api_url ?? "—"}
</span>
{row.wallet_api_url ? (
<CopyIconButton
label={t("integrationSites.copy")}
onClick={() =>
void copyText(
t("integrationSites.columns.walletUrl"),
row.wallet_api_url ?? "",
)
}
/>
) : null}
</div>
</TableCell>
<TableCell>
<MaskedValueWithCopy
configured={row.has_sso_secret}
masked={row.sso_secret_masked}
copyLabel={t("integrationSites.copy")}
canCopy={canManage}
copying={secretCopyBusyKey === `${row.id}:sso`}
onCopy={() => void copySiteSecret(row, "sso")}
/>
</TableCell>
<TableCell>
<MaskedValueWithCopy
configured={row.has_wallet_api_key}
masked={row.wallet_api_key_masked}
copyLabel={t("integrationSites.copy")}
canCopy={canManage}
copying={secretCopyBusyKey === `${row.id}:wallet`}
onCopy={() => void copySiteSecret(row, "wallet")}
/>
</TableCell>
<TableCell className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
<AdminRowActionsMenu
busy={exportBusyId === row.id}
actions={[
{
key: "connectivity",
label: t("integrationSites.connectivityTest"),
icon: Link2,
hidden: !canManage,
onClick: () => 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),
},
]}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</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={t("integrationSites.placeholders.code")}
/>
{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}
placeholder={t("integrationSites.placeholders.name")}
onChange={(e) => updateForm("name", e.target.value)}
/>
</div>
{mode === "create" ? (
<>
<div className="rounded-lg border border-border/60 bg-muted/20 p-4">
<div className="mb-3">
<p className="text-sm font-medium text-foreground">
{t("integrationSites.adminAccountSectionTitle", {
defaultValue: "站点后台管理账号",
})}
</p>
<p className="mt-1 text-xs text-muted-foreground">
{t("integrationSites.adminAccountSectionDescription", {
defaultValue: "创建站点时将同步创建一个绑定该站点的后台管理账号。",
})}
</p>
</div>
<div className="grid gap-4">
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="is-admin-username">
{t("integrationSites.fields.adminUsername", { defaultValue: "后台登录名" })}
</Label>
<Input
id="is-admin-username"
value={form.admin_username}
placeholder={t("integrationSites.placeholders.adminUsername", {
defaultValue: "请输入后台登录名",
})}
onChange={(e) => updateForm("admin_username", e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="is-admin-nickname">
{t("integrationSites.fields.adminNickname", { defaultValue: "账号昵称" })}
</Label>
<Input
id="is-admin-nickname"
value={form.admin_nickname}
placeholder={t("integrationSites.placeholders.adminNickname", {
defaultValue: "请输入账号昵称",
})}
onChange={(e) => updateForm("admin_nickname", e.target.value)}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="is-admin-password">
{t("integrationSites.fields.adminPassword", { defaultValue: "初始密码" })}
</Label>
<Input
id="is-admin-password"
type="password"
value={form.admin_password}
placeholder={t("integrationSites.placeholders.adminPassword", {
defaultValue: "至少 8 位",
})}
onChange={(e) => updateForm("admin_password", e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="is-admin-email">
{t("integrationSites.fields.adminEmail", { defaultValue: "邮箱(可选)" })}
</Label>
<Input
id="is-admin-email"
value={form.admin_email}
placeholder={t("integrationSites.placeholders.adminEmail", {
defaultValue: "请输入邮箱",
})}
onChange={(e) => updateForm("admin_email", e.target.value)}
/>
</div>
</div>
</div>
</div>
</>
) : null}
<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}
placeholder={t("integrationSites.placeholders.currency")}
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}
placeholder={t("integrationSites.placeholders.walletApiUrl")}
onChange={(e) => updateForm("wallet_api_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={t("integrationSites.placeholders.iframeOrigins")}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="is-notes">{t("integrationSites.fields.notes")}</Label>
<Textarea
id="is-notes"
rows={2}
value={form.notes}
placeholder={t("integrationSites.placeholders.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={deleteTarget !== null} onOpenChange={(open) => !open && setDeleteTarget(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("integrationSites.deleteConfirmTitle")}</DialogTitle>
<DialogDescription>
{t("integrationSites.deleteConfirmDescription", {
code: deleteTarget?.code ?? "",
name: deleteTarget?.name ?? "",
})}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setDeleteTarget(null)}>
{t("integrationSites.cancel")}
</Button>
<Button
type="button"
variant="destructive"
disabled={deleteBusy}
onClick={() => void confirmDelete()}
>
{deleteBusy ? t("integrationSites.deleting") : t("integrationSites.deleteConfirm")}
</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={t("integrationSites.placeholders.connectivityPlayerId")}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="ct-currency">{t("integrationSites.fields.currency")}</Label>
<Input
id="ct-currency"
value={connectivityCurrency}
placeholder={t("integrationSites.placeholders.currency")}
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={!canManage || 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" />
<CopyIconButton
label={t("integrationSites.copy")}
onClick={() =>
void copyText(
t("integrationSites.fields.ssoSecret"),
secretsDialog.secrets.sso_jwt_secret,
)
}
/>
</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" />
<CopyIconButton
label={t("integrationSites.copy")}
onClick={() =>
void copyText(
t("integrationSites.fields.walletApiKey"),
secretsDialog.secrets.wallet_api_key,
)
}
/>
</div>
</div>
</div>
) : null}
<DialogFooter>
<Button type="button" onClick={() => setSecretsDialog(null)}>
{t("integrationSites.secretsDismiss")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}