Files
lotteryAdmin/src/modules/integration/integration-sites-console.tsx
kang 0d984b00f0
Some checks failed
lotteryadmin CI / build (push) Has been cancelled
feat(admin): 仪表盘重构、代理线路修复与后台体验优化
- 各角色仪表盘:KPI 可点击跳转、快捷入口、去重冗余区块,代理/站点降级 analytics
- 代理线路:创建玩家权限、侧栏搜索、深链 URL、档案保存与删除预检等修复
- 统一密码最短 6 位;代理模块表格/侧栏背景色一致(admin-table-inset)
- 报表、钱包、对账、开奖、结算、配置等模块结构与 i18n 同步更新
2026-06-26 14:39:22 +08:00

1029 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 { validateAdminPassword } from "@/lib/admin-input-validation";
import { AdminPageCard } from "@/components/admin/admin-page-card";
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" && validateAdminPassword(form.admin_password) === "too_short") {
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 (
<>
<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: "至少 6 位",
})}
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>
</>
);
}