diff --git a/AGENTS.md b/AGENTS.md index 6214b9a..240fdcf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,6 +32,8 @@ This version has breaking changes — APIs, conventions, and file structure may ## Learned User Preferences - 占成/授信/回水/上限等数值字段用 `AdminNumericStepper`(± 步进 + 可手输),勿单独裸 `input[type=number]`。 +- 对外文档(接入 + 后台运营手册)禁用 RBAC slug(如 `prd.settlement.agent.manage`);对客户称「贵司」;排版忌 AI 感,正文对比度与字号可读优先。 +- 文档 i18n:`useTranslation` 须显式 `ns`;`returnObjects` 列表用 `Array.isArray` 守卫;避免节名与表头 key 冲突(如 `billStatus`)。 ## Learned Workspace Facts @@ -39,5 +41,11 @@ This version has breaking changes — APIs, conventions, and file structure may - 超管判定用登录态 `is_super_admin`,勿用站点角色或 `admin_user_site_roles` 绑定推断。 - 站点管理员(`profile.site != null`)代理 UI 绕过选中代理的 `can_create_*` 门控,按自身 manage 权限展示 Tab/操作。 - 站点管理员在代理下创建玩家须传 `agent_node_id`(与超管同逻辑),勿默认挂根代理。 -- 客户接入文档公开站:`/docs`(首页)、`/docs/integration`(接入正文),无需 `/admin` 登录;旧 `/admin/docs/integration-guide` 重定向至此。 +- 客户对外文档:`/docs`(首页)、`/docs/integration`(API 接入)、`/docs/admin`(后台运营手册),均公开免 `/admin` 登录;读者为接入方技术与站点运营/代理,非内部运维;旧 `/admin/docs/integration-guide` 重定向 `/docs/integration`;顶栏「管理后台」链 `/admin`(勿 `/admin/login`)。 - `SettlementBillRow` 无 `currency_code`;账单金额展示用玩家 `default_currency`。 +- 浏览器 `/api/v1/*` 由 Next 转发到 `LOTTERY_API_UPSTREAM`;与本地 Postgres 对账前须确认 upstream 与所查库一致。 +- 接入文档页静态内容校验用 `document.body.innerText`;`DocCode` 非裸 `
/`,勿只查 code 选择器。
+- Docs 侧栏粘性定位用 CSS 变量 `--docs-sticky-top`(含顶栏/header 偏移)。
+- Tanumo 联调/生产默认:H5 `https://front.tanumo.com`、API `https://lotterylaravel.tanumo.com`、管理端/文档 `https://lotteryadmin.tanumo.com`。
+- 接入文档 SSO:无「登录换票」;主站 JWT 直传 H5/iframe,`GET /api/v1/player/me` 自动建档;iframe 约定 token 在顶层 `data.token`;勿引用 main-site/monorepo 等内部仓库路径。
+- 风控页默认:占用流水按注单聚合;风险池仅显示有占用/高风险;组合明细放注单详情二级页。
diff --git a/src/api/admin-risk.ts b/src/api/admin-risk.ts
index e506e88..b329bad 100644
--- a/src/api/admin-risk.ts
+++ b/src/api/admin-risk.ts
@@ -15,6 +15,7 @@ export type AdminRiskPoolListQuery = {
   per_page?: number;
   sold_out_only?: boolean;
   high_risk_only?: boolean;
+  active_only?: boolean;
   normalized_number?: string;
   sort?: "usage_desc" | "locked_desc" | "remaining_asc" | "number_asc";
 };
@@ -29,6 +30,7 @@ export async function getAdminRiskPools(
       per_page: q.per_page,
       sold_out_only: q.sold_out_only === true ? 1 : undefined,
       high_risk_only: q.high_risk_only === true ? 1 : undefined,
+      active_only: q.active_only === true ? 1 : undefined,
       normalized_number: q.normalized_number || undefined,
       sort: q.sort,
     },
