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.
1035 lines
38 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
}
|