feat(docs, agents, risk): enhance documentation, API queries, and UI components
Updated the public documentation site with improved layout and accessibility, including new sections for client integration and admin guides. Enhanced API queries by adding 'active_only' and 'group_by' parameters for better data filtering in risk management. Refined UI components for agent management, ensuring consistent styling and improved user experience across the application. Added localization support for new documentation content in English and Nepali.
This commit is contained in:
@@ -13,6 +13,7 @@ import {
|
||||
deleteAdminRole,
|
||||
getAdminRoles,
|
||||
getAdminUserPermissionCatalog,
|
||||
postAdminRole,
|
||||
putAdminRole,
|
||||
putAdminRolePermissions,
|
||||
} from "@/api/admin-users";
|
||||
@@ -116,6 +117,15 @@ export function AdminRolesConsole(): React.ReactElement {
|
||||
void load();
|
||||
}, []);
|
||||
|
||||
function openCreateRole(): void {
|
||||
setEditingRoleId(null);
|
||||
setRoleSlug("");
|
||||
setRoleName("");
|
||||
setRoleDescription("");
|
||||
setRoleStatus(1);
|
||||
setRoleDialogOpen(true);
|
||||
}
|
||||
|
||||
function openEditRole(role: AdminRoleRow): void {
|
||||
if (isPlatformSuperAdminRole(role)) {
|
||||
return;
|
||||
@@ -172,13 +182,29 @@ export function AdminRolesConsole(): React.ReactElement {
|
||||
async function submitRole(): Promise<void> {
|
||||
const name = roleName.trim();
|
||||
const slug = roleSlug.trim().toLowerCase();
|
||||
if (name === "" || slug === "" || editingRoleId === null) {
|
||||
if (name === "" || slug === "") {
|
||||
toast.error(t("roleFormRequired"));
|
||||
return;
|
||||
}
|
||||
|
||||
setRoleFormSaving(true);
|
||||
try {
|
||||
if (editingRoleId === null) {
|
||||
const created = await postAdminRole({
|
||||
slug,
|
||||
name,
|
||||
description: roleDescription.trim() === "" ? null : roleDescription.trim(),
|
||||
status: roleStatus,
|
||||
});
|
||||
setRoles((prev) => [...prev, created].sort((a, b) => a.sort_order - b.sort_order || a.id - b.id));
|
||||
setCatalog((prev) =>
|
||||
prev ? { ...prev, roles: [...prev.roles, created].sort((a, b) => a.slug.localeCompare(b.slug)) } : prev,
|
||||
);
|
||||
toast.success(t("roleCreateSuccess", { name: created.name }));
|
||||
handleRoleDialogOpenChange(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const updated = await putAdminRole(editingRoleId, {
|
||||
slug,
|
||||
name,
|
||||
@@ -228,6 +254,11 @@ export function AdminRolesConsole(): React.ReactElement {
|
||||
<CardTitle>{t("roleListTitle", { defaultValue: "平台角色管理" })}</CardTitle>
|
||||
</div>
|
||||
<div className="admin-list-actions">
|
||||
{canManageRoles ? (
|
||||
<Button type="button" size="sm" onClick={() => openCreateRole()}>
|
||||
{t("createRole")}
|
||||
</Button>
|
||||
) : null}
|
||||
<AdminTableExportButton
|
||||
tableId="admin-roles-table"
|
||||
filename={exportLabels.filename}
|
||||
@@ -241,7 +272,7 @@ export function AdminRolesConsole(): React.ReactElement {
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("roleListHint", {
|
||||
defaultValue: "平台仅保留「超级管理员」与「代理」两个内置角色;超级管理员自动拥有全部权限。",
|
||||
defaultValue: "可新增自定义角色并配置权限;内置角色(超级管理员、站点管理员、代理)不可删除。",
|
||||
})}
|
||||
</p>
|
||||
{err ? <p className="text-sm text-red-600 dark:text-red-400">{err}</p> : null}
|
||||
@@ -256,7 +287,7 @@ export function AdminRolesConsole(): React.ReactElement {
|
||||
<TableHead>{t("roleTable.status")}</TableHead>
|
||||
<TableHead>{t("roleTable.users")}</TableHead>
|
||||
<TableHead>{t("roleTable.permissions")}</TableHead>
|
||||
<TableHead className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">{t("roleTable.actions")}</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("roleTable.actions")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -273,10 +304,7 @@ export function AdminRolesConsole(): React.ReactElement {
|
||||
<TableRow key={role.id}>
|
||||
<TableCell>{role.id}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{role.name}</span>
|
||||
<span className="text-xs text-muted-foreground">{role.description ?? ""}</span>
|
||||
</div>
|
||||
<span className="font-medium">{role.name}</span>
|
||||
</TableCell>
|
||||
<TableCell>{role.slug}</TableCell>
|
||||
<TableCell>
|
||||
@@ -390,7 +418,9 @@ export function AdminRolesConsole(): React.ReactElement {
|
||||
<Dialog open={roleDialogOpen} onOpenChange={handleRoleDialogOpenChange}>
|
||||
<DialogContent showCloseButton className="max-w-lg gap-4">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("roleDialog.editTitle")}</DialogTitle>
|
||||
<DialogTitle>
|
||||
{editingRoleId === null ? t("roleDialog.createTitle") : t("roleDialog.editTitle")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>{t("roleDialog.description")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
@@ -400,7 +430,7 @@ export function AdminRolesConsole(): React.ReactElement {
|
||||
value={roleSlug}
|
||||
placeholder={t("roleDialog.slugPlaceholder")}
|
||||
onChange={(e) => setRoleSlug(e.target.value)}
|
||||
disabled
|
||||
disabled={editingRoleId !== null}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
@@ -444,7 +474,10 @@ export function AdminRolesConsole(): React.ReactElement {
|
||||
onClick={() =>
|
||||
requestConfirm({
|
||||
title: t("confirmSaveRoleTitle"),
|
||||
description: t("confirmSaveRoleEditDescription", { name: roleName || "—" }),
|
||||
description:
|
||||
editingRoleId === null
|
||||
? t("confirmSaveRoleCreateDescription", { name: roleName || "—" })
|
||||
: t("confirmSaveRoleEditDescription", { name: roleName || "—" }),
|
||||
confirmLabel: t("confirm.confirmSave", { ns: "common" }),
|
||||
onConfirm: () => submitRole(),
|
||||
})
|
||||
|
||||
@@ -179,8 +179,9 @@ export function AgentLineDetailPanel({
|
||||
detailTab === "players" ? playerActionHint : childActionHint;
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[28rem] min-w-0 flex-1 flex-col bg-background">
|
||||
<header className="border-b border-border/60 bg-card px-5 py-4 sm:px-6">
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden bg-background">
|
||||
<div className="shrink-0 bg-card shadow-[0_1px_0_rgb(216_230_251_/_35%)]">
|
||||
<header className="border-b border-border/60 px-5 py-4 sm:px-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2.5">
|
||||
@@ -232,7 +233,7 @@ export function AgentLineDetailPanel({
|
||||
|
||||
<AdminSubnav
|
||||
aria-label={t("detailTabs", { defaultValue: "代理详情" })}
|
||||
className="overflow-x-auto bg-card px-4 sm:px-5"
|
||||
className="overflow-x-auto border-b border-border/60 px-4 sm:px-5"
|
||||
>
|
||||
{tabs
|
||||
.filter((tab) => tab.visible)
|
||||
@@ -247,8 +248,9 @@ export function AgentLineDetailPanel({
|
||||
</AdminSubnavButton>
|
||||
))}
|
||||
</AdminSubnav>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto bg-muted/15 px-5 py-5 sm:px-6 sm:py-6">
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain bg-muted/15 px-5 py-5 sm:px-6 sm:py-6">
|
||||
{detailTab === "overview" ? (
|
||||
<OverviewTab
|
||||
profile={profile}
|
||||
@@ -392,47 +394,42 @@ function OverviewTab({
|
||||
</div>
|
||||
|
||||
{!profileLoading && profile ? (
|
||||
<>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<MetricCard
|
||||
label={t("profile.rebateLimit", { defaultValue: "回水上限 (%)" })}
|
||||
value={`${rebateCap ?? "0"}%`}
|
||||
/>
|
||||
<MetricCard
|
||||
label={t("profile.defaultPlayerRebate", { defaultValue: "默认玩家回水 (%)" })}
|
||||
value={`${percentValueToUi(profile.default_player_rebate ?? 0)}%`}
|
||||
/>
|
||||
<MetricCard
|
||||
label={t("profile.riskTags", { defaultValue: "风控标签" })}
|
||||
value={
|
||||
(profile.risk_tags?.length ?? 0) > 0
|
||||
? profile.risk_tags!.join(", ")
|
||||
: t("common:states.none", { defaultValue: "无" })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<CapabilityMetric
|
||||
label={t("profile.canGrantExtraRebate", { defaultValue: "允许额外回水" })}
|
||||
enabled={profile.can_grant_extra_rebate === true}
|
||||
yesLabel={yesLabel}
|
||||
noLabel={noLabel}
|
||||
/>
|
||||
<CapabilityMetric
|
||||
label={t("profile.canCreatePlayer", { defaultValue: "允许创建玩家" })}
|
||||
enabled={profile.can_create_player !== false}
|
||||
yesLabel={yesLabel}
|
||||
noLabel={noLabel}
|
||||
/>
|
||||
<CapabilityMetric
|
||||
label={t("profile.canCreateChildAgent", { defaultValue: "允许创建下级代理" })}
|
||||
enabled={profile.can_create_child_agent === true}
|
||||
yesLabel={yesLabel}
|
||||
noLabel={noLabel}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||
<MetricCard
|
||||
label={t("profile.rebateLimit", { defaultValue: "回水上限 (%)" })}
|
||||
value={`${rebateCap ?? "0"}%`}
|
||||
/>
|
||||
<MetricCard
|
||||
label={t("profile.defaultPlayerRebate", { defaultValue: "默认玩家回水 (%)" })}
|
||||
value={`${percentValueToUi(profile.default_player_rebate ?? 0)}%`}
|
||||
/>
|
||||
<MetricCard
|
||||
label={t("profile.riskTags", { defaultValue: "风控标签" })}
|
||||
value={
|
||||
(profile.risk_tags?.length ?? 0) > 0
|
||||
? profile.risk_tags!.join(", ")
|
||||
: t("common:states.none", { defaultValue: "无" })
|
||||
}
|
||||
/>
|
||||
<CapabilityMetric
|
||||
label={t("profile.canGrantExtraRebate", { defaultValue: "允许额外回水" })}
|
||||
enabled={profile.can_grant_extra_rebate === true}
|
||||
yesLabel={yesLabel}
|
||||
noLabel={noLabel}
|
||||
/>
|
||||
<CapabilityMetric
|
||||
label={t("profile.canCreatePlayer", { defaultValue: "允许创建玩家" })}
|
||||
enabled={profile.can_create_player !== false}
|
||||
yesLabel={yesLabel}
|
||||
noLabel={noLabel}
|
||||
/>
|
||||
<CapabilityMetric
|
||||
label={t("profile.canCreateChildAgent", { defaultValue: "允许创建下级代理" })}
|
||||
enabled={profile.can_create_child_agent === true}
|
||||
yesLabel={yesLabel}
|
||||
noLabel={noLabel}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
@@ -454,7 +451,7 @@ function CapabilityMetric({
|
||||
<p className="text-xs font-medium text-muted-foreground">{label}</p>
|
||||
<p
|
||||
className={cn(
|
||||
"mt-1.5 text-lg font-semibold",
|
||||
"mt-1.5 text-2xl font-semibold tracking-tight",
|
||||
enabled ? "text-foreground" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -231,7 +231,7 @@ export function AgentLineSidebar({
|
||||
const hasAnyAgent = displayForest.length > 0;
|
||||
|
||||
return (
|
||||
<aside className="flex h-full min-h-[28rem] w-full flex-col bg-muted/10 lg:w-[18rem] lg:shrink-0 lg:border-r lg:border-border/70">
|
||||
<aside className="flex min-h-0 h-full w-full flex-col bg-muted/10 lg:w-[18rem] lg:shrink-0 lg:border-r lg:border-border/70">
|
||||
<div className="space-y-3 border-b border-border/60 bg-card px-4 py-4">
|
||||
{siteLabel ? (
|
||||
<p className="truncate text-xs font-medium text-foreground/80" title={siteLabel}>
|
||||
|
||||
@@ -23,6 +23,7 @@ import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
||||
import {
|
||||
AGENT_PERCENT_HARD_MAX,
|
||||
actualShareRateFromRelative,
|
||||
creditLimitRangeIssue,
|
||||
isNumericStepperOutOfRange,
|
||||
maxCreditLimitFromParent,
|
||||
maxDefaultRebatePercent,
|
||||
@@ -104,6 +105,10 @@ export function AgentProfileFields({
|
||||
const maxDefaultRebate = maxDefaultRebatePercent(rebateLimit, parentCaps);
|
||||
const maxCreditLimit = maxCreditLimitFromParent(parentCaps, baselineCreditLimit);
|
||||
const actualShare = actualShareRateFromRelative(Number.parseFloat(shareRate) || 0, parentCaps);
|
||||
const creditRangeIssue = creditLimitRangeIssue(creditLimit, {
|
||||
min: minCreditLimit,
|
||||
max: maxCreditLimit,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -224,19 +229,19 @@ export function AgentProfileFields({
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
{profileScalarsEditable &&
|
||||
isNumericStepperOutOfRange(creditLimit, {
|
||||
min: minCreditLimit,
|
||||
max: maxCreditLimit,
|
||||
integer: true,
|
||||
}) ? (
|
||||
{profileScalarsEditable && creditRangeIssue === "below_min" ? (
|
||||
<p className="text-xs text-destructive">
|
||||
{t("profile.validation.creditBelowAllocated", {
|
||||
defaultValue: "授信额度不能低于已下发给下级/玩家的总额(当前至少 {{min}})",
|
||||
min: formatAdminCreditMajorDecimal(minCreditLimit, currencyCode),
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
{profileScalarsEditable && creditRangeIssue === "above_max" && maxCreditLimit !== undefined ? (
|
||||
<p className="text-xs text-destructive">
|
||||
{t("profile.validation.creditExceedsParentWithMax", {
|
||||
defaultValue: "授信额度不能超过 {{max}}",
|
||||
max:
|
||||
maxCreditLimit !== undefined
|
||||
? formatAdminCreditMajorDecimal(maxCreditLimit, currencyCode)
|
||||
: creditLimit,
|
||||
max: formatAdminCreditMajorDecimal(maxCreditLimit, currencyCode),
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
@@ -21,6 +21,8 @@ import { AgentLineProvisionWizard } from "@/modules/agents/agent-line-provision-
|
||||
import { AgentLineSidebar } from "@/modules/agents/agent-line-sidebar";
|
||||
import { AgentProfileFields } from "@/modules/agents/agent-profile-fields";
|
||||
import { AdminLoadingState } from "@/components/admin/admin-loading-state";
|
||||
import { AdminPageGuide } from "@/components/admin/admin-page-guide";
|
||||
import { ADMIN_DOC_LINKS } from "@/lib/admin-doc-links";
|
||||
import { AdminNoIntegrationSiteState } from "@/components/admin/admin-no-integration-site-state";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -952,7 +954,7 @@ export function AgentsConsole(): React.ReactElement {
|
||||
|
||||
if (showProvisionEmpty) {
|
||||
return (
|
||||
<div className="flex min-h-[32rem] flex-col gap-0">
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-0">
|
||||
<AgentLineProvisionWizard
|
||||
embedded
|
||||
defaultSiteCode={activeSiteCode}
|
||||
@@ -965,13 +967,14 @@ export function AgentsConsole(): React.ReactElement {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[32rem] flex-col gap-0">
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-0">
|
||||
<AdminPageGuide guide={t("pageGuide")} docHref={ADMIN_DOC_LINKS.agents} className="mb-4 px-1" />
|
||||
<ConfirmDialog />
|
||||
|
||||
{canViewAgents && err ? <p className="px-1 text-sm text-destructive">{err}</p> : null}
|
||||
|
||||
{canViewAgents ? (
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-2xl border border-border/70 bg-card shadow-sm lg:flex-row">
|
||||
<div className="flex max-h-[calc(100dvh-14rem)] min-h-[28rem] flex-1 flex-col overflow-hidden rounded-2xl border border-border/70 bg-card shadow-sm lg:flex-row">
|
||||
{showAgentSidebar ? (
|
||||
<AgentLineSidebar
|
||||
siteLabel={selectedSiteLabel}
|
||||
|
||||
418
src/modules/docs/admin/admin-doc-screens.tsx
Normal file
418
src/modules/docs/admin/admin-doc-screens.tsx
Normal file
@@ -0,0 +1,418 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
|
||||
import {
|
||||
DocList,
|
||||
DocNote,
|
||||
DocOrderedList,
|
||||
DocPage,
|
||||
DocPageHeader,
|
||||
DocParagraph,
|
||||
DocSection,
|
||||
DocTable,
|
||||
} from "@/components/docs/doc-ui";
|
||||
import { useAdminDoc } from "@/modules/docs/admin/use-admin-doc";
|
||||
|
||||
export function AdminOverviewDocScreen(): React.ReactElement {
|
||||
const { p, rows, list, header } = useAdminDoc("overview");
|
||||
|
||||
return (
|
||||
<DocPage>
|
||||
<DocPageHeader title={p("title")} description={p("description")} />
|
||||
<DocNote>{p("loginNote")}</DocNote>
|
||||
<DocSection title={p("scope")}>
|
||||
<DocList items={list("scopeItems")} />
|
||||
</DocSection>
|
||||
<DocSection title={p("menuMap")}>
|
||||
<DocParagraph>{p("menuMapNote")}</DocParagraph>
|
||||
<DocTable compact headers={header("menu")} rows={rows("menuMapRows")} />
|
||||
</DocSection>
|
||||
<DocSection title={p("modes")}>
|
||||
<DocTable compact headers={header("module")} rows={rows("modeRows")} />
|
||||
</DocSection>
|
||||
<DocSection title={p("readingOrder")}>
|
||||
<DocOrderedList items={list("readingItems")} />
|
||||
</DocSection>
|
||||
<DocNote>{p("note")}</DocNote>
|
||||
</DocPage>
|
||||
);
|
||||
}
|
||||
|
||||
export function AdminRolesDocScreen(): React.ReactElement {
|
||||
const { p, rows, list, header } = useAdminDoc("roles");
|
||||
|
||||
return (
|
||||
<DocPage>
|
||||
<DocPageHeader title={p("title")} description={p("description")} />
|
||||
<DocSection title={p("matrix")}>
|
||||
<DocTable compact headers={header("role")} rows={rows("matrixRows")} />
|
||||
</DocSection>
|
||||
<DocSection title={p("accountModel")}>
|
||||
<DocList items={list("accountItems")} />
|
||||
</DocSection>
|
||||
<DocSection title={p("accountSetup")}>
|
||||
<DocOrderedList items={list("accountSetupSteps")} />
|
||||
</DocSection>
|
||||
<DocNote>{p("note")}</DocNote>
|
||||
</DocPage>
|
||||
);
|
||||
}
|
||||
|
||||
export function AdminSiteSetupDocScreen(): React.ReactElement {
|
||||
const { p, rows, list, header } = useAdminDoc("siteSetup");
|
||||
|
||||
return (
|
||||
<DocPage>
|
||||
<DocPageHeader title={p("title")} description={p("description")} />
|
||||
<DocSection title={p("path")}>
|
||||
<DocOrderedList items={list("pathItems")} />
|
||||
</DocSection>
|
||||
<DocSection title={p("fields")}>
|
||||
<DocTable compact headers={header("field")} rows={rows("fieldRows")} />
|
||||
</DocSection>
|
||||
<DocSection title={p("caution")}>
|
||||
<DocList items={list("cautionItems")} />
|
||||
</DocSection>
|
||||
<DocNote>
|
||||
{p("apiLinkNote")}{" "}
|
||||
<Link
|
||||
href="/docs/integration/preparation"
|
||||
className="font-medium text-slate-900 underline decoration-slate-300 underline-offset-2 hover:decoration-slate-500"
|
||||
>
|
||||
{p("apiLinkLabel")}
|
||||
</Link>
|
||||
</DocNote>
|
||||
</DocPage>
|
||||
);
|
||||
}
|
||||
|
||||
export function AdminDrawsDocScreen(): React.ReactElement {
|
||||
const { p, rows, list, header } = useAdminDoc("draws");
|
||||
|
||||
return (
|
||||
<DocPage>
|
||||
<DocPageHeader title={p("title")} description={p("description")} />
|
||||
<DocSection title={p("lifecycle")}>
|
||||
<DocTable compact headers={header("status")} rows={rows("statusRows")} />
|
||||
</DocSection>
|
||||
<DocSection title={p("workflow")}>
|
||||
<DocOrderedList items={list("workflowItems")} />
|
||||
</DocSection>
|
||||
<DocSection title={p("publishWalkthrough")}>
|
||||
<DocOrderedList items={list("publishSteps")} />
|
||||
</DocSection>
|
||||
<DocSection title={p("reopenWalkthrough")}>
|
||||
<DocOrderedList items={list("reopenSteps")} />
|
||||
</DocSection>
|
||||
<DocSection title={p("rules")}>
|
||||
<DocList items={list("rulesItems")} />
|
||||
</DocSection>
|
||||
<DocNote>{p("note")}</DocNote>
|
||||
<DocParagraph>
|
||||
{p("manualReviewLinkNote")}{" "}
|
||||
<Link
|
||||
href="/docs/admin/manual-review"
|
||||
className="font-medium text-slate-900 underline decoration-slate-300 underline-offset-2 hover:decoration-slate-500"
|
||||
>
|
||||
{p("manualReviewLinkLabel")}
|
||||
</Link>
|
||||
</DocParagraph>
|
||||
</DocPage>
|
||||
);
|
||||
}
|
||||
|
||||
export function AdminSettlementCenterDocScreen(): React.ReactElement {
|
||||
const { p, rows, list, header } = useAdminDoc("settlementCenter");
|
||||
|
||||
return (
|
||||
<DocPage>
|
||||
<DocPageHeader title={p("title")} description={p("description")} />
|
||||
<DocSection title={p("entry")}>
|
||||
<DocList items={list("entryItems")} />
|
||||
</DocSection>
|
||||
<DocSection title={p("periodFlow")}>
|
||||
<DocOrderedList items={list("periodItems")} />
|
||||
</DocSection>
|
||||
<DocSection title={p("openWalkthrough")}>
|
||||
<DocOrderedList items={list("openSteps")} />
|
||||
</DocSection>
|
||||
<DocSection title={p("closeWalkthrough")}>
|
||||
<DocOrderedList items={list("closeSteps")} />
|
||||
</DocSection>
|
||||
<DocSection title={p("paymentWalkthrough")}>
|
||||
<DocOrderedList items={list("paymentSteps")} />
|
||||
</DocSection>
|
||||
<DocSection title={p("detailTabs")}>
|
||||
<DocList items={list("detailTabItems")} />
|
||||
</DocSection>
|
||||
<DocSection title={p("billStatusSection")}>
|
||||
<DocTable compact headers={header("billStatusTable")} rows={rows("billStatusRows")} />
|
||||
</DocSection>
|
||||
<DocSection title={p("operations")}>
|
||||
<DocList items={list("operationItems")} />
|
||||
</DocSection>
|
||||
<DocNote>{p("note")}</DocNote>
|
||||
<DocParagraph>
|
||||
{p("fundOpsLinkNote")}{" "}
|
||||
<Link
|
||||
href="/docs/admin/fund-operations"
|
||||
className="font-medium text-slate-900 underline decoration-slate-300 underline-offset-2 hover:decoration-slate-500"
|
||||
>
|
||||
{p("fundOpsLinkLabel")}
|
||||
</Link>
|
||||
</DocParagraph>
|
||||
</DocPage>
|
||||
);
|
||||
}
|
||||
|
||||
export function AdminAgentsDocScreen(): React.ReactElement {
|
||||
const { p, rows, list, header } = useAdminDoc("agents");
|
||||
|
||||
return (
|
||||
<DocPage>
|
||||
<DocPageHeader title={p("title")} description={p("description")} />
|
||||
<DocSection title={p("structure")}>
|
||||
<DocOrderedList items={list("structureItems")} />
|
||||
</DocSection>
|
||||
<DocSection title={p("provisionWalkthrough")}>
|
||||
<DocOrderedList items={list("provisionSteps")} />
|
||||
</DocSection>
|
||||
<DocSection title={p("dailyWalkthrough")}>
|
||||
<DocOrderedList items={list("dailySteps")} />
|
||||
</DocSection>
|
||||
<DocSection title={p("profile")}>
|
||||
<DocTable compact headers={header("field")} rows={rows("profileRows")} />
|
||||
</DocSection>
|
||||
<DocSection title={p("siteAdmin")}>
|
||||
<DocList items={list("siteAdminItems")} />
|
||||
</DocSection>
|
||||
<DocNote>{p("note")}</DocNote>
|
||||
</DocPage>
|
||||
);
|
||||
}
|
||||
|
||||
export function AdminPlayersDocScreen(): React.ReactElement {
|
||||
const { p, rows, list, header } = useAdminDoc("players");
|
||||
|
||||
return (
|
||||
<DocPage>
|
||||
<DocPageHeader title={p("title")} description={p("description")} />
|
||||
<DocSection title={p("list")}>
|
||||
<DocList items={list("listItems")} />
|
||||
</DocSection>
|
||||
<DocSection title={p("createWalkthrough")}>
|
||||
<DocOrderedList items={list("createSteps")} />
|
||||
</DocSection>
|
||||
<DocSection title={p("freezeWalkthrough")}>
|
||||
<DocOrderedList items={list("freezeSteps")} />
|
||||
</DocSection>
|
||||
<DocSection title={p("modes")}>
|
||||
<DocTable compact headers={header("module")} rows={rows("modeRows")} />
|
||||
</DocSection>
|
||||
<DocSection title={p("detail")}>
|
||||
<DocList items={list("detailItems")} />
|
||||
</DocSection>
|
||||
<DocNote>{p("note")}</DocNote>
|
||||
</DocPage>
|
||||
);
|
||||
}
|
||||
|
||||
export function AdminTicketsDocScreen(): React.ReactElement {
|
||||
const { p, list } = useAdminDoc("tickets");
|
||||
|
||||
return (
|
||||
<DocPage>
|
||||
<DocPageHeader title={p("title")} description={p("description")} />
|
||||
<DocSection title={p("entry")}>
|
||||
<DocList items={list("entryItems")} />
|
||||
</DocSection>
|
||||
<DocSection title={p("filter")}>
|
||||
<DocList items={list("filterItems")} />
|
||||
</DocSection>
|
||||
<DocSection title={p("detail")}>
|
||||
<DocList items={list("detailItems")} />
|
||||
</DocSection>
|
||||
<DocNote>{p("note")}</DocNote>
|
||||
</DocPage>
|
||||
);
|
||||
}
|
||||
|
||||
export function AdminWalletDocScreen(): React.ReactElement {
|
||||
const { p, list } = useAdminDoc("wallet");
|
||||
|
||||
return (
|
||||
<DocPage>
|
||||
<DocPageHeader title={p("title")} description={p("description")} />
|
||||
<DocSection title={p("walletSection")}>
|
||||
<DocList items={list("walletItems")} />
|
||||
</DocSection>
|
||||
<DocSection title={p("transferSection")}>
|
||||
<DocList items={list("transferItems")} />
|
||||
</DocSection>
|
||||
<DocSection title={p("reconcileSection")}>
|
||||
<DocOrderedList items={list("reconcileSteps")} />
|
||||
</DocSection>
|
||||
<DocNote>{p("note")}</DocNote>
|
||||
<DocParagraph>
|
||||
{p("fundOpsLinkNote")}{" "}
|
||||
<Link
|
||||
href="/docs/admin/fund-operations"
|
||||
className="font-medium text-slate-900 underline decoration-slate-300 underline-offset-2 hover:decoration-slate-500"
|
||||
>
|
||||
{p("fundOpsLinkLabel")}
|
||||
</Link>
|
||||
</DocParagraph>
|
||||
</DocPage>
|
||||
);
|
||||
}
|
||||
|
||||
export function AdminConfigDocScreen(): React.ReactElement {
|
||||
const { p, list } = useAdminDoc("config");
|
||||
|
||||
return (
|
||||
<DocPage>
|
||||
<DocPageHeader title={p("title")} description={p("description")} />
|
||||
<DocSection title={p("plays")}>
|
||||
<DocList items={list("playsItems")} />
|
||||
</DocSection>
|
||||
<DocSection title={p("odds")}>
|
||||
<DocList items={list("oddsItems")} />
|
||||
</DocSection>
|
||||
<DocSection title={p("riskCap")}>
|
||||
<DocList items={list("riskCapItems")} />
|
||||
</DocSection>
|
||||
<DocSection title={p("risk")}>
|
||||
<DocList items={list("riskItems")} />
|
||||
</DocSection>
|
||||
<DocSection title={p("jackpot")}>
|
||||
<DocList items={list("jackpotItems")} />
|
||||
</DocSection>
|
||||
<DocNote>{p("note")}</DocNote>
|
||||
</DocPage>
|
||||
);
|
||||
}
|
||||
|
||||
export function AdminFundOperationsDocScreen(): React.ReactElement {
|
||||
const { p, rows, list, header } = useAdminDoc("fundOperations");
|
||||
|
||||
return (
|
||||
<DocPage>
|
||||
<DocPageHeader title={p("title")} description={p("description")} />
|
||||
<DocSection title={p("twoSystems")}>
|
||||
<DocList items={list("twoSystemsItems")} />
|
||||
</DocSection>
|
||||
<DocSection title={p("creditModel")}>
|
||||
<DocList items={list("creditModelItems")} />
|
||||
</DocSection>
|
||||
<DocSection title={p("creditLifecycle")}>
|
||||
<DocOrderedList items={list("creditLifecycleSteps")} />
|
||||
</DocSection>
|
||||
<DocSection title={p("creditLedger")}>
|
||||
<DocTable compact headers={header("ledger")} rows={rows("creditLedgerRows")} />
|
||||
</DocSection>
|
||||
<DocSection title={p("creditBill")}>
|
||||
<DocList items={list("creditBillItems")} />
|
||||
</DocSection>
|
||||
<DocSection title={p("creditAdjust")}>
|
||||
<DocOrderedList items={list("creditAdjustSteps")} />
|
||||
</DocSection>
|
||||
<DocSection title={p("walletLifecycle")}>
|
||||
<DocOrderedList items={list("walletLifecycleSteps")} />
|
||||
</DocSection>
|
||||
<DocSection title={p("walletTxn")}>
|
||||
<DocTable compact headers={header("walletTxn")} rows={rows("walletTxnRows")} />
|
||||
</DocSection>
|
||||
<DocSection title={p("walletReconcile")}>
|
||||
<DocTable compact headers={header("reconcile")} rows={rows("walletReconcileRows")} />
|
||||
</DocSection>
|
||||
<DocSection title={p("compare")}>
|
||||
<DocTable compact headers={header("compare")} rows={rows("compareRows")} />
|
||||
</DocSection>
|
||||
<DocNote>{p("note")}</DocNote>
|
||||
</DocPage>
|
||||
);
|
||||
}
|
||||
|
||||
export function AdminManualReviewDocScreen(): React.ReactElement {
|
||||
const { p, rows, list, header } = useAdminDoc("manualReview");
|
||||
|
||||
return (
|
||||
<DocPage>
|
||||
<DocPageHeader title={p("title")} description={p("description")} />
|
||||
<DocSection title={p("distinction")}>
|
||||
<DocList items={list("distinctionItems")} />
|
||||
</DocSection>
|
||||
<DocSection title={p("drawReview")}>
|
||||
<DocList items={list("drawReviewItems")} />
|
||||
</DocSection>
|
||||
<DocSection title={p("drawPublishSteps")}>
|
||||
<DocOrderedList items={list("drawPublishStepItems")} />
|
||||
</DocSection>
|
||||
<DocSection title={p("cooldown")}>
|
||||
<DocList items={list("cooldownItems")} />
|
||||
</DocSection>
|
||||
<DocSection title={p("settlementBatch")}>
|
||||
<DocList items={list("settlementBatchItems")} />
|
||||
</DocSection>
|
||||
<DocSection title={p("batchStatusSection")}>
|
||||
<DocTable compact headers={header("batchStatus")} rows={rows("batchStatusRows")} />
|
||||
</DocSection>
|
||||
<DocSection title={p("batchWalkthrough")}>
|
||||
<DocOrderedList items={list("batchWalkthroughSteps")} />
|
||||
</DocSection>
|
||||
<DocSection title={p("settings")}>
|
||||
<DocTable compact headers={header("setting")} rows={rows("settingRows")} />
|
||||
</DocSection>
|
||||
<DocNote>{p("settingsNote")}</DocNote>
|
||||
<DocNote>{p("rejectNote")}</DocNote>
|
||||
<DocNote>{p("note")}</DocNote>
|
||||
</DocPage>
|
||||
);
|
||||
}
|
||||
|
||||
export function AdminReportsDocScreen(): React.ReactElement {
|
||||
const { p, rows, list, header } = useAdminDoc("reports");
|
||||
|
||||
return (
|
||||
<DocPage>
|
||||
<DocPageHeader title={p("title")} description={p("description")} />
|
||||
<DocSection title={p("entry")}>
|
||||
<DocList items={list("entryItems")} />
|
||||
</DocSection>
|
||||
<DocSection title={p("types")}>
|
||||
<DocTable compact headers={header("report")} rows={rows("reportRows")} />
|
||||
</DocSection>
|
||||
<DocSection title={p("export")}>
|
||||
<DocList items={list("exportItems")} />
|
||||
</DocSection>
|
||||
<DocSection title={p("scope")}>
|
||||
<DocList items={list("scopeItems")} />
|
||||
</DocSection>
|
||||
</DocPage>
|
||||
);
|
||||
}
|
||||
|
||||
export function AdminFaqDocScreen(): React.ReactElement {
|
||||
const { p, rows, list, header } = useAdminDoc("faq");
|
||||
|
||||
return (
|
||||
<DocPage>
|
||||
<DocPageHeader title={p("title")} description={p("description")} />
|
||||
<DocSection>
|
||||
<DocTable compact headers={header("faq")} rows={rows("faqRows")} />
|
||||
</DocSection>
|
||||
<DocSection title={p("integration")}>
|
||||
<DocList items={list("integrationItems")} />
|
||||
<DocParagraph>
|
||||
<Link
|
||||
href="/docs/integration"
|
||||
className="font-medium text-slate-900 underline decoration-slate-300 underline-offset-2 hover:decoration-slate-500"
|
||||
>
|
||||
{p("integrationLinkLabel")}
|
||||
</Link>
|
||||
</DocParagraph>
|
||||
</DocSection>
|
||||
</DocPage>
|
||||
);
|
||||
}
|
||||
46
src/modules/docs/admin/use-admin-doc.ts
Normal file
46
src/modules/docs/admin/use-admin-doc.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export type AdminDocPageKey =
|
||||
| "overview"
|
||||
| "roles"
|
||||
| "siteSetup"
|
||||
| "draws"
|
||||
| "settlementCenter"
|
||||
| "agents"
|
||||
| "players"
|
||||
| "tickets"
|
||||
| "wallet"
|
||||
| "config"
|
||||
| "fundOperations"
|
||||
| "manualReview"
|
||||
| "reports"
|
||||
| "faq";
|
||||
|
||||
const ADMIN_DOCS_NS = "adminDocs";
|
||||
|
||||
function asStringArray(value: unknown): string[] {
|
||||
return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
|
||||
}
|
||||
|
||||
function asStringMatrix(value: unknown): string[][] {
|
||||
return Array.isArray(value)
|
||||
? value.filter((row): row is string[] => Array.isArray(row) && row.every((cell) => typeof cell === "string"))
|
||||
: [];
|
||||
}
|
||||
|
||||
export function useAdminDoc(page: AdminDocPageKey) {
|
||||
const { t } = useTranslation(ADMIN_DOCS_NS);
|
||||
|
||||
return {
|
||||
t,
|
||||
p: (key: string) => t(`pages.${page}.${key}`, { ns: ADMIN_DOCS_NS }),
|
||||
rows: (key: string) =>
|
||||
asStringMatrix(t(`pages.${page}.${key}`, { returnObjects: true, ns: ADMIN_DOCS_NS })),
|
||||
list: (key: string) =>
|
||||
asStringArray(t(`pages.${page}.${key}`, { returnObjects: true, ns: ADMIN_DOCS_NS })),
|
||||
header: (key: string) =>
|
||||
asStringArray(t(`headers.${key}`, { returnObjects: true, ns: ADMIN_DOCS_NS })),
|
||||
};
|
||||
}
|
||||
373
src/modules/docs/integration/api-reference-screen.tsx
Normal file
373
src/modules/docs/integration/api-reference-screen.tsx
Normal file
@@ -0,0 +1,373 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
DocCode,
|
||||
DocEndpoint,
|
||||
DocPage,
|
||||
DocPageHeader,
|
||||
DocParagraph,
|
||||
DocSection,
|
||||
DocTable,
|
||||
DocList,
|
||||
DocOrderedList,
|
||||
DocNote,
|
||||
} from "@/components/docs/doc-ui";
|
||||
|
||||
const CURL_PLAYER_ME = `curl -sS "https://{lottery_api}/api/v1/player/me" \\
|
||||
-H "Authorization: Bearer {JWT}" \\
|
||||
-H "Accept: application/json"`;
|
||||
|
||||
const CURL_WALLET_BALANCE = `curl -sS "https://{wallet_host}/wallet/balance?site_code=demo&site_player_id=100001¤cy_code=NPR" \\
|
||||
-H "Authorization: Bearer {wallet_api_key}"`;
|
||||
|
||||
const CURL_WALLET_DEBIT = `curl -sS -X POST "https://{wallet_host}/wallet/debit-for-lottery" \\
|
||||
-H "Authorization: Bearer {wallet_api_key}" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{
|
||||
"site_code": "demo",
|
||||
"site_player_id": "100001",
|
||||
"player_id": 42,
|
||||
"currency_code": "NPR",
|
||||
"amount_minor": 100,
|
||||
"idempotent_key": "accept-debit-001"
|
||||
}'`;
|
||||
|
||||
const IFRAME_EXAMPLE = `<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>主站集成</title></head>
|
||||
<body>
|
||||
<iframe id="lotteryFrame" src="https://lottery.example.com/"></iframe>
|
||||
<script>
|
||||
const LOTTERY_ORIGIN = "https://lottery.example.com";
|
||||
|
||||
window.addEventListener("message", (event) => {
|
||||
if (event.origin !== LOTTERY_ORIGIN) return;
|
||||
const { data } = event;
|
||||
switch (data.type) {
|
||||
case "LOTTERY_READY":
|
||||
fetchNewToken().then(token => {
|
||||
sendToIframe("MAIN_INIT_TOKEN", { token });
|
||||
});
|
||||
break;
|
||||
case "LOTTERY_TOKEN_NEEDED":
|
||||
case "LOTTERY_TOKEN_REFRESH_REQUEST":
|
||||
fetchNewToken().then(token => {
|
||||
sendToIframe("MAIN_REFRESH_TOKEN", { token });
|
||||
});
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
function sendToIframe(type, payload) {
|
||||
const iframe = document.getElementById("lotteryFrame");
|
||||
iframe.contentWindow.postMessage(
|
||||
{ type, payload, timestamp: Date.now(), source: "main-site" },
|
||||
LOTTERY_ORIGIN
|
||||
);
|
||||
}
|
||||
|
||||
async function fetchNewToken() {
|
||||
const res = await fetch("/api/auth/lottery-token", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
});
|
||||
const data = await res.json();
|
||||
return data.token;
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
const JWT_SIGN_TS = `const header = base64url(JSON.stringify({ alg: "HS256", typ: "JWT" }));
|
||||
const payload = base64url(JSON.stringify({
|
||||
site_code: "demo",
|
||||
site_player_id: "100001",
|
||||
iat: Math.floor(Date.now() / 1000),
|
||||
exp: Math.floor(Date.now() / 1000) + 300,
|
||||
}));
|
||||
const sig = hmacSha256Base64url(\`\${header}.\${payload}\`, SSO_JWT_SECRET);
|
||||
const token = \`\${header}.\${payload}.\${sig}\`;`;
|
||||
|
||||
export function ApiReferenceDocScreen(): React.ReactElement {
|
||||
return (
|
||||
<DocPage>
|
||||
<DocPageHeader
|
||||
title="API 对接参考"
|
||||
description="面向主站开发/集成工程师的技术文档:SSO、iframe、钱包网关、错误码与上线清单。"
|
||||
/>
|
||||
|
||||
<DocSection title="1. 接入总览">
|
||||
<DocTable
|
||||
compact
|
||||
headers={["组件", "职责", "实现方"]}
|
||||
rows={[
|
||||
["主站", "签发 JWT;实现钱包网关(余额查询 / 扣款 / 加款)", "客户"],
|
||||
["彩票 API", "验签、玩法、划转、下注、结算、开奖", "我方"],
|
||||
["彩票前端", "H5 / iframe 承载,玩家交互界面", "我方"],
|
||||
]}
|
||||
/>
|
||||
<DocNote>
|
||||
金额一律使用最小货币单位整数(minor),如 2000 = 20.00。编码 UTF-8 JSON。
|
||||
</DocNote>
|
||||
<DocOrderedList
|
||||
items={[
|
||||
"主站登录 → 服务端签发 JWT",
|
||||
"进入彩票(URL 跳转或 iframe 嵌入)",
|
||||
"转入:主站扣款 + 彩票加款",
|
||||
"下注 / 派奖(彩票内余额)",
|
||||
"转出:彩票扣款 + 主站加款",
|
||||
]}
|
||||
/>
|
||||
</DocSection>
|
||||
|
||||
<DocSection title="2. 快速开始">
|
||||
<DocParagraph>最小验证步骤:</DocParagraph>
|
||||
<DocCode language="bash">{CURL_PLAYER_ME}</DocCode>
|
||||
<DocCode language="bash">{CURL_WALLET_BALANCE}</DocCode>
|
||||
<DocCode language="bash">{CURL_WALLET_DEBIT}</DocCode>
|
||||
</DocSection>
|
||||
|
||||
<DocSection title="3. 接入配置">
|
||||
<DocOrderedList
|
||||
items={[
|
||||
"超管登录后台 → 平台管理 → 接入配置 → 新建站点",
|
||||
"填写站点编码(site_code)、名称、默认币种、wallet_api_url、lottery_h5_base_url、iframe_allowed_origins",
|
||||
"创建成功后立即保存一次性展示的 sso_jwt_secret 和 wallet_api_key",
|
||||
"将密钥写入主站 .env;执行「连通性测试」",
|
||||
]}
|
||||
/>
|
||||
<DocTable
|
||||
compact
|
||||
headers={["项", "后台字段", "主站 .env", "说明"]}
|
||||
rows={[
|
||||
["站点编码", "code", "MAIN_SITE_CODE", "JWT 与玩家建档标识;双方须一致"],
|
||||
["SSO 密钥", "sso_jwt_secret", "MAIN_SITE_SSO_JWT_SECRET", "主站签发;彩票验签"],
|
||||
["钱包鉴权", "wallet_api_key", "MAIN_SITE_WALLET_API_KEY", "彩票回调主站时 Bearer 携带;主站校验"],
|
||||
["钱包根地址", "wallet_api_url", "—", "客户 HTTPS 根地址;彩票拼接 /wallet/* 路径"],
|
||||
["彩票入口", "lottery_h5_base_url", "NEXT_PUBLIC_LOTTERY_IFRAME_URL", "跳转或 iframe 目标"],
|
||||
["iframe 白名单", "iframe_allowed_origins", "NEXT_PUBLIC_LOTTERY_ORIGIN", "主站 origin;彩票允许嵌入"],
|
||||
]}
|
||||
/>
|
||||
<DocNote>
|
||||
测试与生产环境的 site_code、密钥、域名须完全隔离。生产环境 wallet_api_url 仅允许 HTTPS 公网地址,拒绝 localhost / 私网 IP。
|
||||
</DocNote>
|
||||
</DocSection>
|
||||
|
||||
<DocSection title="4. 单点登录(SSO)">
|
||||
<DocParagraph>采用 HS256 JWT。主站签发,彩票验签。</DocParagraph>
|
||||
<DocTable
|
||||
compact
|
||||
headers={["字段", "类型", "必填", "说明"]}
|
||||
rows={[
|
||||
["site_code", "string", "是", "接入站点编码"],
|
||||
["site_player_id", "string", "是", "主站用户 ID,稳定唯一"],
|
||||
["iat", "number", "是", "签发时间(Unix 秒)"],
|
||||
["exp", "number", "是", "过期时间(Unix 秒);建议 ≤ 300 秒"],
|
||||
]}
|
||||
/>
|
||||
<DocCode language="typescript">{JWT_SIGN_TS}</DocCode>
|
||||
|
||||
<DocSection title="入场方式">
|
||||
<DocParagraph>方式 A — URL 跳转:</DocParagraph>
|
||||
<DocCode>{`https://{lottery_h5_base_url}/?token={JWT}`}</DocCode>
|
||||
<DocParagraph>方式 B — iframe 嵌入:</DocParagraph>
|
||||
<DocCode>{`<iframe id="lotteryFrame" src="https://lottery.example.com/"></iframe>`}</DocCode>
|
||||
<DocNote>
|
||||
首次有效 JWT 调用 GET /api/v1/player/me 时自动建档。username / nickname 由彩票生成,不从主站同步。
|
||||
</DocNote>
|
||||
</DocSection>
|
||||
|
||||
<DocSection title="入场接口">
|
||||
<DocEndpoint method="GET" path="/api/v1/player/me" />
|
||||
<DocCode language="http">{`GET /api/v1/player/me
|
||||
Authorization: Bearer {JWT}
|
||||
Accept-Language: zh`}</DocCode>
|
||||
<DocNote>
|
||||
彩票不提供「登录换票」接口。主站登录后自行签发 JWT,玩家 API 统一用 Authorization: Bearer 携带。
|
||||
</DocNote>
|
||||
</DocSection>
|
||||
|
||||
<DocSection title="SSO 错误码">
|
||||
<DocTable
|
||||
compact
|
||||
headers={["错误码", "说明"]}
|
||||
rows={[
|
||||
["8001", "缺少 Authorization 头"],
|
||||
["8002", "JWT 无效或已过期"],
|
||||
["8003", "玩家未建档"],
|
||||
["8004", "SSO 密钥未配置"],
|
||||
["8005", "账号已冻结(站点不存在/停用或玩家冻结)"],
|
||||
]}
|
||||
/>
|
||||
</DocSection>
|
||||
</DocSection>
|
||||
|
||||
<DocSection title="5. iframe 协议">
|
||||
<DocOrderedList
|
||||
items={[
|
||||
"主站页面嵌入 <iframe src=\"{lottery_h5_base_url}\">",
|
||||
"彩票 H5 加载白名单后发送 LOTTERY_READY",
|
||||
"主站监听 message,校验 origin 后发送 MAIN_INIT_TOKEN",
|
||||
"彩票 H5 保存 token,调用 /api/v1/player/me 入场",
|
||||
"Token 将过期时:彩票发 LOTTERY_TOKEN_NEEDED → 主站续签后发 MAIN_REFRESH_TOKEN",
|
||||
]}
|
||||
/>
|
||||
<DocNote>
|
||||
postMessage 第二参数须为具体 origin(如 https://www.partner.com),禁止使用 *。
|
||||
收到 MAIN_INIT_TOKEN 后彩票子页不再发送 LOTTERY_READY,避免重复下发 token。
|
||||
</DocNote>
|
||||
|
||||
<DocSection title="彩票 → 主站">
|
||||
<DocTable
|
||||
compact
|
||||
headers={["方向", "消息类型", "说明"]}
|
||||
rows={[
|
||||
["→ 主站", "LOTTERY_READY", "子页就绪,请求下发 token"],
|
||||
["→ 主站", "LOTTERY_TOKEN_NEEDED", "token 失效,请求续签"],
|
||||
["→ 主站", "LOTTERY_TOKEN_REFRESH_REQUEST", "主动请求刷新 token"],
|
||||
["→ 主站", "LOTTERY_HEARTBEAT", "心跳(可忽略)"],
|
||||
["→ 主站", "LOTTERY_TOKEN_REFRESHED", "续签成功通知"],
|
||||
]}
|
||||
/>
|
||||
</DocSection>
|
||||
|
||||
<DocSection title="主站 → 彩票">
|
||||
<DocTable
|
||||
compact
|
||||
headers={["方向", "消息类型", "载荷", "说明"]}
|
||||
rows={[
|
||||
["→ 彩票", "MAIN_INIT_TOKEN", "{ token }", "首次下发 JWT"],
|
||||
["→ 彩票", "MAIN_REFRESH_TOKEN", "{ token }", "续签 JWT"],
|
||||
["→ 彩票", "MAIN_REQUEST_STATUS", "—", "请求子页状态"],
|
||||
["→ 彩票", "MAIN_NAVIGATE", "{ path }", "导航到指定路径"],
|
||||
]}
|
||||
/>
|
||||
</DocSection>
|
||||
|
||||
<DocSection title="完整示例">
|
||||
<DocCode>{IFRAME_EXAMPLE}</DocCode>
|
||||
</DocSection>
|
||||
</DocSection>
|
||||
|
||||
<DocSection title="6. 钱包网关">
|
||||
<DocParagraph>
|
||||
由客户实现。彩票服务端调用。鉴权:Authorization: Bearer {"{wallet_api_key}"}。
|
||||
</DocParagraph>
|
||||
|
||||
<DocSection title="查询余额">
|
||||
<DocEndpoint method="GET" path="/wallet/balance" />
|
||||
<DocTable
|
||||
compact
|
||||
headers={["参数", "类型", "说明"]}
|
||||
rows={[
|
||||
["site_code", "string", "站点编码"],
|
||||
["site_player_id", "string", "主站用户 ID"],
|
||||
["currency_code", "string", "币种代码"],
|
||||
]}
|
||||
/>
|
||||
<DocCode>{`{
|
||||
"success": true,
|
||||
"data": { "main_balance": 500000, "currency_code": "NPR" }
|
||||
}`}</DocCode>
|
||||
</DocSection>
|
||||
|
||||
<DocSection title="扣款(转入时调用)">
|
||||
<DocEndpoint method="POST" path="/wallet/debit-for-lottery" />
|
||||
<DocTable
|
||||
compact
|
||||
headers={["字段", "类型", "说明"]}
|
||||
rows={[
|
||||
["site_code", "string", "站点编码"],
|
||||
["site_player_id", "string", "主站用户 ID"],
|
||||
["player_id", "number", "彩票玩家 ID(参考)"],
|
||||
["currency_code", "string", "币种"],
|
||||
["amount_minor", "integer", "minor 正整数"],
|
||||
["idempotent_key", "string", "幂等键"],
|
||||
]}
|
||||
/>
|
||||
<DocCode>{`{
|
||||
"success": true,
|
||||
"external_ref_no": "MW-001",
|
||||
"data": { "main_balance": 498000, "currency_code": "NPR" }
|
||||
}`}</DocCode>
|
||||
</DocSection>
|
||||
|
||||
<DocSection title="加款(转出时调用)">
|
||||
<DocEndpoint method="POST" path="/wallet/credit-from-lottery" />
|
||||
<DocParagraph>请求体与扣款相同。用于转出或失败回滚加款。</DocParagraph>
|
||||
</DocSection>
|
||||
|
||||
<DocSection title="HTTP 契约">
|
||||
<DocTable
|
||||
compact
|
||||
headers={["场景", "HTTP 状态码", "响应体"]}
|
||||
rows={[
|
||||
["扣款/加款成功", "200", "success: true;含 external_ref_no 与 data.main_balance"],
|
||||
["余额查询成功", "200", "success: true;data.main_balance + currency_code"],
|
||||
["参数非法", "422", "success: false;message: invalid request"],
|
||||
["鉴权失败", "401", "success: false;message: unauthorized"],
|
||||
["业务拒绝", "409", "success: false;message 说明原因(如余额不足)"],
|
||||
["幂等重放", "200", "与首次成功/拒绝响应完全一致"],
|
||||
]}
|
||||
/>
|
||||
</DocSection>
|
||||
|
||||
<DocNote>
|
||||
idempotent_key:相同键 + 相同操作须返回首次 JSON(HTTP 200),禁止重复记账;同键不同操作/金额 → success: false。
|
||||
</DocNote>
|
||||
</DocSection>
|
||||
|
||||
<DocSection title="7. 错误码汇总">
|
||||
<DocSection title="SSO 鉴权">
|
||||
<DocTable
|
||||
compact
|
||||
headers={["错误码", "说明"]}
|
||||
rows={[
|
||||
["8001", "缺少 Authorization 头"],
|
||||
["8002", "JWT 无效或已过期"],
|
||||
["8003", "玩家未建档"],
|
||||
["8004", "SSO 密钥未配置"],
|
||||
["8005", "账号已冻结"],
|
||||
]}
|
||||
/>
|
||||
</DocSection>
|
||||
<DocSection title="彩票钱包 / 划转">
|
||||
<DocTable
|
||||
compact
|
||||
headers={["错误码", "说明"]}
|
||||
rows={[
|
||||
["1001", "彩票余额不足(转出时)"],
|
||||
["1009", "主站钱包处理失败"],
|
||||
["1010", "幂等键冲突(同键不同金额)"],
|
||||
["2003", "请先转入后再下注"],
|
||||
]}
|
||||
/>
|
||||
</DocSection>
|
||||
<DocSection title="客户钱包网关 HTTP">
|
||||
<DocTable
|
||||
compact
|
||||
headers={["HTTP", "message", "原因"]}
|
||||
rows={[
|
||||
["401", "unauthorized", "API Key 错误"],
|
||||
["422", "invalid request", "字段或金额非法"],
|
||||
["409", "—", "业务拒绝(如余额不足)"],
|
||||
]}
|
||||
/>
|
||||
</DocSection>
|
||||
</DocSection>
|
||||
|
||||
<DocSection title="8. 上线清单">
|
||||
<DocList
|
||||
items={[
|
||||
"测试与生产:site_code、密钥、域名完全隔离",
|
||||
"JWT 仅服务端签发,有效期 ≤ 5 分钟",
|
||||
"钱包接口走 HTTPS,超时建议 ≤ 10 秒",
|
||||
"idempotent_key 幂等处理已正确实现",
|
||||
"iframe 模式:已配置 iframe_allowed_origins",
|
||||
"全链路联调通过:转入 → 下注 → 派奖 → 转出",
|
||||
]}
|
||||
/>
|
||||
</DocSection>
|
||||
</DocPage>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,13 @@
|
||||
/** 文档默认环境地址(当前 Tanumo 部署;客户独立环境以商务交付为准) */
|
||||
export const DOC_ENV = {
|
||||
lotteryH5Origin: "https://front.tanumo.com",
|
||||
lotteryH5Wallet: "https://front.tanumo.com/wallet",
|
||||
lotteryApiBase: "https://lotterylaravel.tanumo.com",
|
||||
adminBase: "https://lotteryadmin.tanumo.com",
|
||||
integrationDocs: "https://lotteryadmin.tanumo.com/docs/integration",
|
||||
integrationSitesAdmin: "https://lotteryadmin.tanumo.com/admin/config/integration-sites",
|
||||
} as const;
|
||||
|
||||
/** 代码示例(语言无关,三语共用) */
|
||||
export const SSO_JWT_PAYLOAD_EXAMPLE = `{
|
||||
"site_code": "demo",
|
||||
@@ -6,24 +16,28 @@ export const SSO_JWT_PAYLOAD_EXAMPLE = `{
|
||||
"exp": 1718000300
|
||||
}`;
|
||||
|
||||
export const SSO_JWT_SIGN_EXAMPLE = `const header = base64url(JSON.stringify({ alg: "HS256", typ: "JWT" }));
|
||||
const payload = base64url(JSON.stringify({
|
||||
site_code: "demo",
|
||||
site_player_id: "100001",
|
||||
iat: Math.floor(Date.now() / 1000),
|
||||
exp: Math.floor(Date.now() / 1000) + 300,
|
||||
}));
|
||||
const sig = hmacSha256Base64url(\`\${header}.\${payload}\`, SSO_JWT_SECRET);
|
||||
const token = \`\${header}.\${payload}.\${sig}\`;`;
|
||||
export const SSO_JWT_SIGN_EXAMPLE = `import jwt from "jsonwebtoken";
|
||||
|
||||
export const SSO_ENTRY_URL = `https://{lottery_host}/?token={JWT}`;
|
||||
const token = jwt.sign(
|
||||
{
|
||||
site_code: "demo",
|
||||
site_player_id: "100001",
|
||||
},
|
||||
process.env.MAIN_SITE_SSO_JWT_SECRET!,
|
||||
{
|
||||
algorithm: "HS256",
|
||||
expiresIn: 300,
|
||||
},
|
||||
);`;
|
||||
|
||||
export const SSO_ENTRY_URL = `${DOC_ENV.lotteryH5Origin}/?token={JWT}`;
|
||||
|
||||
export const SSO_POSTMESSAGE = `iframe.contentWindow.postMessage(
|
||||
{ type: "MAIN_INIT_TOKEN", token: jwt, source: "main-site" },
|
||||
"https://lottery.example.com"
|
||||
"${DOC_ENV.lotteryH5Origin}"
|
||||
);`;
|
||||
|
||||
export const PLAYER_ME_REQUEST = `GET /api/v1/player/me
|
||||
export const PLAYER_ME_REQUEST = `GET ${DOC_ENV.lotteryApiBase}/api/v1/player/me
|
||||
Authorization: Bearer {JWT}
|
||||
Accept-Language: zh`;
|
||||
|
||||
@@ -48,7 +62,7 @@ export const PLAYER_ME_SUCCESS = `{
|
||||
|
||||
export const IFRAME_CHILD_READY = `{
|
||||
"type": "LOTTERY_READY",
|
||||
"payload": { "url": "https://lottery.example.com/", "userAgent": "..." },
|
||||
"payload": { "url": "${DOC_ENV.lotteryH5Origin}/", "userAgent": "..." },
|
||||
"timestamp": 1718000000000,
|
||||
"source": "lottery-iframe"
|
||||
}`;
|
||||
@@ -60,7 +74,33 @@ export const IFRAME_PARENT_INIT = `{
|
||||
"source": "main-site"
|
||||
}`;
|
||||
|
||||
export const ACCEPTANCE_PLAYER_ME = `curl -sS "https://{lottery_api}/api/v1/player/me" \\
|
||||
export const IFRAME_INTEGRATION_EXAMPLE = `<iframe id="lotteryFrame" src="${DOC_ENV.lotteryH5Origin}/"></iframe>
|
||||
<script>
|
||||
const LOTTERY_ORIGIN = "${DOC_ENV.lotteryH5Origin}";
|
||||
|
||||
window.addEventListener("message", (event) => {
|
||||
if (event.origin !== LOTTERY_ORIGIN) return;
|
||||
const { type } = event.data ?? {};
|
||||
if (type === "LOTTERY_READY" || type === "LOTTERY_TOKEN_NEEDED" || type === "LOTTERY_TOKEN_REFRESH_REQUEST") {
|
||||
issueLotteryJwt().then((token) => sendToken(type === "LOTTERY_READY" ? "MAIN_INIT_TOKEN" : "MAIN_REFRESH_TOKEN", token));
|
||||
}
|
||||
});
|
||||
|
||||
function sendToken(type, token) {
|
||||
document.getElementById("lotteryFrame").contentWindow.postMessage(
|
||||
{ type, token, timestamp: Date.now(), source: "main-site" },
|
||||
LOTTERY_ORIGIN,
|
||||
);
|
||||
}
|
||||
|
||||
async function issueLotteryJwt() {
|
||||
const res = await fetch("/api/your-server/sign-lottery-jwt", { method: "POST", credentials: "include" });
|
||||
const { token } = await res.json();
|
||||
return token;
|
||||
}
|
||||
</script>`;
|
||||
|
||||
export const ACCEPTANCE_PLAYER_ME = `curl -sS "${DOC_ENV.lotteryApiBase}/api/v1/player/me" \\
|
||||
-H "Authorization: Bearer {JWT}" \\
|
||||
-H "Accept: application/json"`;
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
DocList,
|
||||
DocNote,
|
||||
DocOrderedList,
|
||||
DocPage,
|
||||
DocPageHeader,
|
||||
DocSection,
|
||||
DocTable,
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
ACCEPTANCE_PLAYER_ME,
|
||||
ACCEPTANCE_WALLET_DEBIT,
|
||||
IFRAME_CHILD_READY,
|
||||
IFRAME_INTEGRATION_EXAMPLE,
|
||||
IFRAME_PARENT_INIT,
|
||||
PLAYER_AUTH_ERROR,
|
||||
PLAYER_ME_REQUEST,
|
||||
@@ -33,10 +35,6 @@ import {
|
||||
} from "@/modules/docs/integration/integration-doc-data";
|
||||
import { useIntegrationDoc } from "@/modules/docs/integration/use-integration-doc";
|
||||
|
||||
function DocPage({ children }: { children: React.ReactNode }): React.ReactElement {
|
||||
return <div className="space-y-8">{children}</div>;
|
||||
}
|
||||
|
||||
export function OverviewDocScreen(): React.ReactElement {
|
||||
const { p, rows, list, header } = useIntegrationDoc("overview");
|
||||
|
||||
@@ -62,8 +60,34 @@ export function OverviewDocScreen(): React.ReactElement {
|
||||
);
|
||||
}
|
||||
|
||||
export function DeliveryDocScreen(): React.ReactElement {
|
||||
const { p, rows, list, header } = useIntegrationDoc("delivery");
|
||||
|
||||
return (
|
||||
<DocPage>
|
||||
<DocPageHeader title={p("title")} description={p("description")} />
|
||||
<DocSection title={p("handoffScope")}>
|
||||
<DocTable compact headers={header("handoffTable")} rows={rows("handoffRows")} />
|
||||
</DocSection>
|
||||
<DocSection title={p("weProvide")}>
|
||||
<DocTable compact headers={header("param")} rows={rows("provideRows")} />
|
||||
</DocSection>
|
||||
<DocSection title={p("youProvide")}>
|
||||
<DocTable compact headers={header("param")} rows={rows("submitRows")} />
|
||||
</DocSection>
|
||||
<DocSection title={p("environment")}>
|
||||
<DocTable compact headers={header("env")} rows={rows("environmentRows")} />
|
||||
</DocSection>
|
||||
<DocSection title={p("process")}>
|
||||
<DocOrderedList items={list("processSteps")} />
|
||||
</DocSection>
|
||||
<DocNote>{p("note")}</DocNote>
|
||||
</DocPage>
|
||||
);
|
||||
}
|
||||
|
||||
export function QuickstartDocScreen(): React.ReactElement {
|
||||
const { p, rows, list, header } = useIntegrationDoc("quickstart");
|
||||
const { p, list } = useIntegrationDoc("quickstart");
|
||||
|
||||
return (
|
||||
<DocPage>
|
||||
@@ -74,12 +98,6 @@ export function QuickstartDocScreen(): React.ReactElement {
|
||||
<DocSection title={p("steps")}>
|
||||
<DocOrderedList items={list("stepItems")} />
|
||||
</DocSection>
|
||||
<DocSection title={p("testAccounts")}>
|
||||
<DocTable compact headers={header("account")} rows={rows("accountRows")} />
|
||||
</DocSection>
|
||||
<DocSection title={p("reference")}>
|
||||
<DocList items={list("referenceItems")} />
|
||||
</DocSection>
|
||||
<DocSection title={p("acceptance")}>
|
||||
<DocOrderedList items={list("acceptanceItems")} />
|
||||
<DocCode language="bash">{ACCEPTANCE_PLAYER_ME}</DocCode>
|
||||
@@ -150,6 +168,7 @@ export function SsoDocScreen(): React.ReactElement {
|
||||
<DocSection title={p("sign")}>
|
||||
<DocCode language="typescript">{SSO_JWT_SIGN_EXAMPLE}</DocCode>
|
||||
</DocSection>
|
||||
<DocNote>{p("noExchangeNote")}</DocNote>
|
||||
<DocSection title={p("entryA")}>
|
||||
<DocCode>{SSO_ENTRY_URL}</DocCode>
|
||||
</DocSection>
|
||||
@@ -158,7 +177,6 @@ export function SsoDocScreen(): React.ReactElement {
|
||||
<DocCode language="typescript">{SSO_POSTMESSAGE}</DocCode>
|
||||
<DocNote>{p("iframeNote")}</DocNote>
|
||||
</DocSection>
|
||||
<DocNote>{p("noExchangeNote")}</DocNote>
|
||||
<DocSection title={p("entryApi")}>
|
||||
<DocEndpoint method="GET" path="/api/v1/player/me" />
|
||||
<DocNote>{p("entryApiNote")}</DocNote>
|
||||
@@ -169,10 +187,7 @@ export function SsoDocScreen(): React.ReactElement {
|
||||
<DocTable compact headers={header("methodPath")} rows={rows("publicApiRows")} />
|
||||
</DocSection>
|
||||
<DocNote>{p("h5ScopeNote")}</DocNote>
|
||||
<DocSection title={p("partnerApis")}>
|
||||
<DocTable compact headers={header("methodPath")} rows={rows("partnerApiRows")} />
|
||||
<DocNote>{p("refreshNote")}</DocNote>
|
||||
</DocSection>
|
||||
<DocNote>{p("refreshNote")}</DocNote>
|
||||
<DocSection title={p("authResponse")}>
|
||||
<DocCode language="http">{PLAYER_AUTH_ERROR}</DocCode>
|
||||
</DocSection>
|
||||
@@ -192,7 +207,8 @@ export function IframeDocScreen(): React.ReactElement {
|
||||
<DocSection title={p("sequence")}>
|
||||
<DocOrderedList items={list("sequenceSteps")} />
|
||||
</DocSection>
|
||||
<DocSection title={p("envelope")}>
|
||||
<DocSection title={p("envelopeSection")}>
|
||||
<DocTable compact headers={header("envelopeTable")} rows={rows("envelopeRows")} />
|
||||
<DocNote>{p("envelopeNote")}</DocNote>
|
||||
</DocSection>
|
||||
<DocSection title={p("childMessages")}>
|
||||
@@ -203,6 +219,9 @@ export function IframeDocScreen(): React.ReactElement {
|
||||
<DocTable compact headers={header("message")} rows={rows("parentMessageRows")} />
|
||||
<DocCode>{IFRAME_PARENT_INIT}</DocCode>
|
||||
</DocSection>
|
||||
<DocSection title={p("example")}>
|
||||
<DocCode language="html">{IFRAME_INTEGRATION_EXAMPLE}</DocCode>
|
||||
</DocSection>
|
||||
<DocSection title={p("targetOrigin")}>
|
||||
<DocNote>{p("targetOriginNote")}</DocNote>
|
||||
</DocSection>
|
||||
@@ -305,10 +324,36 @@ export function GoLiveDocScreen(): React.ReactElement {
|
||||
|
||||
return (
|
||||
<DocPage>
|
||||
<DocPageHeader title={p("title")} />
|
||||
<DocPageHeader title={p("title")} description={p("description")} />
|
||||
<DocSection title={p("deliveryChecklist")}>
|
||||
<DocList items={list("deliveryItems")} />
|
||||
</DocSection>
|
||||
<DocSection title={p("checklist")}>
|
||||
<DocList items={list("items")} />
|
||||
</DocSection>
|
||||
</DocPage>
|
||||
);
|
||||
}
|
||||
|
||||
export function TroubleshootingDocScreen(): React.ReactElement {
|
||||
const { p, rows, header } = useIntegrationDoc("troubleshooting");
|
||||
|
||||
return (
|
||||
<DocPage>
|
||||
<DocPageHeader title={p("title")} description={p("description")} />
|
||||
<DocSection title={p("faq")}>
|
||||
<DocTable compact headers={header("faq")} rows={rows("faqRows")} />
|
||||
</DocSection>
|
||||
<DocSection title={p("jwt")}>
|
||||
<DocTable compact headers={header("faq")} rows={rows("jwtRows")} />
|
||||
</DocSection>
|
||||
<DocSection title={p("iframe")}>
|
||||
<DocTable compact headers={header("faq")} rows={rows("iframeRows")} />
|
||||
</DocSection>
|
||||
<DocSection title={p("wallet")}>
|
||||
<DocTable compact headers={header("faq")} rows={rows("walletRows")} />
|
||||
</DocSection>
|
||||
<DocNote>{p("note")}</DocNote>
|
||||
</DocPage>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next";
|
||||
|
||||
type DocPageKey =
|
||||
| "overview"
|
||||
| "delivery"
|
||||
| "quickstart"
|
||||
| "fundamentals"
|
||||
| "setup"
|
||||
@@ -12,16 +13,32 @@ type DocPageKey =
|
||||
| "wallet"
|
||||
| "transfer"
|
||||
| "errors"
|
||||
| "troubleshooting"
|
||||
| "golive";
|
||||
|
||||
const INTEGRATION_DOCS_NS = "integrationDocs";
|
||||
|
||||
function asStringArray(value: unknown): string[] {
|
||||
return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
|
||||
}
|
||||
|
||||
function asStringMatrix(value: unknown): string[][] {
|
||||
return Array.isArray(value)
|
||||
? value.filter((row): row is string[] => Array.isArray(row) && row.every((cell) => typeof cell === "string"))
|
||||
: [];
|
||||
}
|
||||
|
||||
export function useIntegrationDoc(page: DocPageKey) {
|
||||
const { t } = useTranslation("integrationDocs");
|
||||
const { t } = useTranslation(INTEGRATION_DOCS_NS);
|
||||
|
||||
return {
|
||||
t,
|
||||
p: (key: string) => t(`pages.${page}.${key}`),
|
||||
rows: (key: string) => t(`pages.${page}.${key}`, { returnObjects: true }) as string[][],
|
||||
list: (key: string) => t(`pages.${page}.${key}`, { returnObjects: true }) as string[],
|
||||
header: (key: string) => t(`headers.${key}`, { returnObjects: true }) as string[],
|
||||
p: (key: string) => t(`pages.${page}.${key}`, { ns: INTEGRATION_DOCS_NS }),
|
||||
rows: (key: string) =>
|
||||
asStringMatrix(t(`pages.${page}.${key}`, { returnObjects: true, ns: INTEGRATION_DOCS_NS })),
|
||||
list: (key: string) =>
|
||||
asStringArray(t(`pages.${page}.${key}`, { returnObjects: true, ns: INTEGRATION_DOCS_NS })),
|
||||
header: (key: string) =>
|
||||
asStringArray(t(`headers.${key}`, { returnObjects: true, ns: INTEGRATION_DOCS_NS })),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@ import {
|
||||
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
||||
import { LOTTERY_SCHEDULE_TIMEZONE } from "@/lib/lottery-schedule-timezone";
|
||||
import { AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||
import { AdminPageGuide } from "@/components/admin/admin-page-guide";
|
||||
import { ADMIN_DOC_LINKS } from "@/lib/admin-doc-links";
|
||||
import { AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
|
||||
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -249,6 +251,7 @@ export function DrawsIndexConsole() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<AdminPageGuide guide={t("pageGuide")} docHref={ADMIN_DOC_LINKS.draws} className="mb-4" />
|
||||
<Card className="admin-list-card">
|
||||
<CardHeader className="admin-list-header flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<CardTitle className="admin-list-title">{t("statusListTitle")}</CardTitle>
|
||||
|
||||
@@ -19,6 +19,8 @@ import {
|
||||
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";
|
||||
@@ -527,6 +529,11 @@ export function IntegrationSitesConsole({
|
||||
|
||||
return (
|
||||
<>
|
||||
<AdminPageGuide
|
||||
guide={t("integrationSites.pageGuide")}
|
||||
docHref={ADMIN_DOC_LINKS.siteSetup}
|
||||
className="mb-4"
|
||||
/>
|
||||
<AdminPageCard
|
||||
title={t("integrationSites.title")}
|
||||
description={t("integrationSites.description")}
|
||||
|
||||
@@ -23,6 +23,8 @@ import {
|
||||
import { flattenAgentTree, type FlatAgentOption } from "@/lib/admin-agent-tree";
|
||||
import { useAdminSiteCodeOptions } from "@/hooks/use-admin-site-code-options";
|
||||
import { AdminAgentCell, AdminAgentHead } from "@/components/admin/admin-agent-columns";
|
||||
import { AdminPageGuide } from "@/components/admin/admin-page-guide";
|
||||
import { ADMIN_DOC_LINKS } from "@/lib/admin-doc-links";
|
||||
import { AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
|
||||
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
||||
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
|
||||
@@ -425,6 +427,7 @@ export function PlayersConsole(): React.ReactElement {
|
||||
|
||||
return (
|
||||
<div className="flex w-full max-w-none flex-col gap-6">
|
||||
<AdminPageGuide guide={t("pageGuide")} docHref={ADMIN_DOC_LINKS.players} />
|
||||
<Card className="admin-list-card">
|
||||
<CardHeader className="admin-list-header flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
|
||||
|
||||
@@ -112,7 +112,7 @@ export function ReportJobsPanel({ canExport, refreshToken = 0, reportType }: Rep
|
||||
<TableHead>{t("tasks.columns.format")}</TableHead>
|
||||
<TableHead>{t("tasks.columns.status")}</TableHead>
|
||||
<TableHead>{t("tasks.columns.createdAt")}</TableHead>
|
||||
<TableHead className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">{t("tasks.columns.actions")}</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("tasks.columns.actions")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
|
||||
@@ -46,6 +46,8 @@ import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||
import { PRD_REPORT_EXPORT, PRD_REPORT_VIEW } from "@/lib/admin-prd";
|
||||
import { useAdminProfile } from "@/stores/admin-session";
|
||||
import { adminAgentDisplayLabel } from "@/components/admin/admin-agent-columns";
|
||||
import { AdminPageGuide } from "@/components/admin/admin-page-guide";
|
||||
import { ADMIN_DOC_LINKS } from "@/lib/admin-doc-links";
|
||||
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
|
||||
import { AdminDateRangeField } from "@/components/admin/admin-date-range-field";
|
||||
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
||||
@@ -1620,6 +1622,7 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-7xl flex-col gap-4">
|
||||
<AdminPageGuide guide={t("pageGuide")} docHref={ADMIN_DOC_LINKS.reports} />
|
||||
<Card className="admin-list-card">
|
||||
<CardHeader className="admin-list-header pb-3">
|
||||
<div className="flex flex-col gap-3">
|
||||
|
||||
@@ -184,7 +184,7 @@ export function RiskIndexConsole() {
|
||||
<TableHead>{t("drawNo")}</TableHead>
|
||||
<TableHead>{t("status")}</TableHead>
|
||||
<TableHead>{t("closeTime")}</TableHead>
|
||||
<TableHead className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">{t("table.actions", { ns: "common" })}</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("table.actions", { ns: "common" })}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useExportLabels } from "@/hooks/use-export-labels";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -10,10 +11,10 @@ import { getAdminRiskPoolLockLogs } from "@/api/admin-risk";
|
||||
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
||||
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||
import { AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -35,20 +36,29 @@ import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter"
|
||||
import { riskActionTypeLabel, riskSourceReasonLabel } from "@/modules/risk/risk-display";
|
||||
import { formatAdminMinorUnits } from "@/lib/money";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
import type { AdminRiskLockLogListData, AdminRiskLockLogRow } from "@/types/api/admin-risk";
|
||||
import type {
|
||||
AdminRiskLockLogListData,
|
||||
AdminRiskLockLogRow,
|
||||
AdminRiskLockLogTicketRow,
|
||||
} from "@/types/api/admin-risk";
|
||||
|
||||
const ACTION_ALL = "__all__";
|
||||
const GROUP_TICKET = "ticket";
|
||||
const GROUP_ENTRY = "entry";
|
||||
|
||||
function riskActionFilterLabel(
|
||||
value: string,
|
||||
t: (key: string) => string,
|
||||
): string {
|
||||
function riskActionFilterLabel(value: string, t: (key: string) => string): string {
|
||||
if (value === ACTION_ALL) {
|
||||
return t("noLimit");
|
||||
}
|
||||
return riskActionTypeLabel(value, t);
|
||||
}
|
||||
|
||||
function isTicketGroupData(
|
||||
data: AdminRiskLockLogListData | null,
|
||||
): data is AdminRiskLockLogListData & { group_by: "ticket"; items: AdminRiskLockLogTicketRow[] } {
|
||||
return data?.group_by === "ticket";
|
||||
}
|
||||
|
||||
export function RiskLockLogsConsole({ drawId }: { drawId: number }) {
|
||||
const { t } = useTranslation(["risk", "common"]);
|
||||
const tRef = useTranslationRef(["risk", "common"]);
|
||||
@@ -66,6 +76,8 @@ export function RiskLockLogsConsole({ drawId }: { drawId: number }) {
|
||||
const [appliedNumber, setAppliedNumber] = useState("");
|
||||
const [draftAction, setDraftAction] = useState<string>(ACTION_ALL);
|
||||
const [appliedAction, setAppliedAction] = useState<string>(ACTION_ALL);
|
||||
const [draftGroupBy, setDraftGroupBy] = useState<"ticket" | "entry">(GROUP_TICKET);
|
||||
const [appliedGroupBy, setAppliedGroupBy] = useState<"ticket" | "entry">(GROUP_TICKET);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -74,34 +86,58 @@ export function RiskLockLogsConsole({ drawId }: { drawId: number }) {
|
||||
const d = await getAdminRiskPoolLockLogs(drawId, {
|
||||
page,
|
||||
per_page: perPage,
|
||||
group_by: appliedGroupBy,
|
||||
normalized_number: appliedNumber.trim() === "" ? undefined : appliedNumber.trim(),
|
||||
action_type:
|
||||
appliedAction === ACTION_ALL
|
||||
? undefined
|
||||
: (appliedAction as "lock" | "release"),
|
||||
appliedAction === ACTION_ALL ? undefined : (appliedAction as "lock" | "release"),
|
||||
});
|
||||
setData(d);
|
||||
} catch (e) {
|
||||
const msg =
|
||||
e instanceof LotteryApiBizError ? e.message : tRef.current("loadLogsFailed");
|
||||
const msg = e instanceof LotteryApiBizError ? e.message : tRef.current("loadLogsFailed");
|
||||
setError(msg);
|
||||
setData(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [drawId, page, perPage, appliedAction, appliedNumber]);
|
||||
}, [drawId, page, perPage, appliedAction, appliedNumber, appliedGroupBy, tRef]);
|
||||
|
||||
useAsyncEffect(() => {
|
||||
void load();
|
||||
}, [drawId, page, perPage, appliedAction, appliedNumber]);
|
||||
}, [drawId, page, perPage, appliedAction, appliedNumber, appliedGroupBy]);
|
||||
|
||||
const ticketGrouped = isTicketGroupData(data);
|
||||
const currencyCode = data?.currency_code ?? "NPR";
|
||||
|
||||
return (
|
||||
<Card className="admin-list-card">
|
||||
<CardHeader className="admin-list-header">
|
||||
<CardHeader className="admin-list-header space-y-1">
|
||||
<CardTitle className="admin-list-title">{t("lockLogsTitle")}</CardTitle>
|
||||
<CardDescription className="text-xs">{t("lockLogsGroupedHint")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="admin-list-content">
|
||||
<div className="admin-list-toolbar">
|
||||
<div className="admin-list-field">
|
||||
<Label htmlFor="risk-log-group" className="sm:w-20 sm:shrink-0">
|
||||
{t("groupBy")}
|
||||
</Label>
|
||||
<Select
|
||||
modal={false}
|
||||
value={draftGroupBy}
|
||||
onValueChange={(v) => {
|
||||
if (v === GROUP_TICKET || v === GROUP_ENTRY) setDraftGroupBy(v);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="risk-log-group" size="sm" className="h-8 w-full sm:w-44">
|
||||
<SelectValue>
|
||||
{draftGroupBy === GROUP_TICKET ? t("groupByTicket") : t("groupByEntry")}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={GROUP_TICKET}>{t("groupByTicket")}</SelectItem>
|
||||
<SelectItem value={GROUP_ENTRY}>{t("groupByEntry")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="admin-list-field">
|
||||
<Label htmlFor="risk-log-number" className="sm:w-20 sm:shrink-0">
|
||||
{t("number4d")}
|
||||
@@ -149,6 +185,7 @@ export function RiskLockLogsConsole({ drawId }: { drawId: number }) {
|
||||
onClick={() => {
|
||||
setAppliedNumber(draftNumber);
|
||||
setAppliedAction(draftAction);
|
||||
setAppliedGroupBy(draftGroupBy);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
@@ -159,23 +196,71 @@ export function RiskLockLogsConsole({ drawId }: { drawId: number }) {
|
||||
|
||||
{error ? <p className="text-sm text-destructive">{error}</p> : null}
|
||||
|
||||
<>
|
||||
<div className="admin-table-shell">
|
||||
<Table id={`risk-lock-logs-table-${drawId}`}>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("time")}</TableHead>
|
||||
<TableHead>{t("searchNumber")}</TableHead>
|
||||
<TableHead>{t("action")}</TableHead>
|
||||
<TableHead className="text-center">{t("amount")}</TableHead>
|
||||
<TableHead>{t("source")}</TableHead>
|
||||
<TableHead>{t("ticketNo")}</TableHead>
|
||||
<TableHead>{t("playCode")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading && !data ? <AdminTableLoadingRow colSpan={7} /> : null}
|
||||
{(data?.items ?? []).map((row: AdminRiskLockLogRow) => (
|
||||
<div className="admin-table-shell">
|
||||
<Table id={`risk-lock-logs-table-${drawId}`}>
|
||||
<TableHeader>
|
||||
{ticketGrouped ? (
|
||||
<TableRow>
|
||||
<TableHead>{t("time")}</TableHead>
|
||||
<TableHead>{t("ticketNo")}</TableHead>
|
||||
<TableHead>{t("playCode")}</TableHead>
|
||||
<TableHead>{t("number")}</TableHead>
|
||||
<TableHead className="text-center">{t("combinationCount")}</TableHead>
|
||||
<TableHead className="text-center">{t("lockReleaseSummary")}</TableHead>
|
||||
<TableHead className="text-center">{t("amount")}</TableHead>
|
||||
<TableHead className="text-center">{t("viewDetail")}</TableHead>
|
||||
</TableRow>
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableHead>{t("time")}</TableHead>
|
||||
<TableHead>{t("searchNumber")}</TableHead>
|
||||
<TableHead>{t("action")}</TableHead>
|
||||
<TableHead className="text-center">{t("amount")}</TableHead>
|
||||
<TableHead>{t("source")}</TableHead>
|
||||
<TableHead>{t("ticketNo")}</TableHead>
|
||||
<TableHead>{t("playCode")}</TableHead>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading && !data ? (
|
||||
<AdminTableLoadingRow colSpan={ticketGrouped ? 8 : 7} />
|
||||
) : null}
|
||||
{ticketGrouped
|
||||
? data.items.map((row: AdminRiskLockLogTicketRow) => (
|
||||
<TableRow key={row.ticket_item_id}>
|
||||
<TableCell className="whitespace-nowrap text-sm text-muted-foreground">
|
||||
{row.last_at ? formatDt(row.last_at) : "—"}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-sm">{row.ticket_no}</TableCell>
|
||||
<TableCell className="text-sm">{playCodeLabel(row.play_code)}</TableCell>
|
||||
<TableCell className="font-mono text-sm">{row.original_number}</TableCell>
|
||||
<TableCell className="text-center text-sm tabular-nums">
|
||||
{row.combination_count}
|
||||
{row.number_count !== row.combination_count ? (
|
||||
<span className="text-muted-foreground"> / {row.number_count}</span>
|
||||
) : null}
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-xs tabular-nums">
|
||||
{t("lock")} {row.lock_entry_count} · {t("release")} {row.release_entry_count}
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-xs tabular-nums">
|
||||
<div>{formatAdminMinorUnits(row.total_lock_amount, currencyCode)}</div>
|
||||
<div className="text-muted-foreground">
|
||||
{formatAdminMinorUnits(row.total_release_amount, currencyCode)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Link
|
||||
href={`/admin/tickets/${encodeURIComponent(row.ticket_no)}`}
|
||||
className="text-xs font-medium text-primary hover:underline"
|
||||
>
|
||||
{t("viewTicket")}
|
||||
</Link>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
: (data?.items as AdminRiskLockLogRow[] | undefined)?.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
<TableCell className="whitespace-nowrap text-sm text-muted-foreground">
|
||||
{row.created_at ? formatDt(row.created_at) : "—"}
|
||||
@@ -187,35 +272,45 @@ export function RiskLockLogsConsole({ drawId }: { drawId: number }) {
|
||||
{riskActionTypeLabel(row.action_type, t)}
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-sm font-semibold">
|
||||
{formatAdminMinorUnits(row.amount, data?.currency_code ?? "NPR")}
|
||||
{formatAdminMinorUnits(row.amount, currencyCode)}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{riskSourceReasonLabel(row.source_reason, t)}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-sm">{row.ticket_no ?? "—"}</TableCell>
|
||||
<TableCell className="font-mono text-sm">
|
||||
{row.ticket_no ? (
|
||||
<Link
|
||||
href={`/admin/tickets/${encodeURIComponent(row.ticket_no)}`}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
{row.ticket_no}
|
||||
</Link>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">{playCodeLabel(row.play_code)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{data ? (
|
||||
<AdminListPaginationFooter
|
||||
selectId={`risk-logs-${drawId}`}
|
||||
total={data.meta.total}
|
||||
page={data.meta.current_page}
|
||||
lastPage={data.meta.last_page}
|
||||
perPage={data.meta.per_page}
|
||||
loading={loading}
|
||||
onPerPageChange={(n) => {
|
||||
setPerPage(n);
|
||||
setPage(1);
|
||||
}}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
{data ? (
|
||||
<AdminListPaginationFooter
|
||||
selectId={`risk-logs-${drawId}-${appliedGroupBy}`}
|
||||
total={data.meta.total}
|
||||
page={data.meta.current_page}
|
||||
lastPage={data.meta.last_page}
|
||||
perPage={data.meta.per_page}
|
||||
loading={loading}
|
||||
onPerPageChange={(n) => {
|
||||
setPerPage(n);
|
||||
setPage(1);
|
||||
}}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -63,7 +63,7 @@ function riskSortLabel(
|
||||
return option ? t(option.label) : value;
|
||||
}
|
||||
|
||||
export type RiskPoolListFilter = "all" | "sold_out" | "high_risk";
|
||||
export type RiskPoolListFilter = "all" | "active" | "sold_out" | "high_risk";
|
||||
|
||||
type RiskPoolsConsoleProps = {
|
||||
drawId: number;
|
||||
@@ -87,7 +87,7 @@ function resolveInitialFilter(
|
||||
if (soldOutOnly) {
|
||||
return "sold_out";
|
||||
}
|
||||
return "all";
|
||||
return "active";
|
||||
}
|
||||
|
||||
export function RiskPoolsConsole({
|
||||
@@ -96,7 +96,7 @@ export function RiskPoolsConsole({
|
||||
titleKey,
|
||||
soldOutOnly,
|
||||
initialFilter: initialFilterProp,
|
||||
defaultSort = "number_asc",
|
||||
defaultSort = "usage_desc",
|
||||
allowSortChange = true,
|
||||
}: RiskPoolsConsoleProps) {
|
||||
const { t } = useTranslation(["risk", "common"]);
|
||||
@@ -130,6 +130,7 @@ export function RiskPoolsConsole({
|
||||
per_page: perPage,
|
||||
sold_out_only: filter === "sold_out",
|
||||
high_risk_only: filter === "high_risk",
|
||||
active_only: filter === "active" && number.trim() === "",
|
||||
normalized_number: number.trim(),
|
||||
sort,
|
||||
});
|
||||
@@ -216,12 +217,15 @@ export function RiskPoolsConsole({
|
||||
<SelectValue>
|
||||
{filter === "all"
|
||||
? t("filterAll")
|
||||
: filter === "sold_out"
|
||||
? t("filterSoldOut")
|
||||
: t("filterHighRisk")}
|
||||
: filter === "active"
|
||||
? t("filterActive")
|
||||
: filter === "sold_out"
|
||||
? t("filterSoldOut")
|
||||
: t("filterHighRisk")}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="active">{t("filterActive")}</SelectItem>
|
||||
<SelectItem value="all">{t("filterAll")}</SelectItem>
|
||||
<SelectItem value="sold_out">{t("filterSoldOut")}</SelectItem>
|
||||
<SelectItem value="high_risk">{t("filterHighRisk")}</SelectItem>
|
||||
|
||||
@@ -10,6 +10,8 @@ import { toast } from "sonner";
|
||||
import { getSettlementPeriods, type SettlementPeriodRow } from "@/api/admin-agent-settlement";
|
||||
import { getAdminIntegrationSites } from "@/api/admin-integration-sites";
|
||||
import { AdminLoadingState } from "@/components/admin/admin-loading-state";
|
||||
import { AdminPageGuide } from "@/components/admin/admin-page-guide";
|
||||
import { ADMIN_DOC_LINKS } from "@/lib/admin-doc-links";
|
||||
import { AdminNoIntegrationSiteState } from "@/components/admin/admin-no-integration-site-state";
|
||||
import { AgentBillDetail } from "@/modules/settlement/agent-bill-detail";
|
||||
import { SettlementCenterPeriodDetail } from "@/modules/settlement/settlement-center-period-detail";
|
||||
@@ -58,6 +60,7 @@ export function SettlementCenterShell(): React.ReactElement {
|
||||
const canFinanceAdjustments = canOperateBills && boundAgent === null;
|
||||
|
||||
const [siteOptions, setSiteOptions] = useState<SiteOption[]>([]);
|
||||
const [sitesReady, setSitesReady] = useState(() => boundAgent?.admin_site_id != null);
|
||||
const [adminSiteId, setAdminSiteId] = useState<number | null>(null);
|
||||
const [sitePickerOpen, setSitePickerOpen] = useState(false);
|
||||
const [siteKeyword, setSiteKeyword] = useState("");
|
||||
@@ -80,27 +83,36 @@ export function SettlementCenterShell(): React.ReactElement {
|
||||
currency_code: "NPR",
|
||||
}]);
|
||||
setAdminSiteId(boundAgent.admin_site_id);
|
||||
setSitesReady(true);
|
||||
return;
|
||||
}
|
||||
|
||||
void getAdminIntegrationSites().then((sites) => {
|
||||
const options = (sites.items ?? []).map((site) => ({
|
||||
id: site.id,
|
||||
label: formatAdminSiteLabel(site.name, site.code),
|
||||
code: site.code,
|
||||
currency_code: site.currency_code ?? "NPR",
|
||||
}));
|
||||
setSiteOptions(options);
|
||||
setAdminSiteId((current) => {
|
||||
if (siteFromUrl !== null && options.some((site) => site.id === siteFromUrl)) {
|
||||
return siteFromUrl;
|
||||
}
|
||||
if (current !== null && options.some((site) => site.id === current)) {
|
||||
return current;
|
||||
}
|
||||
return options[0]?.id ?? null;
|
||||
setSitesReady(false);
|
||||
void getAdminIntegrationSites()
|
||||
.then((sites) => {
|
||||
const options = (sites.items ?? []).map((site) => ({
|
||||
id: site.id,
|
||||
label: formatAdminSiteLabel(site.name, site.code),
|
||||
code: site.code,
|
||||
currency_code: site.currency_code ?? "NPR",
|
||||
}));
|
||||
setSiteOptions(options);
|
||||
setAdminSiteId((current) => {
|
||||
if (siteFromUrl !== null && options.some((site) => site.id === siteFromUrl)) {
|
||||
return siteFromUrl;
|
||||
}
|
||||
if (current !== null && options.some((site) => site.id === current)) {
|
||||
return current;
|
||||
}
|
||||
return options[0]?.id ?? null;
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
setSiteOptions([]);
|
||||
})
|
||||
.finally(() => {
|
||||
setSitesReady(true);
|
||||
});
|
||||
});
|
||||
}, [boundAgent, siteFromUrl]);
|
||||
|
||||
const siteId = adminSiteId ?? siteOptions[0]?.id ?? null;
|
||||
@@ -283,14 +295,18 @@ export function SettlementCenterShell(): React.ReactElement {
|
||||
};
|
||||
}, [activePeriod, activePeriodId, activeView, periodsReady, router, siteId]);
|
||||
|
||||
const shellBootstrapping =
|
||||
!sitesReady || (siteId !== null && !periodsReady);
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-7xl flex-col gap-4">
|
||||
{siteId === null && siteOptions.length === 0 && boundAgent === null ? (
|
||||
<AdminPageGuide guide={t("pageGuide")} docHref={ADMIN_DOC_LINKS.settlementCenter} />
|
||||
{shellBootstrapping ? (
|
||||
<AdminLoadingState />
|
||||
) : siteId === null && siteOptions.length === 0 && boundAgent === null ? (
|
||||
<AdminNoIntegrationSiteState canCreate={profile?.is_super_admin === true} />
|
||||
) : siteId === null ? (
|
||||
<p className="text-sm text-muted-foreground">{t("empty.noSite", { defaultValue: "请选择站点。" })}</p>
|
||||
) : !periodsReady ? (
|
||||
<AdminLoadingState />
|
||||
) : isListMode ? (
|
||||
<SettlementPeriodWorkbench
|
||||
adminSiteId={siteId}
|
||||
|
||||
@@ -140,7 +140,7 @@ export function SettlementCreditLedgerPanel({
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [perPage, setPerPage] = useState(20);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
|
||||
@@ -401,18 +401,24 @@ export function PlayerTicketsConsole(): React.ReactElement {
|
||||
<TableCell className="text-xs">{formatTs(row.updated_at)}</TableCell>
|
||||
<TableCell className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
|
||||
<AdminRowActionsMenu
|
||||
actions={
|
||||
row.player_id
|
||||
actions={[
|
||||
{
|
||||
key: "view-ticket",
|
||||
label: t("viewTicketDetail"),
|
||||
icon: Eye,
|
||||
href: `/admin/tickets/${encodeURIComponent(row.ticket_no)}`,
|
||||
},
|
||||
...(row.player_id
|
||||
? [
|
||||
{
|
||||
key: "view-player",
|
||||
label: t("viewPlayer", { ns: "tickets" }),
|
||||
label: t("viewPlayer"),
|
||||
icon: Eye,
|
||||
href: adminPlayerDetailPath(row.player_id),
|
||||
},
|
||||
]
|
||||
: []
|
||||
}
|
||||
: []),
|
||||
]}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
230
src/modules/tickets/ticket-detail-console.tsx
Normal file
230
src/modules/tickets/ticket-detail-console.tsx
Normal file
@@ -0,0 +1,230 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||
|
||||
import { getAdminTicketItem } from "@/api/admin-ticket-detail";
|
||||
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
||||
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
||||
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
|
||||
import { PlayerFundingModeBadge } from "@/components/admin/player-funding-badges";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
||||
import { useAdminPlayCodeLabel } from "@/hooks/use-admin-play-type-catalog";
|
||||
import { useExportLabels } from "@/hooks/use-export-labels";
|
||||
import { adminPlayerDetailPath } from "@/lib/admin-player-paths";
|
||||
import { formatAdminMinorUnits } from "@/lib/money";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
import type { AdminTicketItemDetail } from "@/types/api/admin-tickets";
|
||||
|
||||
function ticketStatusText(value: string, t: (key: string) => string): string {
|
||||
const key = `statusOptions.${value}`;
|
||||
const translated = t(key);
|
||||
return translated === key ? value : translated;
|
||||
}
|
||||
|
||||
export function TicketDetailConsole({ ticketNo }: { ticketNo: string }) {
|
||||
const { t } = useTranslation(["tickets", "common"]);
|
||||
const tRef = useTranslationRef(["tickets", "common"]);
|
||||
const playCodeLabel = useAdminPlayCodeLabel();
|
||||
const formatDt = useAdminDateTimeFormatter();
|
||||
const exportLabels = useExportLabels("ticketCombinations");
|
||||
const [data, setData] = useState<AdminTicketItemDetail | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [comboPage, setComboPage] = useState(1);
|
||||
const [comboPerPage, setComboPerPage] = useState(20);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const d = await getAdminTicketItem(ticketNo);
|
||||
setData(d);
|
||||
} catch (e) {
|
||||
const msg = e instanceof LotteryApiBizError ? e.message : tRef.current("detailLoadFailed");
|
||||
setError(msg);
|
||||
setData(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [ticketNo, tRef]);
|
||||
|
||||
useAsyncEffect(() => {
|
||||
void load();
|
||||
}, [ticketNo]);
|
||||
|
||||
const combinations = data?.combinations ?? [];
|
||||
const comboLastPage = Math.max(1, Math.ceil(combinations.length / comboPerPage));
|
||||
const comboPageSafe = Math.min(comboPage, comboLastPage);
|
||||
const comboSlice = useMemo(() => {
|
||||
const start = (comboPageSafe - 1) * comboPerPage;
|
||||
return combinations.slice(start, start + comboPerPage);
|
||||
}, [combinations, comboPageSafe, comboPerPage]);
|
||||
|
||||
if (error && !data) {
|
||||
return (
|
||||
<Card className="border-destructive/40">
|
||||
<CardHeader>
|
||||
<CardTitle>{t("detailTitle")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
<Link href="/admin/tickets" className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
|
||||
{t("backToList")}
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const currencyCode = data?.currency_code ?? "NPR";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<Card>
|
||||
<CardHeader className="space-y-2">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<CardTitle className="text-lg">{t("detailTitle")}</CardTitle>
|
||||
<Link href="/admin/tickets" className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
|
||||
{t("backToList")}
|
||||
</Link>
|
||||
</div>
|
||||
<CardDescription className="font-mono text-xs">
|
||||
{data?.ticket_no ?? ticketNo}
|
||||
{data?.order_no ? ` · ${data.order_no}` : ""}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-sm">
|
||||
{loading && !data ? (
|
||||
<p className="text-muted-foreground">{t("loadingDetail")}</p>
|
||||
) : data ? (
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<p>
|
||||
<span className="text-muted-foreground">{t("drawNo")}:</span>
|
||||
<span className="font-mono font-medium">{data.draw_no ?? "—"}</span>
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-muted-foreground">{t("playCode")}:</span>
|
||||
<span>{playCodeLabel(data.play_code)}</span>
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-muted-foreground">{t("number")}:</span>
|
||||
<span className="font-mono font-medium">{data.original_number ?? "—"}</span>
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-muted-foreground">{t("combinationCount")}:</span>
|
||||
<span className="font-semibold tabular-nums">{data.combination_count}</span>
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-muted-foreground">{t("betAmount")}:</span>
|
||||
<span className="font-semibold tabular-nums">{data.total_bet_amount_formatted}</span>
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-muted-foreground">{t("actualDeduct")}:</span>
|
||||
<span className="font-semibold tabular-nums">{data.actual_deduct_amount_formatted}</span>
|
||||
</p>
|
||||
<p className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-muted-foreground">{t("status")}:</span>
|
||||
<AdminStatusBadge status={data.status}>{ticketStatusText(data.status, t)}</AdminStatusBadge>
|
||||
</p>
|
||||
<p className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-muted-foreground">{t("player", { ns: "tickets" })}:</span>
|
||||
<PlayerFundingModeBadge row={data} />
|
||||
{data.player_id ? (
|
||||
<Link href={adminPlayerDetailPath(data.player_id)} className="font-mono text-xs text-primary hover:underline">
|
||||
{data.username ?? data.site_player_id ?? data.player_id}
|
||||
</Link>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-muted-foreground">{t("placedAt")}:</span>
|
||||
<span>{data.placed_at ? formatDt(data.placed_at) : "—"}</span>
|
||||
</p>
|
||||
{data.fail_reason_text || data.fail_reason_code ? (
|
||||
<p className="sm:col-span-2 text-destructive">
|
||||
<span className="text-muted-foreground">{t("failReason")}:</span>
|
||||
{data.fail_reason_text ?? data.fail_reason_code}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="admin-list-card">
|
||||
<CardHeader className="admin-list-header">
|
||||
<CardTitle className="admin-list-title">{t("combinationsTitle")}</CardTitle>
|
||||
<CardDescription className="text-xs">{t("combinationsHint")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="admin-list-content">
|
||||
<div className="admin-list-actions mb-3 justify-end">
|
||||
<AdminTableExportButton
|
||||
tableId={`ticket-combinations-${ticketNo}`}
|
||||
filename={exportLabels.filename}
|
||||
sheetName={exportLabels.sheetName}
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-table-shell">
|
||||
<Table id={`ticket-combinations-${ticketNo}`}>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-16 text-center">No.</TableHead>
|
||||
<TableHead>{t("number4d")}</TableHead>
|
||||
<TableHead className="text-center">{t("comboBetAmount")}</TableHead>
|
||||
<TableHead className="text-center">{t("comboEstimatedPayout")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading && !data ? <AdminTableLoadingRow colSpan={4} /> : null}
|
||||
{comboSlice.map((row) => (
|
||||
<TableRow key={`${row.combination_no}-${row.number_4d}`}>
|
||||
<TableCell className="text-center font-mono text-xs">{row.combination_no}</TableCell>
|
||||
<TableCell className="font-mono font-medium">{row.number_4d}</TableCell>
|
||||
<TableCell className="text-center tabular-nums text-sm">
|
||||
{formatAdminMinorUnits(row.bet_amount, currencyCode)}
|
||||
</TableCell>
|
||||
<TableCell className="text-center tabular-nums text-sm">
|
||||
{formatAdminMinorUnits(row.estimated_payout, currencyCode)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
{combinations.length > 0 ? (
|
||||
<AdminListPaginationFooter
|
||||
selectId={`ticket-combo-${ticketNo}`}
|
||||
total={combinations.length}
|
||||
page={comboPageSafe}
|
||||
lastPage={comboLastPage}
|
||||
perPage={comboPerPage}
|
||||
loading={loading}
|
||||
onPerPageChange={(n) => {
|
||||
setComboPerPage(n);
|
||||
setComboPage(1);
|
||||
}}
|
||||
onPageChange={setComboPage}
|
||||
/>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user