@@ -58,8 +60,10 @@ export async function postAdminRiskPoolRecover(
 export type AdminRiskLockLogQuery = {
   page?: number;
   per_page?: number;
+  group_by?: "ticket" | "entry";
   action_type?: "lock" | "release";
   normalized_number?: string;
+  ticket_item_id?: number;
 };
 
 export async function getAdminRiskPoolLockLogs(
@@ -72,8 +76,10 @@ export async function getAdminRiskPoolLockLogs(
       params: {
         page: q.page,
         per_page: q.per_page,
+        group_by: q.group_by ?? "ticket",
         action_type: q.action_type,
         normalized_number: q.normalized_number,
+        ticket_item_id: q.ticket_item_id,
       },
     },
   );
diff --git a/src/api/admin-ticket-detail.ts b/src/api/admin-ticket-detail.ts
new file mode 100644
index 0000000..69a255b
--- /dev/null
+++ b/src/api/admin-ticket-detail.ts
@@ -0,0 +1,10 @@
+import { adminRequest } from "@/lib/admin-http";
+
+import type { AdminTicketItemDetail } from "@/types/api/admin-tickets";
+
+const A = `/admin`;
+
+export async function getAdminTicketItem(ticketNo: string): Promise {
+  const encoded = encodeURIComponent(ticketNo);
+  return adminRequest.get(`${A}/tickets/${encoded}`);
+}
diff --git a/src/app/admin/(shell)/agents/layout.tsx b/src/app/admin/(shell)/agents/layout.tsx
index a8f835e..a479a9b 100644
--- a/src/app/admin/(shell)/agents/layout.tsx
+++ b/src/app/admin/(shell)/agents/layout.tsx
@@ -4,7 +4,7 @@ import { AgentsSubnav } from "@/modules/agents/agents-subnav";
 
 export default function AdminAgentsLayout({ children }: { children: ReactNode }) {
   return (
-    
+
{children}
diff --git a/src/app/admin/(shell)/agents/list/page.tsx b/src/app/admin/(shell)/agents/list/page.tsx index 9bdecd8..72544f4 100644 --- a/src/app/admin/(shell)/agents/list/page.tsx +++ b/src/app/admin/(shell)/agents/list/page.tsx @@ -3,7 +3,7 @@ import type { Metadata } from "next"; import { AgentsDirectoryConsole } from "@/modules/agents/agents-directory-console"; import { buildPageMetadata } from "@/lib/page-metadata"; -export const metadata: Metadata = buildPageMetadata("agents", "directoryTitle"); +export const metadata: Metadata = buildPageMetadata("agents", "listTitle"); export default function AgentsListPage() { return ; diff --git a/src/app/admin/(shell)/draws/[drawId]/risk/pools/page.tsx b/src/app/admin/(shell)/draws/[drawId]/risk/pools/page.tsx index 42b8691..fa4c686 100644 --- a/src/app/admin/(shell)/draws/[drawId]/risk/pools/page.tsx +++ b/src/app/admin/(shell)/draws/[drawId]/risk/pools/page.tsx @@ -4,10 +4,10 @@ import { } from "@/modules/risk/risk-pools-console"; function parsePoolFilter(raw: string | undefined): RiskPoolListFilter { - if (raw === "sold_out" || raw === "high_risk") { + if (raw === "sold_out" || raw === "high_risk" || raw === "all" || raw === "active") { return raw; } - return "all"; + return "active"; } export default async function AdminDrawRiskPoolsPage(props: { @@ -24,7 +24,7 @@ export default async function AdminDrawRiskPoolsPage(props: { drawId={id} titleKey="allPoolsPageTitle" initialFilter={filter} - defaultSort={filter === "high_risk" ? "usage_desc" : "number_asc"} + defaultSort={filter === "high_risk" || filter === "active" ? "usage_desc" : "number_asc"} allowSortChange /> ); diff --git a/src/app/admin/(shell)/tickets/[ticketNo]/page.tsx b/src/app/admin/(shell)/tickets/[ticketNo]/page.tsx new file mode 100644 index 0000000..5c1b818 --- /dev/null +++ b/src/app/admin/(shell)/tickets/[ticketNo]/page.tsx @@ -0,0 +1,22 @@ +import { AdminPermissionGate } from "@/components/admin/admin-permission-gate"; +import { ModuleScaffold } from "@/components/admin/module-scaffold"; +import { PRD_TICKETS_ACCESS_ANY } from "@/lib/admin-prd"; +import { TicketDetailConsole } from "@/modules/tickets/ticket-detail-console"; +import { buildPageMetadata } from "@/lib/page-metadata"; +import type { Metadata } from "next"; + +export const metadata: Metadata = buildPageMetadata("tickets", "detailTitle"); + +export default async function AdminTicketDetailPage(props: { + params: Promise<{ ticketNo: string }>; +}) { + const { ticketNo } = await props.params; + + return ( + + + + + + ); +} diff --git a/src/app/docs/admin/agents/page.tsx b/src/app/docs/admin/agents/page.tsx new file mode 100644 index 0000000..dfd0436 --- /dev/null +++ b/src/app/docs/admin/agents/page.tsx @@ -0,0 +1,5 @@ +import { AdminAgentsDocScreen } from "@/modules/docs/admin/admin-doc-screens"; + +export default function AdminDocsAgentsPage(): React.ReactElement { + return ; +} diff --git a/src/app/docs/admin/config/page.tsx b/src/app/docs/admin/config/page.tsx new file mode 100644 index 0000000..ff4bcae --- /dev/null +++ b/src/app/docs/admin/config/page.tsx @@ -0,0 +1,5 @@ +import { AdminConfigDocScreen } from "@/modules/docs/admin/admin-doc-screens"; + +export default function AdminDocsConfigPage(): React.ReactElement { + return ; +} diff --git a/src/app/docs/admin/draws/page.tsx b/src/app/docs/admin/draws/page.tsx new file mode 100644 index 0000000..7e05acc --- /dev/null +++ b/src/app/docs/admin/draws/page.tsx @@ -0,0 +1,5 @@ +import { AdminDrawsDocScreen } from "@/modules/docs/admin/admin-doc-screens"; + +export default function AdminDocsDrawsPage(): React.ReactElement { + return ; +} diff --git a/src/app/docs/admin/faq/page.tsx b/src/app/docs/admin/faq/page.tsx new file mode 100644 index 0000000..7505822 --- /dev/null +++ b/src/app/docs/admin/faq/page.tsx @@ -0,0 +1,5 @@ +import { AdminFaqDocScreen } from "@/modules/docs/admin/admin-doc-screens"; + +export default function AdminDocsFaqPage(): React.ReactElement { + return ; +} diff --git a/src/app/docs/admin/fund-operations/page.tsx b/src/app/docs/admin/fund-operations/page.tsx new file mode 100644 index 0000000..df26b84 --- /dev/null +++ b/src/app/docs/admin/fund-operations/page.tsx @@ -0,0 +1,5 @@ +import { AdminFundOperationsDocScreen } from "@/modules/docs/admin/admin-doc-screens"; + +export default function AdminDocsFundOperationsPage(): React.ReactElement { + return ; +} diff --git a/src/app/docs/admin/layout.tsx b/src/app/docs/admin/layout.tsx new file mode 100644 index 0000000..8d84329 --- /dev/null +++ b/src/app/docs/admin/layout.tsx @@ -0,0 +1,13 @@ +import type { ReactNode } from "react"; + +import { DocsSidebar } from "@/components/docs/docs-sidebar"; +import { DocsBody } from "@/components/docs/docs-shell"; +import { ADMIN_DOCS_NAV_GROUPS } from "@/lib/admin-docs-nav"; + +export default function AdminDocsLayout({ children }: { children: ReactNode }): React.ReactElement { + return ( + }> + {children} + + ); +} diff --git a/src/app/docs/admin/manual-review/page.tsx b/src/app/docs/admin/manual-review/page.tsx new file mode 100644 index 0000000..259ce4b --- /dev/null +++ b/src/app/docs/admin/manual-review/page.tsx @@ -0,0 +1,5 @@ +import { AdminManualReviewDocScreen } from "@/modules/docs/admin/admin-doc-screens"; + +export default function AdminDocsManualReviewPage(): React.ReactElement { + return ; +} diff --git a/src/app/docs/admin/page.tsx b/src/app/docs/admin/page.tsx new file mode 100644 index 0000000..0a59006 --- /dev/null +++ b/src/app/docs/admin/page.tsx @@ -0,0 +1,5 @@ +import { AdminOverviewDocScreen } from "@/modules/docs/admin/admin-doc-screens"; + +export default function AdminDocsOverviewPage(): React.ReactElement { + return ; +} diff --git a/src/app/docs/admin/players/page.tsx b/src/app/docs/admin/players/page.tsx new file mode 100644 index 0000000..58940e1 --- /dev/null +++ b/src/app/docs/admin/players/page.tsx @@ -0,0 +1,5 @@ +import { AdminPlayersDocScreen } from "@/modules/docs/admin/admin-doc-screens"; + +export default function AdminDocsPlayersPage(): React.ReactElement { + return ; +} diff --git a/src/app/docs/admin/reports/page.tsx b/src/app/docs/admin/reports/page.tsx new file mode 100644 index 0000000..6fc59f5 --- /dev/null +++ b/src/app/docs/admin/reports/page.tsx @@ -0,0 +1,5 @@ +import { AdminReportsDocScreen } from "@/modules/docs/admin/admin-doc-screens"; + +export default function AdminDocsReportsPage(): React.ReactElement { + return ; +} diff --git a/src/app/docs/admin/roles/page.tsx b/src/app/docs/admin/roles/page.tsx new file mode 100644 index 0000000..a2e50aa --- /dev/null +++ b/src/app/docs/admin/roles/page.tsx @@ -0,0 +1,5 @@ +import { AdminRolesDocScreen } from "@/modules/docs/admin/admin-doc-screens"; + +export default function AdminDocsRolesPage(): React.ReactElement { + return ; +} diff --git a/src/app/docs/admin/settlement-center/page.tsx b/src/app/docs/admin/settlement-center/page.tsx new file mode 100644 index 0000000..585b3cf --- /dev/null +++ b/src/app/docs/admin/settlement-center/page.tsx @@ -0,0 +1,5 @@ +import { AdminSettlementCenterDocScreen } from "@/modules/docs/admin/admin-doc-screens"; + +export default function AdminDocsSettlementCenterPage(): React.ReactElement { + return ; +} diff --git a/src/app/docs/admin/site-setup/page.tsx b/src/app/docs/admin/site-setup/page.tsx new file mode 100644 index 0000000..1dddb4c --- /dev/null +++ b/src/app/docs/admin/site-setup/page.tsx @@ -0,0 +1,5 @@ +import { AdminSiteSetupDocScreen } from "@/modules/docs/admin/admin-doc-screens"; + +export default function AdminDocsSiteSetupPage(): React.ReactElement { + return ; +} diff --git a/src/app/docs/admin/tickets/page.tsx b/src/app/docs/admin/tickets/page.tsx new file mode 100644 index 0000000..ba47722 --- /dev/null +++ b/src/app/docs/admin/tickets/page.tsx @@ -0,0 +1,5 @@ +import { AdminTicketsDocScreen } from "@/modules/docs/admin/admin-doc-screens"; + +export default function AdminDocsTicketsPage(): React.ReactElement { + return ; +} diff --git a/src/app/docs/admin/wallet/page.tsx b/src/app/docs/admin/wallet/page.tsx new file mode 100644 index 0000000..6692e62 --- /dev/null +++ b/src/app/docs/admin/wallet/page.tsx @@ -0,0 +1,5 @@ +import { AdminWalletDocScreen } from "@/modules/docs/admin/admin-doc-screens"; + +export default function AdminDocsWalletPage(): React.ReactElement { + return ; +} diff --git a/src/app/docs/integration/admin-guide/page.tsx b/src/app/docs/integration/admin-guide/page.tsx new file mode 100644 index 0000000..7d59c99 --- /dev/null +++ b/src/app/docs/integration/admin-guide/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from "next/navigation"; + +export default function LegacyAdminGuidePage(): never { + redirect("/docs/admin"); +} diff --git a/src/app/docs/integration/api-reference/page.tsx b/src/app/docs/integration/api-reference/page.tsx new file mode 100644 index 0000000..6537928 --- /dev/null +++ b/src/app/docs/integration/api-reference/page.tsx @@ -0,0 +1,6 @@ +import { redirect } from "next/navigation"; + +/** 旧链接兼容:完整内容已拆至左侧分章导航 */ +export default function ApiReferencePage(): never { + redirect("/docs/integration"); +} diff --git a/src/app/docs/integration/delivery/page.tsx b/src/app/docs/integration/delivery/page.tsx new file mode 100644 index 0000000..9477937 --- /dev/null +++ b/src/app/docs/integration/delivery/page.tsx @@ -0,0 +1,5 @@ +import { DeliveryDocScreen } from "@/modules/docs/integration/integration-doc-screens"; + +export default function IntegrationDeliveryPage(): React.ReactElement { + return ; +} diff --git a/src/app/docs/integration/troubleshooting/page.tsx b/src/app/docs/integration/troubleshooting/page.tsx new file mode 100644 index 0000000..d4bae2e --- /dev/null +++ b/src/app/docs/integration/troubleshooting/page.tsx @@ -0,0 +1,5 @@ +import { TroubleshootingDocScreen } from "@/modules/docs/integration/integration-doc-screens"; + +export default function IntegrationTroubleshootingPage(): React.ReactElement { + return ; +} diff --git a/src/app/docs/layout.tsx b/src/app/docs/layout.tsx index 41d5188..8b2e994 100644 --- a/src/app/docs/layout.tsx +++ b/src/app/docs/layout.tsx @@ -5,10 +5,10 @@ import { DocsShell } from "@/components/docs/docs-shell"; export const metadata: Metadata = { title: { - template: "%s · Integration API", - default: "Integration API", + template: "%s · Docs", + default: "Documentation", }, - description: "Lottery integration docs: SSO, wallet gateway, transfers.", + description: "Lottery admin user guide and API integration documentation.", }; export default function DocsLayout({ children }: { children: ReactNode }): React.ReactElement { diff --git a/src/app/globals.css b/src/app/globals.css index 48df7e2..a8a8232 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -238,3 +238,21 @@ } } +/* 公开文档站:正文链接与强调,避免继承后台 muted 色 */ +.docs-site .doc-content a:not([class]) { + color: #0f172a; + font-weight: 500; + text-decoration: underline; + text-decoration-color: #cbd5e1; + text-underline-offset: 2px; +} + +.docs-site .doc-content a:not([class]):hover { + text-decoration-color: #64748b; +} + +.docs-site .doc-content strong { + color: #0f172a; + font-weight: 600; +} + diff --git a/src/components/admin/admin-breadcrumb.tsx b/src/components/admin/admin-breadcrumb.tsx index 233354e..84e0eba 100644 --- a/src/components/admin/admin-breadcrumb.tsx +++ b/src/components/admin/admin-breadcrumb.tsx @@ -41,7 +41,7 @@ const TOP_ROUTE_LABELS: Record = { }; const AGENT_ROUTE_LABELS: Record = { - list: "agents.directoryTitle", + list: "agents.listTitle", provision: "agents.subnav.provision", "settlement-bills": "settlementCenter.title", }; diff --git a/src/components/admin/admin-page-guide.tsx b/src/components/admin/admin-page-guide.tsx new file mode 100644 index 0000000..52a6da5 --- /dev/null +++ b/src/components/admin/admin-page-guide.tsx @@ -0,0 +1,37 @@ +"use client"; + +import Link from "next/link"; +import { BookOpenIcon } from "lucide-react"; +import { useTranslation } from "react-i18next"; + +import { cn } from "@/lib/utils"; + +type AdminPageGuideProps = { + guide: string; + docHref?: string; + className?: string; +}; + +export function AdminPageGuide({ guide, docHref, className }: AdminPageGuideProps): React.ReactElement { + const { t } = useTranslation("common"); + + return ( +
+

{guide}

+ {docHref ? ( + + + {t("docs.learnMore")} + + ) : null} +
+ ); +} diff --git a/src/components/admin/admin-shell.tsx b/src/components/admin/admin-shell.tsx index d29a551..365869d 100644 --- a/src/components/admin/admin-shell.tsx +++ b/src/components/admin/admin-shell.tsx @@ -48,7 +48,7 @@ export function AdminShell({ children }: { children: ReactNode }) { )}
-
+
{children}
diff --git a/src/components/admin/module-scaffold.tsx b/src/components/admin/module-scaffold.tsx index a6d0d08..0d6be69 100644 --- a/src/components/admin/module-scaffold.tsx +++ b/src/components/admin/module-scaffold.tsx @@ -15,7 +15,7 @@ export function ModuleScaffold({ children, className, embedded = false }: Module
{t("toolbar.accountSettings")} + router.push("/docs/admin")} + > + + {t("toolbar.relatedDocs")} + diff --git a/src/components/docs/doc-code.tsx b/src/components/docs/doc-code.tsx index b29a3d7..3622372 100644 --- a/src/components/docs/doc-code.tsx +++ b/src/components/docs/doc-code.tsx @@ -9,12 +9,14 @@ type DocCodeProps = { children: string; language?: HighlightLang; className?: string; + title?: string; }; export function DocCode({ children, language = "json", className, + title, }: DocCodeProps): React.ReactElement { const [html, setHtml] = useState(null); const code = children.trimEnd(); @@ -36,17 +38,22 @@ export function DocCode({ return (
+ {title ? ( +
+ {title} +
+ ) : null} {html ? (
) : ( -
+        
           {code}
         
)} diff --git a/src/components/docs/doc-ui.tsx b/src/components/docs/doc-ui.tsx index 0736512..0cb46eb 100644 --- a/src/components/docs/doc-ui.tsx +++ b/src/components/docs/doc-ui.tsx @@ -4,6 +4,11 @@ import { cn } from "@/lib/utils"; export { DocCode } from "@/components/docs/doc-code"; +/** 文档正文容器:统一字号与段落间距 */ +export function DocPage({ children }: { children: ReactNode }): React.ReactElement { + return
{children}
; +} + export function DocPageHeader({ title, description, @@ -12,10 +17,14 @@ export function DocPageHeader({ description?: string; }): React.ReactElement { return ( -
-

{title}

+
+

+ {title} +

{description ? ( -

{description}

+

+ {description} +

) : null}
); @@ -31,26 +40,39 @@ export function DocSection({ className?: string; }): React.ReactElement { return ( -
- {title ?

{title}

: null} +
+ {title ? ( +

+ {title} +

+ ) : null} {children}
); } +export function DocParagraph({ children }: { children: ReactNode }): React.ReactElement { + return

{children}

; +} + export function DocNote({ children }: { children: ReactNode }): React.ReactElement { return ( -

{children}

+ ); } export function DocList({ items }: { items: readonly string[] }): React.ReactElement { return ( -
    +
      {items.map((item) => ( -
    • - · - {item} +
    • + + {item}
    • ))}
    @@ -59,9 +81,11 @@ export function DocList({ items }: { items: readonly string[] }): React.ReactEle export function DocOrderedList({ items }: { items: readonly string[] }): React.ReactElement { return ( -
      +
        {items.map((item) => ( -
      1. {item}
      2. +
      3. + {item} +
      4. ))}
      ); @@ -77,14 +101,14 @@ export function DocTable({ compact?: boolean; }): React.ReactElement { return ( -
      - +
      +
      - + {headers.map((header) => ( @@ -93,13 +117,17 @@ export function DocTable({ {rows.map((row, rowIndex) => ( - + {row.map((cell, cellIndex) => (
      {header}
      {cell} @@ -115,15 +143,19 @@ export function DocTable({ export function DocEndpoint({ method, path }: { method: string; path: string }): React.ReactElement { return ( -
      - {method} - {path} +
      + + {method} + + {path}
      ); } export function DocInlineCode({ children }: { children: ReactNode }): React.ReactElement { return ( - {children} + + {children} + ); } diff --git a/src/components/docs/docs-admin-console-link.tsx b/src/components/docs/docs-admin-console-link.tsx new file mode 100644 index 0000000..5355cc7 --- /dev/null +++ b/src/components/docs/docs-admin-console-link.tsx @@ -0,0 +1,31 @@ +"use client"; + +import Link from "next/link"; +import { useTranslation } from "react-i18next"; + +import { cn } from "@/lib/utils"; + +type DocsAdminConsoleLinkProps = { + namespace: "adminDocs" | "integrationDocs"; + className?: string; +}; + +export function DocsAdminConsoleLink({ + namespace, + className, +}: DocsAdminConsoleLinkProps): React.ReactElement { + const { t } = useTranslation(namespace); + const label = t("shell.adminLogin"); + + return ( + + {label} + + ); +} diff --git a/src/components/docs/docs-shell.tsx b/src/components/docs/docs-shell.tsx index 209ba6c..46d16cb 100644 --- a/src/components/docs/docs-shell.tsx +++ b/src/components/docs/docs-shell.tsx @@ -1,9 +1,12 @@ "use client"; import Link from "next/link"; +import { usePathname } from "next/navigation"; import { useTranslation } from "react-i18next"; import { AdminLanguageSwitcher } from "@/components/admin/admin-language-switcher"; +import { DocsAdminConsoleLink } from "@/components/docs/docs-admin-console-link"; +import { DocsTopNav } from "@/components/docs/docs-top-nav"; import { cn } from "@/lib/utils"; type DocsShellProps = { @@ -12,23 +15,28 @@ type DocsShellProps = { }; export function DocsShell({ children, className }: DocsShellProps): React.ReactElement { - const { t } = useTranslation("integrationDocs"); + const pathname = usePathname(); + const isAdminDocs = pathname === "/docs/admin" || pathname.startsWith("/docs/admin/"); + const namespace = isAdminDocs ? "adminDocs" : "integrationDocs"; + const homeHref = isAdminDocs ? "/docs/admin" : "/docs/integration"; + const { t } = useTranslation(namespace); return ( -
      -
      -
      - - {t("shell.title")} - -
      - +
      +
      +
      +
      - {t("shell.admin")} + {t("shell.title")} + +
      +
      + +
      @@ -45,9 +53,11 @@ export function DocsBody({ children: React.ReactNode; }): React.ReactElement { return ( -
      +
      {sidebar} -
      {children}
      +
      + {children} +
      ); } diff --git a/src/components/docs/docs-sidebar.tsx b/src/components/docs/docs-sidebar.tsx index fddbc66..ddff37c 100644 --- a/src/components/docs/docs-sidebar.tsx +++ b/src/components/docs/docs-sidebar.tsx @@ -8,43 +8,49 @@ import type { DocsNavGroup } from "@/lib/docs-nav"; import { resolveDocsNavLabel } from "@/lib/docs-nav-labels"; import { cn } from "@/lib/utils"; -export function DocsSidebar({ groups }: { groups: readonly DocsNavGroup[] }): React.ReactElement { +export function DocsSidebar({ + groups, + namespace = "integrationDocs", +}: { + groups: readonly DocsNavGroup[]; + namespace?: "integrationDocs" | "adminDocs"; +}): React.ReactElement { const pathname = usePathname(); - const { t, i18n } = useTranslation("integrationDocs"); + const { t, i18n } = useTranslation(namespace); + + const homeHref = namespace === "adminDocs" ? "/docs/admin" : "/docs/integration"; const label = (key: string): string => { const translated = t(key); if (translated !== key) { return translated; } - return resolveDocsNavLabel(key, i18n.resolvedLanguage ?? i18n.language); + return resolveDocsNavLabel(key, i18n.resolvedLanguage ?? i18n.language, namespace); }; return ( -
      +
      - {data ? ( - { - setPerPage(n); - setPage(1); - }} - onPageChange={setPage} - /> - ) : null} - + {data ? ( + { + setPerPage(n); + setPage(1); + }} + onPageChange={setPage} + /> + ) : null} ); diff --git a/src/modules/risk/risk-pools-console.tsx b/src/modules/risk/risk-pools-console.tsx index 4e13134..94a7aba 100644 --- a/src/modules/risk/risk-pools-console.tsx +++ b/src/modules/risk/risk-pools-console.tsx @@ -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({ {filter === "all" ? t("filterAll") - : filter === "sold_out" - ? t("filterSoldOut") - : t("filterHighRisk")} + : filter === "active" + ? t("filterActive") + : filter === "sold_out" + ? t("filterSoldOut") + : t("filterHighRisk")} + {t("filterActive")} {t("filterAll")} {t("filterSoldOut")} {t("filterHighRisk")} diff --git a/src/modules/settlement/settlement-center-shell.tsx b/src/modules/settlement/settlement-center-shell.tsx index 8504a04..d53d319 100644 --- a/src/modules/settlement/settlement-center-shell.tsx +++ b/src/modules/settlement/settlement-center-shell.tsx @@ -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([]); + const [sitesReady, setSitesReady] = useState(() => boundAgent?.admin_site_id != null); const [adminSiteId, setAdminSiteId] = useState(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 (
      - {siteId === null && siteOptions.length === 0 && boundAgent === null ? ( + + {shellBootstrapping ? ( + + ) : siteId === null && siteOptions.length === 0 && boundAgent === null ? ( ) : siteId === null ? (

      {t("empty.noSite", { defaultValue: "请选择站点。" })}

      - ) : !periodsReady ? ( - ) : isListMode ? ( { setLoading(true); diff --git a/src/modules/tickets/player-tickets-console.tsx b/src/modules/tickets/player-tickets-console.tsx index 33eb601..541d836 100644 --- a/src/modules/tickets/player-tickets-console.tsx +++ b/src/modules/tickets/player-tickets-console.tsx @@ -401,18 +401,24 @@ export function PlayerTicketsConsole(): React.ReactElement { {formatTs(row.updated_at)} diff --git a/src/modules/tickets/ticket-detail-console.tsx b/src/modules/tickets/ticket-detail-console.tsx new file mode 100644 index 0000000..007cdc7 --- /dev/null +++ b/src/modules/tickets/ticket-detail-console.tsx @@ -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(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 ( + + + {t("detailTitle")} + + +

      {error}

      + + {t("backToList")} + +
      +
      + ); + } + + const currencyCode = data?.currency_code ?? "NPR"; + + return ( +
      + + +
      + {t("detailTitle")} + + {t("backToList")} + +
      + + {data?.ticket_no ?? ticketNo} + {data?.order_no ? ` · ${data.order_no}` : ""} + +
      + + {loading && !data ? ( +

      {t("loadingDetail")}

      + ) : data ? ( +
      +

      + {t("drawNo")}: + {data.draw_no ?? "—"} +

      +

      + {t("playCode")}: + {playCodeLabel(data.play_code)} +

      +

      + {t("number")}: + {data.original_number ?? "—"} +

      +

      + {t("combinationCount")}: + {data.combination_count} +

      +

      + {t("betAmount")}: + {data.total_bet_amount_formatted} +

      +

      + {t("actualDeduct")}: + {data.actual_deduct_amount_formatted} +

      +

      + {t("status")}: + {ticketStatusText(data.status, t)} +

      +

      + {t("player", { ns: "tickets" })}: + + {data.player_id ? ( + + {data.username ?? data.site_player_id ?? data.player_id} + + ) : ( + "—" + )} +

      +

      + {t("placedAt")}: + {data.placed_at ? formatDt(data.placed_at) : "—"} +

      + {data.fail_reason_text || data.fail_reason_code ? ( +

      + {t("failReason")}: + {data.fail_reason_text ?? data.fail_reason_code} +

      + ) : null} +
      + ) : null} +
      +
      + + + + {t("combinationsTitle")} + {t("combinationsHint")} + + +
      + +
      +
      + + + + No. + {t("number4d")} + {t("comboBetAmount")} + {t("comboEstimatedPayout")} + + + + {loading && !data ? : null} + {comboSlice.map((row) => ( + + {row.combination_no} + {row.number_4d} + + {formatAdminMinorUnits(row.bet_amount, currencyCode)} + + + {formatAdminMinorUnits(row.estimated_payout, currencyCode)} + + + ))} + +
      +
      + {combinations.length > 0 ? ( + { + setComboPerPage(n); + setComboPage(1); + }} + onPageChange={setComboPage} + /> + ) : null} +
      +
      +
      + ); +} diff --git a/src/types/api/admin-risk.ts b/src/types/api/admin-risk.ts index 043540b..afedb0c 100644 --- a/src/types/api/admin-risk.ts +++ b/src/types/api/admin-risk.ts @@ -37,11 +37,27 @@ export type AdminRiskLockLogRow = { created_at: string | null; }; +export type AdminRiskLockLogTicketRow = { + ticket_item_id: number; + ticket_no: string; + play_code: string; + original_number: string; + combination_count: number; + player_id: number; + number_count: number; + lock_entry_count: number; + release_entry_count: number; + total_lock_amount: number; + total_release_amount: number; + last_at: string | null; +}; + export type AdminRiskLockLogListData = { draw_id: number; draw_no: string; currency_code: string | null; - items: AdminRiskLockLogRow[]; + group_by: "ticket" | "entry"; + items: AdminRiskLockLogRow[] | AdminRiskLockLogTicketRow[]; meta: AdminRiskPoolListMeta; }; diff --git a/src/types/api/admin-tickets.ts b/src/types/api/admin-tickets.ts index c6435da..2afe8fb 100644 --- a/src/types/api/admin-tickets.ts +++ b/src/types/api/admin-tickets.ts @@ -39,6 +39,52 @@ export type AdminTicketItemRow = { updated_at: string | null; }; +export type AdminTicketCombinationRow = { + combination_no: number; + number_4d: string; + bet_amount: number; + estimated_payout: number; +}; + +export type AdminTicketItemDetail = { + id: number; + ticket_no: string; + order_no: string | null; + order_status: string | null; + draw_id: number; + draw_no: string | null; + currency_code: string | null; + player_id: number; + site_code: string | null; + site_player_id: string | null; + username: string | null; + nickname: string | null; + funding_mode: string | null; + agent_node_id: number | null; + agent_code: string | null; + agent_name: string | null; + play_code: string; + dimension: number | null; + digit_slot: number | null; + original_number: string | null; + normalized_number: string | null; + combination_count: number; + unit_bet_amount: number; + total_bet_amount_minor: number; + total_bet_amount_formatted: string; + actual_deduct_amount_minor: number; + actual_deduct_amount_formatted: string; + risk_locked_amount: number; + status: string; + fail_reason_code: string | null; + fail_reason_text: string | null; + win_amount_minor: number; + win_amount_formatted: string; + placed_at: string | null; + updated_at: string | null; + combinations: AdminTicketCombinationRow[]; +}; + export type AdminTicketItemsData = { items: AdminTicketItemRow[]; total: number;