diff --git a/src/api/admin-audit.ts b/src/api/admin-audit.ts new file mode 100644 index 0000000..52a245d --- /dev/null +++ b/src/api/admin-audit.ts @@ -0,0 +1,19 @@ +import { adminRequest } from "@/lib/admin-http"; + +import { API_V1_PREFIX } from "./paths"; + +import type { AdminAuditLogListData } from "@/types/api/admin-audit"; + +const A = `${API_V1_PREFIX}/admin`; + +export async function getAdminAuditLogs(params?: { + page?: number; + per_page?: number; + module_code?: string; + action_code?: string; + operator_type?: string; +}): Promise { + return adminRequest.get(`${A}/audit-logs`, { + params, + }); +} diff --git a/src/api/admin-draws.ts b/src/api/admin-draws.ts index 5030de2..d94f74b 100644 --- a/src/api/admin-draws.ts +++ b/src/api/admin-draws.ts @@ -2,6 +2,7 @@ import { adminRequest } from "@/lib/admin-http"; import { API_V1_PREFIX } from "./paths"; +import type { AdminDrawFinanceSummaryData } from "@/types/api/admin-draw-finance"; import type { AdminDrawBatchesData, AdminDrawListData, @@ -30,6 +31,15 @@ export async function getAdminDrawResultBatches(drawId: number): Promise(`${A}/draws/${drawId}/result-batches`); } +/** PRD §15.4:单期投注/派彩与结算批次摘要 */ +export async function getAdminDrawFinanceSummary( + drawId: number, +): Promise { + return adminRequest.get( + `${A}/draws/${drawId}/finance-summary`, + ); +} + export async function postAdminPublishResultBatch( drawId: number, batchId: number, diff --git a/src/api/admin-player-tickets.ts b/src/api/admin-player-tickets.ts new file mode 100644 index 0000000..a74cafb --- /dev/null +++ b/src/api/admin-player-tickets.ts @@ -0,0 +1,17 @@ +import { adminRequest } from "@/lib/admin-http"; + +import { API_V1_PREFIX } from "./paths"; + +import type { AdminPlayerTicketItemsData } from "@/types/api/admin-player-tickets"; + +const A = `${API_V1_PREFIX}/admin`; + +export async function getAdminPlayerTicketItems( + playerId: number, + params?: { page?: number; per_page?: number; draw_no?: string }, +): Promise { + return adminRequest.get( + `${A}/players/${playerId}/ticket-items`, + { params }, + ); +} diff --git a/src/api/admin-reconcile.ts b/src/api/admin-reconcile.ts new file mode 100644 index 0000000..7d10a7d --- /dev/null +++ b/src/api/admin-reconcile.ts @@ -0,0 +1,48 @@ +import { adminRequest } from "@/lib/admin-http"; + +import { API_V1_PREFIX } from "./paths"; + +import type { + AdminReconcileItemsData, + AdminReconcileJobCreateResponse, + AdminReconcileJobListData, +} from "@/types/api/admin-reconcile"; + +const A = `${API_V1_PREFIX}/admin`; + +export async function getAdminReconcileJobs(params?: { + page?: number; + per_page?: number; + reconcile_type?: string; +}): Promise { + return adminRequest.get(`${A}/reconcile-jobs`, { + params, + }); +} + +export async function postAdminReconcileJob(body: { + reconcile_type: string; + period_start?: string | null; + period_end?: string | null; + items?: { + side_a_ref?: string | null; + side_b_ref?: string | null; + difference_amount?: number | null; + status?: string | null; + }[]; +}): Promise { + return adminRequest.post( + `${A}/reconcile-jobs`, + body, + ); +} + +export async function getAdminReconcileJobItems( + jobId: number, + params?: { page?: number; per_page?: number }, +): Promise { + return adminRequest.get( + `${A}/reconcile-jobs/${jobId}/items`, + { params }, + ); +} diff --git a/src/api/admin-reports.ts b/src/api/admin-reports.ts new file mode 100644 index 0000000..5ae66a0 --- /dev/null +++ b/src/api/admin-reports.ts @@ -0,0 +1,30 @@ +import { adminRequest } from "@/lib/admin-http"; + +import { API_V1_PREFIX } from "./paths"; + +import type { + AdminReportJobCreateResponse, + AdminReportJobListData, +} from "@/types/api/admin-reports"; + +const A = `${API_V1_PREFIX}/admin`; + +export async function getAdminReportJobs(params?: { + page?: number; + per_page?: number; +}): Promise { + return adminRequest.get(`${A}/report-jobs`, { + params, + }); +} + +export async function postAdminReportJob(body: { + report_type: string; + export_format?: "csv" | "xlsx"; + filter_json?: Record | null; +}): Promise { + return adminRequest.post( + `${A}/report-jobs`, + body, + ); +} diff --git a/src/api/index.ts b/src/api/index.ts index e51a7f3..7b6ccbf 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -6,12 +6,21 @@ export { getAdminTransferOrders, getAdminWalletTransactions, } from "@/api/admin-wallet"; +export { getAdminReportJobs, postAdminReportJob } from "@/api/admin-reports"; +export { + getAdminReconcileJobItems, + getAdminReconcileJobs, + postAdminReconcileJob, +} from "@/api/admin-reconcile"; +export { getAdminAuditLogs } from "@/api/admin-audit"; export { getAdminDraw, + getAdminDrawFinanceSummary, getAdminDrawResultBatches, getAdminDraws, postAdminPublishResultBatch, } from "@/api/admin-draws"; +export { getAdminPlayerTicketItems } from "@/api/admin-player-tickets"; export type { AdminAuthCaptchaResponse, AdminAuthLoginRequest, diff --git a/src/app/admin/(shell)/audit-logs/page.tsx b/src/app/admin/(shell)/audit-logs/page.tsx new file mode 100644 index 0000000..d3b8dac --- /dev/null +++ b/src/app/admin/(shell)/audit-logs/page.tsx @@ -0,0 +1,16 @@ +import { ModuleScaffold } from "@/components/admin/module-scaffold"; +import { auditModuleMeta } from "@/modules/audit/meta"; +import { AuditLogsConsole } from "@/modules/audit/audit-logs-console"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: auditModuleMeta.title, +}; + +export default function AdminAuditLogsPage() { + return ( + + + + ); +} diff --git a/src/app/admin/(shell)/draws/[drawId]/finance/page.tsx b/src/app/admin/(shell)/draws/[drawId]/finance/page.tsx new file mode 100644 index 0000000..a5471b8 --- /dev/null +++ b/src/app/admin/(shell)/draws/[drawId]/finance/page.tsx @@ -0,0 +1,13 @@ +import { DrawFinanceConsole } from "@/modules/draws/draw-finance-console"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "期号收支", +}; + +export default async function AdminDrawFinancePage(props: { + params: Promise<{ drawId: string }>; +}) { + const { drawId } = await props.params; + return ; +} diff --git a/src/app/admin/(shell)/draws/[drawId]/publish/[batchId]/page.tsx b/src/app/admin/(shell)/draws/[drawId]/publish/[batchId]/page.tsx new file mode 100644 index 0000000..4bdbfa5 --- /dev/null +++ b/src/app/admin/(shell)/draws/[drawId]/publish/[batchId]/page.tsx @@ -0,0 +1,14 @@ +import { DrawPublishConsole } from "@/modules/draws/draw-publish-console"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "开奖结果发布", +}; + +/** PRD §11.4:与 `POST /api/v1/admin/draws/{draw}/result-batches/{batch}/publish` 联调页 */ +export default async function AdminDrawPublishBatchPage(props: { + params: Promise<{ drawId: string; batchId: string }>; +}) { + const { drawId, batchId } = await props.params; + return ; +} diff --git a/src/app/admin/(shell)/draws/[drawId]/review/[batchId]/page.tsx b/src/app/admin/(shell)/draws/[drawId]/review/[batchId]/page.tsx index 6921728..96fb92b 100644 --- a/src/app/admin/(shell)/draws/[drawId]/review/[batchId]/page.tsx +++ b/src/app/admin/(shell)/draws/[drawId]/review/[batchId]/page.tsx @@ -1,8 +1,9 @@ -import { DrawPublishConsole } from "@/modules/draws/draw-publish-console"; +import { redirect } from "next/navigation"; -export default async function AdminDrawPublishPage(props: { +/** 兼容旧链接:发布页统一为 `/publish/[batchId]`(与接口动词一致)。 */ +export default async function AdminDrawReviewBatchRedirectPage(props: { params: Promise<{ drawId: string; batchId: string }>; }) { const { drawId, batchId } = await props.params; - return ; + redirect(`/admin/draws/${drawId}/publish/${batchId}`); } diff --git a/src/app/admin/(shell)/players/page.tsx b/src/app/admin/(shell)/players/page.tsx index 0b470d4..028bab0 100644 --- a/src/app/admin/(shell)/players/page.tsx +++ b/src/app/admin/(shell)/players/page.tsx @@ -1,5 +1,6 @@ import { ModuleScaffold } from "@/components/admin/module-scaffold"; import { playersModuleMeta } from "@/modules/players/meta"; +import { PlayersConsole } from "@/modules/players/players-console"; import type { Metadata } from "next"; export const metadata: Metadata = { @@ -8,14 +9,8 @@ export const metadata: Metadata = { export default function AdminPlayersPage() { return ( - -

- 业务组件请放在{" "} - - src/modules/players - {" "} - 下。 -

+ + ); } diff --git a/src/app/admin/(shell)/reconcile/page.tsx b/src/app/admin/(shell)/reconcile/page.tsx new file mode 100644 index 0000000..bec0e7d --- /dev/null +++ b/src/app/admin/(shell)/reconcile/page.tsx @@ -0,0 +1,16 @@ +import { ModuleScaffold } from "@/components/admin/module-scaffold"; +import { reconcileModuleMeta } from "@/modules/reconcile/meta"; +import { ReconcileConsole } from "@/modules/reconcile/reconcile-console"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: reconcileModuleMeta.title, +}; + +export default function AdminReconcilePage() { + return ( + + + + ); +} diff --git a/src/app/admin/(shell)/reports/page.tsx b/src/app/admin/(shell)/reports/page.tsx new file mode 100644 index 0000000..da8c512 --- /dev/null +++ b/src/app/admin/(shell)/reports/page.tsx @@ -0,0 +1,16 @@ +import { ModuleScaffold } from "@/components/admin/module-scaffold"; +import { reportsModuleMeta } from "@/modules/reports/meta"; +import { ReportsConsole } from "@/modules/reports/reports-console"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: reportsModuleMeta.title, +}; + +export default function AdminReportsPage() { + return ( + + + + ); +} diff --git a/src/app/admin/(shell)/service-desk/page.tsx b/src/app/admin/(shell)/service-desk/page.tsx new file mode 100644 index 0000000..8f46706 --- /dev/null +++ b/src/app/admin/(shell)/service-desk/page.tsx @@ -0,0 +1,22 @@ +import { ModuleScaffold } from "@/components/admin/module-scaffold"; +import { ServiceDeskConsole } from "@/modules/service-desk/service-desk-console"; +import { serviceDeskModuleMeta } from "@/modules/service-desk/meta"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: serviceDeskModuleMeta.title, +}; + +export default function AdminServiceDeskPage() { + return ( + +
+

{serviceDeskModuleMeta.title}

+

+ {serviceDeskModuleMeta.description} +

+
+ +
+ ); +} diff --git a/src/app/admin/(shell)/tickets/page.tsx b/src/app/admin/(shell)/tickets/page.tsx index f2bcc7c..4e30089 100644 --- a/src/app/admin/(shell)/tickets/page.tsx +++ b/src/app/admin/(shell)/tickets/page.tsx @@ -1,5 +1,6 @@ import { ModuleScaffold } from "@/components/admin/module-scaffold"; import { ticketsModuleMeta } from "@/modules/tickets/meta"; +import { PlayerTicketsConsole } from "@/modules/tickets/player-tickets-console"; import type { Metadata } from "next"; export const metadata: Metadata = { @@ -8,14 +9,8 @@ export const metadata: Metadata = { export default function AdminTicketsPage() { return ( - -

- 业务组件请放在{" "} - - src/modules/tickets - {" "} - 下。 -

+ + ); } diff --git a/src/app/admin/(shell)/wallet/layout.tsx b/src/app/admin/(shell)/wallet/layout.tsx new file mode 100644 index 0000000..ca1480e --- /dev/null +++ b/src/app/admin/(shell)/wallet/layout.tsx @@ -0,0 +1,12 @@ +import type { ReactNode } from "react"; + +import { WalletSubnav } from "@/modules/wallet/wallet-subnav"; + +export default function AdminWalletLayout({ children }: { children: ReactNode }) { + return ( +
+ + {children} +
+ ); +} diff --git a/src/app/admin/(shell)/wallet/page.tsx b/src/app/admin/(shell)/wallet/page.tsx index ec3d678..b3ff865 100644 --- a/src/app/admin/(shell)/wallet/page.tsx +++ b/src/app/admin/(shell)/wallet/page.tsx @@ -1,16 +1,5 @@ -import { ModuleScaffold } from "@/components/admin/module-scaffold"; -import { walletModuleMeta } from "@/modules/wallet/meta"; -import { WalletConsole } from "@/modules/wallet/wallet-console"; -import type { Metadata } from "next"; +import { redirect } from "next/navigation"; -export const metadata: Metadata = { - title: walletModuleMeta.title, -}; - -export default function AdminWalletPage() { - return ( - - - - ); +export default function AdminWalletIndexPage() { + redirect("/admin/wallet/transactions"); } diff --git a/src/app/admin/(shell)/wallet/player/page.tsx b/src/app/admin/(shell)/wallet/player/page.tsx new file mode 100644 index 0000000..208ce1f --- /dev/null +++ b/src/app/admin/(shell)/wallet/player/page.tsx @@ -0,0 +1,16 @@ +import { ModuleScaffold } from "@/components/admin/module-scaffold"; +import { walletModuleMeta } from "@/modules/wallet/meta"; +import { PlayerWalletPanel } from "@/modules/wallet/wallet-console"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: `${walletModuleMeta.title} · 玩家钱包`, +}; + +export default function AdminWalletPlayerPage() { + return ( + + + + ); +} diff --git a/src/app/admin/(shell)/wallet/transactions/page.tsx b/src/app/admin/(shell)/wallet/transactions/page.tsx new file mode 100644 index 0000000..e6fb5f3 --- /dev/null +++ b/src/app/admin/(shell)/wallet/transactions/page.tsx @@ -0,0 +1,16 @@ +import { ModuleScaffold } from "@/components/admin/module-scaffold"; +import { walletModuleMeta } from "@/modules/wallet/meta"; +import { WalletTxnsPanel } from "@/modules/wallet/wallet-console"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: `${walletModuleMeta.title} · 流水`, +}; + +export default function AdminWalletTransactionsPage() { + return ( + + + + ); +} diff --git a/src/app/admin/(shell)/wallet/transfer-orders/page.tsx b/src/app/admin/(shell)/wallet/transfer-orders/page.tsx new file mode 100644 index 0000000..3be128f --- /dev/null +++ b/src/app/admin/(shell)/wallet/transfer-orders/page.tsx @@ -0,0 +1,16 @@ +import { ModuleScaffold } from "@/components/admin/module-scaffold"; +import { walletModuleMeta } from "@/modules/wallet/meta"; +import { TransferOrdersPanel } from "@/modules/wallet/wallet-console"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: `${walletModuleMeta.title} · 转账单`, +}; + +export default function AdminWalletTransferOrdersPage() { + return ( + + + + ); +} diff --git a/src/components/admin/admin-sidebar.tsx b/src/components/admin/admin-sidebar.tsx index a86ec92..56e36d5 100644 --- a/src/components/admin/admin-sidebar.tsx +++ b/src/components/admin/admin-sidebar.tsx @@ -2,6 +2,7 @@ import Link from "next/link"; import { usePathname } from "next/navigation"; +import { useMemo } from "react"; import { SparklesIcon } from "lucide-react"; import { @@ -22,7 +23,9 @@ import { adminNavIconBySegment, LogIn, } from "@/modules/_config/admin-nav-icons"; +import { adminNavItemVisible } from "@/lib/admin-nav-visibility"; import { adminShellNavItems, ADMIN_BASE } from "@/modules/_config/admin-nav"; +import { useAdminProfile } from "@/stores/admin-session"; function isActive(pathname: string, item: { href: string; activeMatchPrefix?: string }): boolean { const { href, activeMatchPrefix } = item; @@ -35,6 +38,14 @@ function isActive(pathname: string, item: { href: string; activeMatchPrefix?: st export function AdminAppSidebar() { const pathname = usePathname(); + const profile = useAdminProfile(); + const visibleNav = useMemo( + () => + adminShellNavItems.filter((item) => + adminNavItemVisible(item, profile?.permissions), + ), + [profile?.permissions], + ); return ( @@ -63,7 +74,7 @@ export function AdminAppSidebar() { 工作台 - {adminShellNavItems.map((item) => { + {visibleNav.map((item) => { const Icon = adminNavIconBySegment[item.segment]; return ( diff --git a/src/components/admin/toolbar.tsx b/src/components/admin/toolbar.tsx index 13611e3..a7e9137 100644 --- a/src/components/admin/toolbar.tsx +++ b/src/components/admin/toolbar.tsx @@ -37,8 +37,6 @@ import { } from "@/stores/admin-session"; import type { AdminProfile } from "@/types/api/admin-auth"; -const ADMIN_ROLE_LABEL = "超级管理员"; - /** 暂未接入通知中心时的占位未读数(与设计稿一致可改为接口数据) */ const NOTIFICATION_PLACEHOLDER_COUNT = 6; @@ -95,6 +93,8 @@ export function ShellToolbar() { adminProfile?.username?.trim() || "管理员"; + const permissionCount = adminProfile?.permissions?.length ?? 0; + function onLogout() { clearSession(); toast.success("已退出登录"); @@ -175,7 +175,9 @@ export function ShellToolbar() { {displayName} - {ADMIN_ROLE_LABEL} + {permissionCount > 0 + ? `${permissionCount} 项功能权限 · 菜单已按角色过滤` + : "重新登录可同步权限与侧栏菜单"} diff --git a/src/lib/admin-nav-visibility.ts b/src/lib/admin-nav-visibility.ts new file mode 100644 index 0000000..4724be2 --- /dev/null +++ b/src/lib/admin-nav-visibility.ts @@ -0,0 +1,17 @@ +import type { AdminNavItem } from "@/modules/_config/admin-nav"; + +/** 已登录且拥有 `requiredAny` 中任一 slug 则显示;未配置 `requiredAny` 则始终显示。 */ +export function adminNavItemVisible( + item: AdminNavItem, + permissionSlugs: readonly string[] | null | undefined, +): boolean { + const req = item.requiredAny; + if (req === undefined || req.length === 0) { + return true; + } + const set = permissionSlugs ?? []; + if (set.length === 0) { + return false; + } + return req.some((slug) => set.includes(slug)); +} diff --git a/src/lib/admin-permissions.ts b/src/lib/admin-permissions.ts new file mode 100644 index 0000000..7e85f8c --- /dev/null +++ b/src/lib/admin-permissions.ts @@ -0,0 +1,11 @@ +/** 当前登录管理员是否拥有 `required` 中任一权限(与 Laravel `prd.*` slug 对齐)。 */ +export function adminHasAnyPermission( + permissionSlugs: readonly string[] | null | undefined, + required: readonly string[], +): boolean { + const set = permissionSlugs ?? []; + if (set.length === 0 || required.length === 0) { + return false; + } + return required.some((slug) => set.includes(slug)); +} diff --git a/src/modules/_config/admin-nav-icons.tsx b/src/modules/_config/admin-nav-icons.tsx index 8ce0578..e34adcb 100644 --- a/src/modules/_config/admin-nav-icons.tsx +++ b/src/modules/_config/admin-nav-icons.tsx @@ -2,9 +2,13 @@ import type { LucideIcon } from "lucide-react"; import { CalendarClock, CircleDollarSign, + FileSpreadsheet, + Headphones, Landmark, LayoutDashboard, LogIn, + Scale, + ScrollText, Settings, ShieldAlert, SlidersHorizontal, @@ -19,6 +23,7 @@ import type { AdminNavItem } from "@/modules/_config/admin-nav"; export const adminNavIconBySegment: Record = { dashboard: LayoutDashboard, + service_desk: Headphones, players: Users, draws: CalendarClock, config: SlidersHorizontal, @@ -27,6 +32,9 @@ export const adminNavIconBySegment: Record risk: ShieldAlert, settlement: Landmark, jackpot: CircleDollarSign, + reports: FileSpreadsheet, + reconcile: Scale, + audit: ScrollText, settings: Settings, }; diff --git a/src/modules/_config/admin-nav.ts b/src/modules/_config/admin-nav.ts index 287ffb6..c860086 100644 --- a/src/modules/_config/admin-nav.ts +++ b/src/modules/_config/admin-nav.ts @@ -1,15 +1,16 @@ /** * 导航与路由的单一事实来源;新增业务模块时先改这里,再增加 `app/admin/(shell)/.../page.tsx`。 + * + * `requiredAny` 与登录接口返回的 `admin.permissions`(Laravel `prd.*`)对齐;缺省表示任意已登录用户可见。 */ export const ADMIN_BASE = "/admin" as const; export type AdminNavItem = { - /** 侧边栏文案 */ label: string; href: string; - /** 对应 `src/modules/` 目录名 */ segment: | "dashboard" + | "service_desk" | "players" | "draws" | "config" @@ -18,25 +19,143 @@ export type AdminNavItem = { | "risk" | "settings" | "settlement" - | "jackpot"; - /** 高亮匹配:默认用 `href`;Jackpot 多子页时传公共前缀如 `/admin/jackpot` */ + | "jackpot" + | "reports" + | "reconcile" + | "audit"; activeMatchPrefix?: string; + /** 拥有任一权限 slug 即显示侧栏项 */ + requiredAny?: readonly string[]; }; export const adminShellNavItems: AdminNavItem[] = [ { segment: "dashboard", label: "总览", href: "/admin" }, - { segment: "players", label: "用户", href: "/admin/players" }, - { segment: "draws", label: "开奖", href: "/admin/draws" }, - { segment: "config", label: "运营配置", href: "/admin/config" }, - { segment: "tickets", label: "注单 / 票务", href: "/admin/tickets" }, - { segment: "wallet", label: "钱包", href: "/admin/wallet" }, - { segment: "risk", label: "风控", href: "/admin/risk" }, - { segment: "settlement", label: "结算", href: "/admin/settlement-batches" }, + { + segment: "service_desk", + label: "客服 / 财务", + href: "/admin/service-desk", + requiredAny: [ + "prd.users.view_cs", + "prd.users.view_finance", + "prd.users.manage", + "prd.wallet_reconcile.view_cs", + "prd.wallet_reconcile.view", + "prd.wallet_reconcile.manage", + "prd.report.finance", + "prd.report.player", + "prd.draw_result.view", + ], + }, + { + segment: "players", + label: "玩家查询", + href: "/admin/players", + requiredAny: [ + "prd.users.manage", + "prd.users.view_finance", + "prd.users.view_cs", + ], + }, + { + segment: "draws", + label: "开奖", + href: "/admin/draws", + requiredAny: ["prd.draw_result.manage", "prd.draw_result.view"], + }, + { + segment: "config", + label: "运营配置", + href: "/admin/config", + requiredAny: [ + "prd.play_switch.manage", + "prd.odds.manage", + "prd.risk_cap.manage", + "prd.risk_cap.view", + "prd.rebate.manage", + "prd.rebate.view", + "prd.jackpot.manage", + "prd.jackpot.view", + ], + }, + { + segment: "tickets", + label: "玩家注单", + href: "/admin/tickets", + requiredAny: [ + "prd.users.view_cs", + "prd.users.manage", + "prd.users.view_finance", + "prd.draw_result.view", + "prd.draw_result.manage", + "prd.payout.view", + "prd.payout.review", + "prd.payout.manage", + "prd.report.player", + ], + }, + { + segment: "wallet", + label: "钱包流水", + href: "/admin/wallet/transactions", + activeMatchPrefix: "/admin/wallet", + requiredAny: [ + "prd.wallet_reconcile.manage", + "prd.wallet_reconcile.view", + "prd.wallet_reconcile.view_cs", + "prd.users.manage", + "prd.users.view_finance", + "prd.users.view_cs", + ], + }, + { + segment: "risk", + label: "风控", + href: "/admin/risk", + requiredAny: ["prd.draw_result.view", "prd.draw_result.manage"], + }, + { + segment: "settlement", + label: "结算", + href: "/admin/settlement-batches", + requiredAny: [ + "prd.payout.manage", + "prd.payout.review", + "prd.payout.view", + ], + }, { segment: "jackpot", label: "Jackpot", href: "/admin/jackpot/pools", activeMatchPrefix: "/admin/jackpot", + requiredAny: ["prd.jackpot.manage", "prd.jackpot.view"], + }, + { + segment: "reports", + label: "报表导出", + href: "/admin/reports", + requiredAny: [ + "prd.report.all", + "prd.report.risk", + "prd.report.finance", + "prd.report.player", + ], + }, + { + segment: "reconcile", + label: "对账", + href: "/admin/reconcile", + requiredAny: [ + "prd.wallet_reconcile.manage", + "prd.wallet_reconcile.view", + "prd.wallet_reconcile.view_cs", + ], + }, + { + segment: "audit", + label: "审计日志", + href: "/admin/audit-logs", + requiredAny: ["prd.audit.all", "prd.audit.self", "prd.audit.finance"], }, { segment: "settings", label: "系统设置", href: "/admin/settings" }, ]; diff --git a/src/modules/audit/audit-logs-console.tsx b/src/modules/audit/audit-logs-console.tsx new file mode 100644 index 0000000..0a1a7a9 --- /dev/null +++ b/src/modules/audit/audit-logs-console.tsx @@ -0,0 +1,190 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; + +import { getAdminAuditLogs } from "@/api/admin-audit"; +import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter"; +import { LotteryApiBizError } from "@/types/api/errors"; +import type { AdminAuditLogListData } from "@/types/api/admin-audit"; + +export function AuditLogsConsole(): React.ReactElement { + const formatTs = useAdminDateTimeFormatter(); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [err, setErr] = useState(null); + const [page, setPage] = useState(1); + const [perPage, setPerPage] = useState(25); + const [moduleCode, setModuleCode] = useState(""); + const [actionCode, setActionCode] = useState(""); + const [operatorType, setOperatorType] = useState(""); + const [appliedModule, setAppliedModule] = useState(""); + const [appliedAction, setAppliedAction] = useState(""); + const [appliedOpType, setAppliedOpType] = useState(""); + + const load = useCallback(async () => { + setLoading(true); + setErr(null); + try { + const d = await getAdminAuditLogs({ + page, + per_page: perPage, + module_code: appliedModule.trim() || undefined, + action_code: appliedAction.trim() || undefined, + operator_type: appliedOpType.trim() || undefined, + }); + setData(d); + } catch (e) { + setErr(e instanceof LotteryApiBizError ? e.message : "加载失败"); + setData(null); + } finally { + setLoading(false); + } + }, [page, perPage, appliedModule, appliedAction, appliedOpType]); + + useEffect(() => { + queueMicrotask(() => { + void load(); + }); + }, [load]); + + const meta = data?.meta; + + return ( + + +
+ 审计日志 + + 查询参数与 GET /api/v1/admin/audit-logs{" "} + 一致;数据范围受 RBAC 约束。 + +
+ +
+ +
+
+ + setModuleCode(e.target.value)} + placeholder="精确匹配" + /> +
+
+ + setActionCode(e.target.value)} + placeholder="精确匹配" + /> +
+
+ + setOperatorType(e.target.value)} + placeholder="如 admin / system" + /> +
+
+ +
+
+ + {err ?

{err}

: null} + {loading && !data ? ( +

加载中…

+ ) : null} + + {data ? ( + <> +
+ + + + ID + 操作者 + 模块 + 动作 + 目标 + 时间 + + + + {data.items.length === 0 ? ( + + + 无数据 + + + ) : ( + data.items.map((row) => ( + + {row.id} + + {row.operator_type}:{row.operator_id} + + {row.module_code} + {row.action_code} + + {row.target_type ?? "—"} {row.target_id ?? ""} + + + {formatTs(row.created_at)} + + + )) + )} + +
+
+ {meta ? ( + { + setPerPage(n); + setPage(1); + }} + onPageChange={setPage} + /> + ) : null} + + ) : null} +
+
+ ); +} diff --git a/src/modules/audit/meta.ts b/src/modules/audit/meta.ts new file mode 100644 index 0000000..d08621e --- /dev/null +++ b/src/modules/audit/meta.ts @@ -0,0 +1,5 @@ +export const auditModuleMeta = { + segment: "audit", + title: "审计日志", + description: "运营留痕查询(权限范围由后端按角色过滤)。", +} as const; diff --git a/src/modules/draws/draw-detail-console.tsx b/src/modules/draws/draw-detail-console.tsx index ed0057a..799cc4f 100644 --- a/src/modules/draws/draw-detail-console.tsx +++ b/src/modules/draws/draw-detail-console.tsx @@ -1,8 +1,10 @@ "use client"; +import Link from "next/link"; import { useCallback, useEffect, useState } from "react"; import { getAdminDraw } from "@/api/admin-draws"; +import { buttonVariants } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Label } from "@/components/ui/label"; import { Separator } from "@/components/ui/separator"; @@ -10,6 +12,8 @@ import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter" import { LotteryApiBizError } from "@/types/api/errors"; import type { AdminDrawShowData } from "@/types/api/admin-draws"; +import { cn } from "@/lib/utils"; + import { DrawStatusBadge } from "./draw-status-badge"; function Field({ label, children }: { label: string; children: React.ReactNode }) { @@ -101,14 +105,22 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) { 批次统计 用于跳转「开奖结果 / 审核」页前的概览。 - - 总批次:{data.result_batch_counts.total} - - 待审核:{data.result_batch_counts.pending_review} - - - 已发布:{data.result_batch_counts.published} - + +
+ 总批次:{data.result_batch_counts.total} + + 待审核:{data.result_batch_counts.pending_review} + + + 已发布:{data.result_batch_counts.published} + +
+ + 期号收支(客服/财务) +
diff --git a/src/modules/draws/draw-finance-console.tsx b/src/modules/draws/draw-finance-console.tsx new file mode 100644 index 0000000..b802ba9 --- /dev/null +++ b/src/modules/draws/draw-finance-console.tsx @@ -0,0 +1,172 @@ +"use client"; + +import Link from "next/link"; +import { useCallback, useEffect, useState } from "react"; + +import { getAdminDrawFinanceSummary } from "@/api/admin-draws"; +import { Button, buttonVariants } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { cn } from "@/lib/utils"; +import { LotteryApiBizError } from "@/types/api/errors"; +import type { AdminDrawFinanceSummaryData } from "@/types/api/admin-draw-finance"; + +/** PRD §15.4:单期投注/派彩与结算批次(`GET …/draws/{id}/finance-summary`) */ +export function DrawFinanceConsole({ drawId }: { drawId: string }): React.ReactElement { + const idNum = Number(drawId); + const [data, setData] = useState(null); + const [err, setErr] = useState(null); + const [loading, setLoading] = useState(true); + + const load = useCallback(async () => { + if (!Number.isFinite(idNum) || idNum < 1) { + setErr("无效的期号 ID"); + setLoading(false); + return; + } + setLoading(true); + setErr(null); + try { + setData(await getAdminDrawFinanceSummary(idNum)); + } catch (e) { + setErr(e instanceof LotteryApiBizError ? e.message : "加载失败"); + setData(null); + } finally { + setLoading(false); + } + }, [idNum]); + + useEffect(() => { + queueMicrotask(() => { + void load(); + }); + }, [load]); + + if (loading && !data) { + return

加载中…

; + } + + if (err || !data) { + return

{err ?? "无数据"}

; + } + + const cur = data.currency_code ?? "—"; + + return ( +
+ + + 期号收支概览 + + 币种 {cur};金额为最小货币单位。毛损益 = 实扣投注 −(中奖派彩 + Jackpot),不含回水细项。 + + + +
+ 期号 +

{data.draw_no}

+
+
+ 状态 +

{data.draw_status}

+
+
+ 订单数 / 注项数 +

+ {data.order_count} / {data.ticket_item_count} +

+
+
+ 当期实扣投注 +

{data.total_bet_minor}

+
+
+ 当期派彩合计 +

{data.total_payout_minor}

+
+
+ 近似毛损益 +

= 0 ? "text-emerald-600" : "text-destructive", + )} + > + {data.approx_house_gross_minor} +

+
+
+
+ +
+ + + 结算批次列表(按期号筛选) + +
+ + + + 本关联期结算批次 + 与 `settlement_batches` 对照;明细见结算模块。 + + + {data.settlement_batches.length === 0 ? ( +

暂无结算批次记录。

+ ) : ( +
+ + + + ID + 状态 + 票数 + 中奖数 + 派彩 + Jackpot + 完成时间 + + + + {data.settlement_batches.map((b) => ( + + {b.id} + {b.status} + + {b.total_ticket_count} + + + {b.total_win_count} + + + {b.total_payout_amount} + + + {b.total_jackpot_payout_amount} + + + {b.finished_at ?? "—"} + + + ))} + +
+
+ )} +
+
+
+ ); +} diff --git a/src/modules/draws/draw-prd.ts b/src/modules/draws/draw-prd.ts new file mode 100644 index 0000000..13794cf --- /dev/null +++ b/src/modules/draws/draw-prd.ts @@ -0,0 +1,17 @@ +/** + * 后台开奖域与 PRD 阶段 3 · §11.4 / §11.7 验收对照(路径以 `API_V1_PREFIX=/api/v1` 为前缀)。 + * + * - 期号列表:`GET …/admin/draws` + * - 期号详情 / 状态:`GET …/admin/draws/{draw}` + * - 开奖结果批次(含待审核、已发布):`GET …/admin/draws/{draw}/result-batches` + * - 发布:`POST …/admin/draws/{draw}/result-batches/{batch}/publish` + */ +export const DRAW_ADMIN_API_PRD_LINES = [ + "GET /api/v1/admin/draws", + "GET /api/v1/admin/draws/{draw}", + "GET /api/v1/admin/draws/{draw}/result-batches", + "POST /api/v1/admin/draws/{draw}/result-batches/{batch}/publish", +] as const; + +/** 具备其一即可执行发布、进入发布页(与 Laravel `prd.draw_result.manage` 一致) */ +export const PRD_DRAW_RESULT_MANAGE = "prd.draw_result.manage" as const; diff --git a/src/modules/draws/draw-publish-console.tsx b/src/modules/draws/draw-publish-console.tsx index a7abff3..52c61d8 100644 --- a/src/modules/draws/draw-publish-console.tsx +++ b/src/modules/draws/draw-publish-console.tsx @@ -17,10 +17,18 @@ import { TableRow, } from "@/components/ui/table"; import { cn } from "@/lib/utils"; +import { adminHasAnyPermission } from "@/lib/admin-permissions"; +import { useAdminProfile } from "@/stores/admin-session"; import { LotteryApiBizError } from "@/types/api/errors"; import type { AdminDrawBatchRow, AdminDrawBatchesData } from "@/types/api/admin-draws"; +import { PRD_DRAW_RESULT_MANAGE } from "./draw-prd"; + export function DrawPublishConsole({ drawId, batchId }: { drawId: string; batchId: string }) { + const profile = useAdminProfile(); + const canManageDraw = adminHasAnyPermission(profile?.permissions, [ + PRD_DRAW_RESULT_MANAGE, + ]); const idNum = Number(drawId); const batchNum = Number(batchId); const [data, setData] = useState(null); @@ -90,13 +98,14 @@ export function DrawPublishConsole({ drawId, batchId }: { drawId: string; batchI ); } - const canPublish = batch.status === "pending_review"; + const canPublish = + canManageDraw && batch.status === "pending_review"; return (
- ← 审核列表 + ← 审核队列
@@ -107,22 +116,36 @@ export function DrawPublishConsole({ drawId, batchId }: { drawId: string; batchI 期号 {data.draw_no} · 批次 v {batch.result_version}{" "} {batch.status} + · 接口{" "} + + POST …/result-batches/{batch.id}/publish + - {!canPublish ? ( + {!canManageDraw ? ( + + 无发布权限 + + 需要 {PRD_DRAW_RESULT_MANAGE}{" "} + 方可调用发布接口;当前账号仅可查看号码表。 + + + ) : null} + {!canPublish && canManageDraw ? ( 不可发布 仅 pending_review 可执行发布接口;当前为「{batch.status}」。已发布时请从「开奖结果」页核对。 - ) : ( + ) : null} + {canPublish ? ( 请核对以下号码后再发布 发布后无期将进入冷静期并按配置写入结果版本。 - )} + ) : null}
diff --git a/src/modules/draws/draw-results-console.tsx b/src/modules/draws/draw-results-console.tsx index b19a11d..96c55b1 100644 --- a/src/modules/draws/draw-results-console.tsx +++ b/src/modules/draws/draw-results-console.tsx @@ -15,12 +15,19 @@ import { TableRow, } from "@/components/ui/table"; import { cn } from "@/lib/utils"; +import { adminHasAnyPermission } from "@/lib/admin-permissions"; +import { useAdminProfile } from "@/stores/admin-session"; import { LotteryApiBizError } from "@/types/api/errors"; import type { AdminDrawBatchRow, AdminDrawBatchesData } from "@/types/api/admin-draws"; +import { PRD_DRAW_RESULT_MANAGE } from "./draw-prd"; import { DrawStatusBadge } from "./draw-status-badge"; export function DrawResultsConsole({ drawId }: { drawId: string }) { + const profile = useAdminProfile(); + const canManageDraw = adminHasAnyPermission(profile?.permissions, [ + PRD_DRAW_RESULT_MANAGE, + ]); const idNum = Number(drawId); const [data, setData] = useState(null); const [error, setError] = useState(null); @@ -68,14 +75,14 @@ export function DrawResultsConsole({ drawId }: { drawId: string }) {

开奖结果

期号 {data.draw_no} · 当期库内状态 · - 以下为已发布批次号码表;未发布请在「审核 / 发布」中处理。 + 以下为已发布批次号码表;未发布请在「审核与发布」中处理。

- 去审核 + {canManageDraw ? "去审核 / 发布" : "查看审核队列"} diff --git a/src/modules/draws/draw-review-console.tsx b/src/modules/draws/draw-review-console.tsx index f09373e..0c0fc26 100644 --- a/src/modules/draws/draw-review-console.tsx +++ b/src/modules/draws/draw-review-console.tsx @@ -15,12 +15,19 @@ import { TableRow, } from "@/components/ui/table"; import { cn } from "@/lib/utils"; +import { adminHasAnyPermission } from "@/lib/admin-permissions"; +import { useAdminProfile } from "@/stores/admin-session"; import { LotteryApiBizError } from "@/types/api/errors"; import type { AdminDrawBatchesData } from "@/types/api/admin-draws"; +import { PRD_DRAW_RESULT_MANAGE } from "./draw-prd"; import { DrawStatusBadge } from "./draw-status-badge"; export function DrawReviewConsole({ drawId }: { drawId: string }) { + const profile = useAdminProfile(); + const canManageDraw = adminHasAnyPermission(profile?.permissions, [ + PRD_DRAW_RESULT_MANAGE, + ]); const idNum = Number(drawId); const [data, setData] = useState(null); const [error, setError] = useState(null); @@ -68,8 +75,11 @@ export function DrawReviewConsole({ drawId }: { drawId: string }) { 审核 - 待审核 RNG 批次会出现在下表;点「审核与发布」进入发布页核对 23 组号码后提交。 - 当前 DB 状态:。 + 待审核 RNG 批次会出现在下表;具备{" "} + {PRD_DRAW_RESULT_MANAGE}{" "} + 时可进入发布页,调用{" "} + POST …/result-batches/…/publish + 。当前 DB 状态: @@ -94,12 +104,16 @@ export function DrawReviewConsole({ drawId }: { drawId: string }) { v{b.result_version} {b.items.length} - - 审核与发布 - + {canManageDraw ? ( + + 核对并发布 + + ) : ( + 无发布权限 + )} ))} diff --git a/src/modules/draws/draw-subnav.tsx b/src/modules/draws/draw-subnav.tsx index 1c102ae..fd1ad3b 100644 --- a/src/modules/draws/draw-subnav.tsx +++ b/src/modules/draws/draw-subnav.tsx @@ -7,11 +7,22 @@ import { buttonVariants } from "@/components/ui/button"; import { cn } from "@/lib/utils"; const segments = [ - { suffix: "", key: "status", label: "当前状态" }, + { suffix: "", key: "status", label: "期号状态" }, { suffix: "/results", key: "results", label: "开奖结果" }, - { suffix: "/review", key: "review", label: "审核 / 发布" }, + { suffix: "/finance", key: "finance", label: "期号收支" }, + { suffix: "/review", key: "review", label: "审核与发布" }, ] as const; +function isReviewTabActive(pathname: string, base: string): boolean { + const reviewPrefix = `${base}/review`; + const publishPrefix = `${base}/publish`; + return ( + pathname === reviewPrefix || + pathname.startsWith(`${reviewPrefix}/`) || + pathname.startsWith(`${publishPrefix}/`) + ); +} + export function DrawSubnav({ drawId }: { drawId: string }) { const pathname = usePathname(); const base = `/admin/draws/${drawId}`; @@ -24,8 +35,8 @@ export function DrawSubnav({ drawId }: { drawId: string }) { suffix === "" ? pathname === base || pathname === `${base}/` : suffix === "/review" - ? pathname === href || pathname?.startsWith(`${href}/`) - : pathname === href; + ? isReviewTabActive(pathname, base) + : pathname === href || pathname.startsWith(`${href}/`); return ( 期号列表 - 按开奖时间倒序;点期号查看状态、开奖结果与审核。 + + + 按开奖时间倒序;点「详情」进入期号子页:状态、开奖结果、审核与发布(阶段 3 · §11.4)。 + + + {DRAW_ADMIN_API_PRD_LINES.join(" · ")} + + {/* Grid:桌面端标签一行 / 控件一行,避免 flex+items-end 与各列实际高度不一致;移动端单列自上而下 */} diff --git a/src/modules/draws/meta.ts b/src/modules/draws/meta.ts index f535cc4..7564a8b 100644 --- a/src/modules/draws/meta.ts +++ b/src/modules/draws/meta.ts @@ -1,5 +1,6 @@ export const drawsModuleMeta = { segment: "draws", title: "开奖", - description: "期号列表、状态、开奖结果、审核与发布。", + description: + "PRD 阶段3 §11.4:期号列表 / 状态 / 开奖结果 / 审核队列 / 发布(POST …/result-batches/…/publish);路由 `/admin/draws/[id]/publish/[batchId]` 与接口动词对齐。", } as const; diff --git a/src/modules/players/meta.ts b/src/modules/players/meta.ts index 67b6d41..0ca05aa 100644 --- a/src/modules/players/meta.ts +++ b/src/modules/players/meta.ts @@ -1,5 +1,5 @@ export const playersModuleMeta = { segment: "players", - title: "用户", - description: "玩家列表、冻结/解冻、账号资料(路由已就绪,待接管理端 API)。", + title: "玩家查询", + description: "按玩家 ID 查钱包等(PRD 15.3);列表/冻结待管理端用户 API。", } as const; diff --git a/src/modules/players/players-console.tsx b/src/modules/players/players-console.tsx new file mode 100644 index 0000000..4d7be8e --- /dev/null +++ b/src/modules/players/players-console.tsx @@ -0,0 +1,26 @@ +"use client"; + +import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; + +import { PlayerWalletPanel } from "@/modules/wallet/wallet-console"; + +/** PRD 15.3:玩家查询(当前对接 `GET .../players/{id}/wallets`)。 */ +export function PlayersConsole(): React.ReactElement { + return ( +
+ + + 玩家查询 + + 按本地玩家主键查询钱包余额;需具备{" "} + + prd.users.manage | view_finance | view_cs + {" "} + 之一。完整列表/冻结等能力待管理端用户 API 就绪后扩展。 + + + + +
+ ); +} diff --git a/src/modules/reconcile/meta.ts b/src/modules/reconcile/meta.ts new file mode 100644 index 0000000..d620991 --- /dev/null +++ b/src/modules/reconcile/meta.ts @@ -0,0 +1,5 @@ +export const reconcileModuleMeta = { + segment: "reconcile", + title: "对账", + description: "钱包对账任务列表、创建与明细(PRD §8 钱包对账)。", +} as const; diff --git a/src/modules/reconcile/reconcile-console.tsx b/src/modules/reconcile/reconcile-console.tsx new file mode 100644 index 0000000..f40a951 --- /dev/null +++ b/src/modules/reconcile/reconcile-console.tsx @@ -0,0 +1,354 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { toast } from "sonner"; + +import { + getAdminReconcileJobItems, + getAdminReconcileJobs, + postAdminReconcileJob, +} from "@/api/admin-reconcile"; +import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Textarea } from "@/components/ui/textarea"; +import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter"; +import { adminHasAnyPermission } from "@/lib/admin-permissions"; +import { useAdminProfile } from "@/stores/admin-session"; +import { LotteryApiBizError } from "@/types/api/errors"; +import type { + AdminReconcileItemsData, + AdminReconcileJobListData, +} from "@/types/api/admin-reconcile"; + +const MANAGE = ["prd.wallet_reconcile.manage"] as const; + +export function ReconcileConsole(): React.ReactElement { + const profile = useAdminProfile(); + const canCreate = adminHasAnyPermission(profile?.permissions, [...MANAGE]); + const formatTs = useAdminDateTimeFormatter(); + + const [jobs, setJobs] = useState(null); + const [jobsLoading, setJobsLoading] = useState(true); + const [jobsErr, setJobsErr] = useState(null); + const [page, setPage] = useState(1); + const [perPage, setPerPage] = useState(25); + + const [selectedId, setSelectedId] = useState(null); + const [items, setItems] = useState(null); + const [itemsPage, setItemsPage] = useState(1); + const [itemsPerPage, setItemsPerPage] = useState(50); + const [itemsLoading, setItemsLoading] = useState(false); + + const [reconcileType, setReconcileType] = useState("wallet_transfer"); + const [periodStart, setPeriodStart] = useState(""); + const [periodEnd, setPeriodEnd] = useState(""); + const [itemsJson, setItemsJson] = useState("[]"); + const [submitting, setSubmitting] = useState(false); + + const loadJobs = useCallback(async () => { + setJobsLoading(true); + setJobsErr(null); + try { + const d = await getAdminReconcileJobs({ page, per_page: perPage }); + setJobs(d); + } catch (e) { + setJobsErr(e instanceof LotteryApiBizError ? e.message : "加载失败"); + setJobs(null); + } finally { + setJobsLoading(false); + } + }, [page, perPage]); + + useEffect(() => { + queueMicrotask(() => { + void loadJobs(); + }); + }, [loadJobs]); + + const loadItems = useCallback(async () => { + if (selectedId == null) { + setItems(null); + return; + } + setItemsLoading(true); + try { + const d = await getAdminReconcileJobItems(selectedId, { + page: itemsPage, + per_page: itemsPerPage, + }); + setItems(d); + } catch (e) { + toast.error(e instanceof LotteryApiBizError ? e.message : "加载明细失败"); + setItems(null); + } finally { + setItemsLoading(false); + } + }, [selectedId, itemsPage, itemsPerPage]); + + useEffect(() => { + queueMicrotask(() => { + void loadItems(); + }); + }, [loadItems]); + + async function onCreate(): Promise { + let itemsPayload: Parameters[0]["items"]; + const trimmed = itemsJson.trim(); + if (trimmed !== "" && trimmed !== "[]") { + try { + itemsPayload = JSON.parse(trimmed) as NonNullable< + Parameters[0]["items"] + >; + } catch { + toast.error("items JSON 无法解析"); + return; + } + } + setSubmitting(true); + try { + await postAdminReconcileJob({ + reconcile_type: reconcileType, + period_start: periodStart.trim() || undefined, + period_end: periodEnd.trim() || undefined, + items: itemsPayload, + }); + toast.success("已创建对账任务"); + setPage(1); + await loadJobs(); + } catch (e) { + toast.error(e instanceof LotteryApiBizError ? e.message : "创建失败"); + } finally { + setSubmitting(false); + } + } + + const jm = jobs?.meta; + const im = items?.meta; + + return ( +
+ {canCreate ? ( + + + 新建对账任务 + + 需要权限 prd.wallet_reconcile.manage + 。周期与明细结构与接口校验一致。 + + + +
+ + setReconcileType(e.target.value)} + /> +
+
+
+ + setPeriodStart(e.target.value)} + /> +
+
+ + setPeriodEnd(e.target.value)} + /> +
+
+
+ +