Some checks failed
lotteryadmin CI / build (push) Has been cancelled
- 各角色仪表盘:KPI 可点击跳转、快捷入口、去重冗余区块,代理/站点降级 analytics - 代理线路:创建玩家权限、侧栏搜索、深链 URL、档案保存与删除预检等修复 - 统一密码最短 6 位;代理模块表格/侧栏背景色一致(admin-table-inset) - 报表、钱包、对账、开奖、结算、配置等模块结构与 i18n 同步更新
1029 lines
38 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
}
|