feat: 添加财务摘要接口,更新管理员抽奖模块和导航,优化权限管理逻辑

This commit is contained in:
2026-05-11 16:21:22 +08:00
parent f083b28fc6
commit b539bf0660
57 changed files with 2134 additions and 108 deletions

19
src/api/admin-audit.ts Normal file
View File

@@ -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<AdminAuditLogListData> {
return adminRequest.get<AdminAuditLogListData>(`${A}/audit-logs`, {
params,
});
}

View File

@@ -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<AdminDr
return adminRequest.get<AdminDrawBatchesData>(`${A}/draws/${drawId}/result-batches`);
}
/** PRD §15.4:单期投注/派彩与结算批次摘要 */
export async function getAdminDrawFinanceSummary(
drawId: number,
): Promise<AdminDrawFinanceSummaryData> {
return adminRequest.get<AdminDrawFinanceSummaryData>(
`${A}/draws/${drawId}/finance-summary`,
);
}
export async function postAdminPublishResultBatch(
drawId: number,
batchId: number,

View File

@@ -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<AdminPlayerTicketItemsData> {
return adminRequest.get<AdminPlayerTicketItemsData>(
`${A}/players/${playerId}/ticket-items`,
{ params },
);
}

View File

@@ -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<AdminReconcileJobListData> {
return adminRequest.get<AdminReconcileJobListData>(`${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<AdminReconcileJobCreateResponse> {
return adminRequest.post<AdminReconcileJobCreateResponse>(
`${A}/reconcile-jobs`,
body,
);
}
export async function getAdminReconcileJobItems(
jobId: number,
params?: { page?: number; per_page?: number },
): Promise<AdminReconcileItemsData> {
return adminRequest.get<AdminReconcileItemsData>(
`${A}/reconcile-jobs/${jobId}/items`,
{ params },
);
}

30
src/api/admin-reports.ts Normal file
View File

@@ -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<AdminReportJobListData> {
return adminRequest.get<AdminReportJobListData>(`${A}/report-jobs`, {
params,
});
}
export async function postAdminReportJob(body: {
report_type: string;
export_format?: "csv" | "xlsx";
filter_json?: Record<string, unknown> | null;
}): Promise<AdminReportJobCreateResponse> {
return adminRequest.post<AdminReportJobCreateResponse>(
`${A}/report-jobs`,
body,
);
}

View File

@@ -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,

View File

@@ -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 (
<ModuleScaffold className="w-full max-w-none">
<AuditLogsConsole />
</ModuleScaffold>
);
}

View File

@@ -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 <DrawFinanceConsole drawId={drawId} />;
}

View File

@@ -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 <DrawPublishConsole drawId={drawId} batchId={batchId} />;
}

View File

@@ -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 <DrawPublishConsole drawId={drawId} batchId={batchId} />;
redirect(`/admin/draws/${drawId}/publish/${batchId}`);
}

View File

@@ -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 (
<ModuleScaffold>
<p className="text-sm text-zinc-500 dark:text-zinc-400">
{" "}
<code className="rounded bg-zinc-100 px-1 py-0.5 font-mono text-xs dark:bg-zinc-800">
src/modules/players
</code>{" "}
</p>
<ModuleScaffold className="w-full max-w-none">
<PlayersConsole />
</ModuleScaffold>
);
}

View File

@@ -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 (
<ModuleScaffold className="w-full max-w-none">
<ReconcileConsole />
</ModuleScaffold>
);
}

View File

@@ -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 (
<ModuleScaffold className="w-full max-w-none">
<ReportsConsole />
</ModuleScaffold>
);
}

View File

@@ -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 (
<ModuleScaffold className="w-full max-w-none">
<div className="mb-8 space-y-2">
<h1 className="text-2xl font-semibold tracking-tight">{serviceDeskModuleMeta.title}</h1>
<p className="text-muted-foreground max-w-3xl text-sm leading-relaxed">
{serviceDeskModuleMeta.description}
</p>
</div>
<ServiceDeskConsole />
</ModuleScaffold>
);
}

View File

@@ -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 (
<ModuleScaffold>
<p className="text-sm text-zinc-500 dark:text-zinc-400">
{" "}
<code className="rounded bg-zinc-100 px-1 py-0.5 font-mono text-xs dark:bg-zinc-800">
src/modules/tickets
</code>{" "}
</p>
<ModuleScaffold className="w-full max-w-none">
<PlayerTicketsConsole />
</ModuleScaffold>
);
}

View File

@@ -0,0 +1,12 @@
import type { ReactNode } from "react";
import { WalletSubnav } from "@/modules/wallet/wallet-subnav";
export default function AdminWalletLayout({ children }: { children: ReactNode }) {
return (
<div className="w-full max-w-none">
<WalletSubnav />
{children}
</div>
);
}

View File

@@ -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 (
<ModuleScaffold className="w-full max-w-none">
<WalletConsole />
</ModuleScaffold>
);
export default function AdminWalletIndexPage() {
redirect("/admin/wallet/transactions");
}

View File

@@ -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 (
<ModuleScaffold className="w-full max-w-none">
<PlayerWalletPanel />
</ModuleScaffold>
);
}

View File

@@ -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 (
<ModuleScaffold className="w-full max-w-none">
<WalletTxnsPanel />
</ModuleScaffold>
);
}

View File

@@ -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 (
<ModuleScaffold className="w-full max-w-none">
<TransferOrdersPanel />
</ModuleScaffold>
);
}

View File

@@ -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 (
<Sidebar collapsible="icon" variant="inset">
@@ -63,7 +74,7 @@ export function AdminAppSidebar() {
<SidebarGroupLabel></SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{adminShellNavItems.map((item) => {
{visibleNav.map((item) => {
const Icon = adminNavIconBySegment[item.segment];
return (
<SidebarMenuItem key={item.segment}>

View File

@@ -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}
</span>
<span className="truncate text-xs text-muted-foreground">
{ADMIN_ROLE_LABEL}
{permissionCount > 0
? `${permissionCount} 项功能权限 · 菜单已按角色过滤`
: "重新登录可同步权限与侧栏菜单"}
</span>
</span>
<ChevronDownIcon className="hidden size-4 shrink-0 text-muted-foreground sm:block" />

View File

@@ -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));
}

View File

@@ -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));
}

View File

@@ -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<AdminNavItem["segment"], LucideIcon> =
{
dashboard: LayoutDashboard,
service_desk: Headphones,
players: Users,
draws: CalendarClock,
config: SlidersHorizontal,
@@ -27,6 +32,9 @@ export const adminNavIconBySegment: Record<AdminNavItem["segment"], LucideIcon>
risk: ShieldAlert,
settlement: Landmark,
jackpot: CircleDollarSign,
reports: FileSpreadsheet,
reconcile: Scale,
audit: ScrollText,
settings: Settings,
};

View File

@@ -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>` 目录名 */
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" },
];

View File

@@ -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<AdminAuditLogListData | null>(null);
const [loading, setLoading] = useState(true);
const [err, setErr] = useState<string | null>(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 (
<Card className="w-full max-w-none">
<CardHeader className="flex flex-row flex-wrap items-end justify-between gap-4">
<div>
<CardTitle></CardTitle>
<CardDescription>
<code className="rounded bg-muted px-1">GET /api/v1/admin/audit-logs</code>{" "}
RBAC
</CardDescription>
</div>
<Button type="button" variant="secondary" size="sm" onClick={() => void load()}>
</Button>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div className="grid gap-1.5">
<Label htmlFor="aud-mod">module_code</Label>
<Input
id="aud-mod"
value={moduleCode}
onChange={(e) => setModuleCode(e.target.value)}
placeholder="精确匹配"
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="aud-act">action_code</Label>
<Input
id="aud-act"
value={actionCode}
onChange={(e) => setActionCode(e.target.value)}
placeholder="精确匹配"
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="aud-op">operator_type</Label>
<Input
id="aud-op"
value={operatorType}
onChange={(e) => setOperatorType(e.target.value)}
placeholder="如 admin / system"
/>
</div>
<div className="flex items-end">
<Button
type="button"
onClick={() => {
setAppliedModule(moduleCode);
setAppliedAction(actionCode);
setAppliedOpType(operatorType);
setPage(1);
}}
>
</Button>
</div>
</div>
{err ? <p className="text-sm text-red-600 dark:text-red-400">{err}</p> : null}
{loading && !data ? (
<p className="text-muted-foreground text-sm"></p>
) : null}
{data ? (
<>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-20">ID</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.items.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-muted-foreground">
</TableCell>
</TableRow>
) : (
data.items.map((row) => (
<TableRow key={row.id}>
<TableCell>{row.id}</TableCell>
<TableCell className="text-xs">
{row.operator_type}:{row.operator_id}
</TableCell>
<TableCell className="font-mono text-xs">{row.module_code}</TableCell>
<TableCell className="font-mono text-xs">{row.action_code}</TableCell>
<TableCell className="text-xs text-muted-foreground">
{row.target_type ?? "—"} {row.target_id ?? ""}
</TableCell>
<TableCell className="whitespace-nowrap font-mono text-[11px] text-muted-foreground">
{formatTs(row.created_at)}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{meta ? (
<AdminListPaginationFooter
selectId="audit-logs-per-page"
total={meta.total}
page={meta.current_page}
lastPage={Math.max(1, meta.last_page)}
perPage={meta.per_page}
loading={loading}
onPerPageChange={(n) => {
setPerPage(n);
setPage(1);
}}
onPageChange={setPage}
/>
) : null}
</>
) : null}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,5 @@
export const auditModuleMeta = {
segment: "audit",
title: "审计日志",
description: "运营留痕查询(权限范围由后端按角色过滤)。",
} as const;

View File

@@ -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 }) {
<CardTitle className="text-base"></CardTitle>
<CardDescription> / </CardDescription>
</CardHeader>
<CardContent className="flex flex-wrap gap-6 text-sm">
<span>{data.result_batch_counts.total}</span>
<span className="text-amber-600 dark:text-amber-400">
{data.result_batch_counts.pending_review}
</span>
<span className="text-emerald-600 dark:text-emerald-400">
{data.result_batch_counts.published}
</span>
<CardContent className="flex flex-col gap-4">
<div className="flex flex-wrap gap-6 text-sm">
<span>{data.result_batch_counts.total}</span>
<span className="text-amber-600 dark:text-amber-400">
{data.result_batch_counts.pending_review}
</span>
<span className="text-emerald-600 dark:text-emerald-400">
{data.result_batch_counts.published}
</span>
</div>
<Link
href={`/admin/draws/${drawId}/finance`}
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "w-fit")}
>
/
</Link>
</CardContent>
</Card>
</div>

View File

@@ -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<AdminDrawFinanceSummaryData | null>(null);
const [err, setErr] = useState<string | null>(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 <p className="text-muted-foreground text-sm"></p>;
}
if (err || !data) {
return <p className="text-destructive text-sm">{err ?? "无数据"}</p>;
}
const cur = data.currency_code ?? "—";
return (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="text-lg"></CardTitle>
<CardDescription>
{cur} = + Jackpot
</CardDescription>
</CardHeader>
<CardContent className="grid gap-3 text-sm sm:grid-cols-2 lg:grid-cols-3">
<div>
<span className="text-muted-foreground"></span>
<p className="font-mono font-semibold">{data.draw_no}</p>
</div>
<div>
<span className="text-muted-foreground"></span>
<p>{data.draw_status}</p>
</div>
<div>
<span className="text-muted-foreground"> / </span>
<p className="tabular-nums">
{data.order_count} / {data.ticket_item_count}
</p>
</div>
<div>
<span className="text-muted-foreground"></span>
<p className="tabular-nums font-medium">{data.total_bet_minor}</p>
</div>
<div>
<span className="text-muted-foreground"></span>
<p className="tabular-nums font-medium">{data.total_payout_minor}</p>
</div>
<div>
<span className="text-muted-foreground"></span>
<p
className={cn(
"tabular-nums font-semibold",
data.approx_house_gross_minor >= 0 ? "text-emerald-600" : "text-destructive",
)}
>
{data.approx_house_gross_minor}
</p>
</div>
</CardContent>
</Card>
<div className="flex flex-wrap gap-2">
<Button type="button" variant="secondary" size="sm" onClick={() => void load()}>
</Button>
<Link
href="/admin/settlement-batches"
className={cn(buttonVariants({ variant: "outline", size: "sm" }))}
>
</Link>
</div>
<Card>
<CardHeader>
<CardTitle className="text-base"></CardTitle>
<CardDescription> `settlement_batches` </CardDescription>
</CardHeader>
<CardContent>
{data.settlement_batches.length === 0 ? (
<p className="text-muted-foreground text-sm"></p>
) : (
<div className="overflow-x-auto rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-20">ID</TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right">Jackpot</TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.settlement_batches.map((b) => (
<TableRow key={b.id}>
<TableCell className="font-mono text-xs">{b.id}</TableCell>
<TableCell className="text-xs">{b.status}</TableCell>
<TableCell className="text-right tabular-nums text-xs">
{b.total_ticket_count}
</TableCell>
<TableCell className="text-right tabular-nums text-xs">
{b.total_win_count}
</TableCell>
<TableCell className="text-right tabular-nums text-xs">
{b.total_payout_amount}
</TableCell>
<TableCell className="text-right tabular-nums text-xs">
{b.total_jackpot_payout_amount}
</TableCell>
<TableCell className="font-mono text-[11px] text-muted-foreground">
{b.finished_at ?? "—"}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -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;

View File

@@ -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<AdminDrawBatchesData | null>(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 (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<Link href={`/admin/draws/${drawId}/review`} className={buttonVariants({ variant: "ghost", size: "sm" })}>
</Link>
</div>
@@ -107,22 +116,36 @@ export function DrawPublishConsole({ drawId, batchId }: { drawId: string; batchI
<span className="font-mono font-medium">{data.draw_no}</span> · v
{batch.result_version}{" "}
<span className="rounded bg-muted px-1 py-0.5 font-mono text-xs">{batch.status}</span>
· {" "}
<code className="rounded bg-muted px-1 text-xs">
POST /result-batches/{batch.id}/publish
</code>
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{!canPublish ? (
{!canManageDraw ? (
<Alert variant="destructive">
<AlertTitle></AlertTitle>
<AlertDescription>
<code className="rounded bg-background/80 px-1">{PRD_DRAW_RESULT_MANAGE}</code>{" "}
</AlertDescription>
</Alert>
) : null}
{!canPublish && canManageDraw ? (
<Alert>
<AlertTitle></AlertTitle>
<AlertDescription>
pending_review {batch.status}
</AlertDescription>
</Alert>
) : (
) : null}
{canPublish ? (
<Alert>
<AlertTitle></AlertTitle>
<AlertDescription></AlertDescription>
</Alert>
)}
) : null}
<div className="overflow-x-auto rounded-lg border border-border">
<Table>

View File

@@ -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<AdminDrawBatchesData | null>(null);
const [error, setError] = useState<string | null>(null);
@@ -68,14 +75,14 @@ export function DrawResultsConsole({ drawId }: { drawId: string }) {
<h2 className="text-lg font-semibold"></h2>
<p className="text-sm text-muted-foreground">
{data.draw_no} · <DrawStatusBadge status={data.draw_status} /> ·
/
</p>
</div>
<Link
href={`/admin/draws/${drawId}/review`}
className={cn(buttonVariants({ variant: "outline", size: "sm" }))}
>
{canManageDraw ? "去审核 / 发布" : "查看审核队列"}
</Link>
</div>

View File

@@ -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<AdminDrawBatchesData | null>(null);
const [error, setError] = useState<string | null>(null);
@@ -68,8 +75,11 @@ export function DrawReviewConsole({ drawId }: { drawId: string }) {
<CardHeader>
<CardTitle className="text-lg"></CardTitle>
<CardDescription>
RNG 23
DB <DrawStatusBadge status={data.draw_status} />
RNG {" "}
<code className="rounded bg-muted px-1 text-xs">{PRD_DRAW_RESULT_MANAGE}</code>{" "}
{" "}
<code className="rounded bg-muted px-1 text-xs">POST /result-batches//publish</code>
DB <DrawStatusBadge status={data.draw_status} />
</CardDescription>
</CardHeader>
<CardContent>
@@ -94,12 +104,16 @@ export function DrawReviewConsole({ drawId }: { drawId: string }) {
<TableCell>v{b.result_version}</TableCell>
<TableCell>{b.items.length}</TableCell>
<TableCell className="text-right">
<Link
href={`/admin/draws/${drawId}/review/${b.id}`}
className={cn(buttonVariants({ size: "sm" }))}
>
</Link>
{canManageDraw ? (
<Link
href={`/admin/draws/${drawId}/publish/${b.id}`}
className={cn(buttonVariants({ size: "sm" }))}
>
</Link>
) : (
<span className="text-xs text-muted-foreground"></span>
)}
</TableCell>
</TableRow>
))}

View File

@@ -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 (
<Link

View File

@@ -29,6 +29,7 @@ import { cn } from "@/lib/utils";
import { LotteryApiBizError } from "@/types/api/errors";
import type { AdminDrawListData, AdminDrawListItem } from "@/types/api/admin-draws";
import { DRAW_ADMIN_API_PRD_LINES } from "./draw-prd";
import { DrawStatusBadge } from "./draw-status-badge";
/** 下拉「不限」;请求时不传 status */
@@ -103,7 +104,14 @@ export function DrawsIndexConsole() {
<Card>
<CardHeader className="space-y-1">
<CardTitle className="text-lg"></CardTitle>
<CardDescription></CardDescription>
<CardDescription className="space-y-2">
<span>
3 · §11.4
</span>
<span className="block font-mono text-[11px] leading-relaxed text-muted-foreground">
{DRAW_ADMIN_API_PRD_LINES.join(" · ")}
</span>
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Grid桌面端标签一行 / 控件一行,避免 flex+items-end 与各列实际高度不一致;移动端单列自上而下 */}

View File

@@ -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;

View File

@@ -1,5 +1,5 @@
export const playersModuleMeta = {
segment: "players",
title: "用户",
description: "玩家列表冻结/解冻、账号资料(路由已就绪,待接管理端 API。",
title: "玩家查询",
description: "玩家 ID 查钱包等PRD 15.3列表/冻结管理端用户 API。",
} as const;

View File

@@ -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 (
<div className="flex w-full max-w-none flex-col gap-6">
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>
{" "}
<code className="rounded bg-muted px-1">
prd.users.manage | view_finance | view_cs
</code>{" "}
/ API
</CardDescription>
</CardHeader>
</Card>
<PlayerWalletPanel />
</div>
);
}

View File

@@ -0,0 +1,5 @@
export const reconcileModuleMeta = {
segment: "reconcile",
title: "对账",
description: "钱包对账任务列表、创建与明细PRD §8 钱包对账)。",
} as const;

View File

@@ -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<AdminReconcileJobListData | null>(null);
const [jobsLoading, setJobsLoading] = useState(true);
const [jobsErr, setJobsErr] = useState<string | null>(null);
const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(25);
const [selectedId, setSelectedId] = useState<number | null>(null);
const [items, setItems] = useState<AdminReconcileItemsData | null>(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<void> {
let itemsPayload: Parameters<typeof postAdminReconcileJob>[0]["items"];
const trimmed = itemsJson.trim();
if (trimmed !== "" && trimmed !== "[]") {
try {
itemsPayload = JSON.parse(trimmed) as NonNullable<
Parameters<typeof postAdminReconcileJob>[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 (
<div className="flex w-full max-w-none flex-col gap-8">
{canCreate ? (
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>
<code className="rounded bg-muted px-1">prd.wallet_reconcile.manage</code>
</CardDescription>
</CardHeader>
<CardContent className="grid max-w-3xl gap-4">
<div className="grid gap-1.5">
<Label htmlFor="rc-type">reconcile_type</Label>
<Input
id="rc-type"
value={reconcileType}
onChange={(e) => setReconcileType(e.target.value)}
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="grid gap-1.5">
<Label htmlFor="rc-start">period_startISO</Label>
<Input
id="rc-start"
placeholder="2026-05-01T00:00:00Z"
value={periodStart}
onChange={(e) => setPeriodStart(e.target.value)}
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="rc-end">period_endISO</Label>
<Input
id="rc-end"
placeholder="2026-05-02T00:00:00Z"
value={periodEnd}
onChange={(e) => setPeriodEnd(e.target.value)}
/>
</div>
</div>
<div className="grid gap-1.5">
<Label htmlFor="rc-items">items JSON</Label>
<Textarea
id="rc-items"
value={itemsJson}
onChange={(e) => setItemsJson(e.target.value)}
rows={6}
className="font-mono text-xs"
/>
</div>
<Button type="button" onClick={() => void onCreate()} disabled={submitting}>
{submitting ? "提交中…" : "创建任务"}
</Button>
</CardContent>
</Card>
) : (
<p className="text-muted-foreground text-sm">
·
</p>
)}
<Card>
<CardHeader className="flex flex-row flex-wrap items-end justify-between gap-4">
<div>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</div>
<Button type="button" variant="secondary" size="sm" onClick={() => void loadJobs()}>
</Button>
</CardHeader>
<CardContent className="space-y-4">
{jobsErr ? <p className="text-sm text-red-600 dark:text-red-400">{jobsErr}</p> : null}
{jobsLoading && !jobs ? (
<p className="text-muted-foreground text-sm"></p>
) : null}
{jobs ? (
<>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-24">ID</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{jobs.items.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-muted-foreground">
</TableCell>
</TableRow>
) : (
jobs.items.map((row) => (
<TableRow
key={row.id}
className={
selectedId === row.id
? "bg-muted/60 cursor-pointer"
: "cursor-pointer hover:bg-muted/40"
}
onClick={() => {
setSelectedId(row.id);
setItemsPage(1);
}}
>
<TableCell className="tabular-nums">{row.id}</TableCell>
<TableCell className="font-mono text-xs">{row.job_no}</TableCell>
<TableCell>{row.reconcile_type}</TableCell>
<TableCell>
<Badge variant="secondary">{row.status}</Badge>
</TableCell>
<TableCell className="max-w-[14rem] truncate text-xs text-muted-foreground">
{row.period_start ?? "—"} ~ {row.period_end ?? "—"}
</TableCell>
<TableCell className="whitespace-nowrap font-mono text-[11px] text-muted-foreground">
{formatTs(row.created_at)}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{jm ? (
<AdminListPaginationFooter
selectId="reconcile-jobs-per-page"
total={jm.total}
page={jm.current_page}
lastPage={Math.max(1, jm.last_page)}
perPage={jm.per_page}
loading={jobsLoading}
onPerPageChange={(n) => {
setPerPage(n);
setPage(1);
}}
onPageChange={setPage}
/>
) : null}
</>
) : null}
</CardContent>
</Card>
{selectedId != null ? (
<Card>
<CardHeader>
<CardTitle> #{selectedId} </CardTitle>
<CardDescription>
{itemsLoading ? "加载中…" : items?.job_no ?? ""}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{items ? (
<>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-20">ID</TableHead>
<TableHead>side_a_ref</TableHead>
<TableHead>side_b_ref</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.items.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="text-muted-foreground">
</TableCell>
</TableRow>
) : (
items.items.map((r) => (
<TableRow key={r.id}>
<TableCell>{r.id}</TableCell>
<TableCell className="font-mono text-xs">{r.side_a_ref ?? "—"}</TableCell>
<TableCell className="font-mono text-xs">{r.side_b_ref ?? "—"}</TableCell>
<TableCell className="tabular-nums">{r.difference_amount}</TableCell>
<TableCell>{r.status}</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{im ? (
<AdminListPaginationFooter
selectId="reconcile-items-per-page"
total={im.total}
page={im.current_page}
lastPage={Math.max(1, im.last_page)}
perPage={im.per_page}
loading={itemsLoading}
onPerPageChange={(n) => {
setItemsPerPage(n);
setItemsPage(1);
}}
onPageChange={setItemsPage}
/>
) : null}
</>
) : null}
</CardContent>
</Card>
) : null}
</div>
);
}

View File

@@ -0,0 +1,5 @@
export const reportsModuleMeta = {
segment: "reports",
title: "报表导出",
description: "创建异步导出任务并查看历史记录PRD §8 报表权限)。",
} as const;

View File

@@ -0,0 +1,256 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
import { getAdminReportJobs, postAdminReportJob } from "@/api/admin-reports";
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 { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
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 { LotteryApiBizError } from "@/types/api/errors";
import type { AdminReportJobListData } from "@/types/api/admin-reports";
const REPORT_TYPES = [
{ value: "wallet_txns_daily", label: "钱包流水日报" },
{ value: "transfer_orders_daily", label: "转账单日报" },
] as const;
export function ReportsConsole(): React.ReactElement {
const formatTs = useAdminDateTimeFormatter();
const [data, setData] = useState<AdminReportJobListData | null>(null);
const [loading, setLoading] = useState(true);
const [err, setErr] = useState<string | null>(null);
const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(25);
const [reportType, setReportType] = useState<string>(REPORT_TYPES[0].value);
const [exportFormat, setExportFormat] = useState<"csv" | "xlsx">("csv");
const [filterJsonText, setFilterJsonText] = useState('{\n "currency_code": "NPR"\n}');
const [submitting, setSubmitting] = useState(false);
const load = useCallback(async () => {
setLoading(true);
setErr(null);
try {
const d = await getAdminReportJobs({ page, per_page: perPage });
setData(d);
} catch (e) {
setErr(e instanceof LotteryApiBizError ? e.message : "加载失败");
setData(null);
} finally {
setLoading(false);
}
}, [page, perPage]);
useEffect(() => {
queueMicrotask(() => {
void load();
});
}, [load]);
async function onCreate(): Promise<void> {
let filter_json: Record<string, unknown> | null = null;
const trimmed = filterJsonText.trim();
if (trimmed !== "") {
try {
filter_json = JSON.parse(trimmed) as Record<string, unknown>;
} catch {
toast.error("筛选 JSON 无法解析");
return;
}
}
setSubmitting(true);
try {
await postAdminReportJob({
report_type: reportType,
export_format: exportFormat,
filter_json,
});
toast.success("已创建导出任务");
setPage(1);
await load();
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : "创建失败");
} finally {
setSubmitting(false);
}
}
const meta = data?.meta;
const lastPage = meta
? Math.max(1, meta.last_page)
: 1;
return (
<div className="flex w-full max-w-none flex-col gap-8">
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>
<code className="rounded bg-muted px-1">POST /api/v1/admin/report-jobs</code>
<code className="rounded bg-muted px-1">output_path</code>{" "}
</CardDescription>
</CardHeader>
<CardContent className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<div className="grid gap-1.5">
<Label></Label>
<Select
modal={false}
value={reportType}
onValueChange={(v) => {
if (v) {
setReportType(v);
}
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{REPORT_TYPES.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-1.5">
<Label></Label>
<Select
modal={false}
value={exportFormat}
onValueChange={(v) => {
if (v === "csv" || v === "xlsx") {
setExportFormat(v);
}
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="csv">CSV</SelectItem>
<SelectItem value="xlsx">XLSX</SelectItem>
</SelectContent>
</Select>
</div>
<div className="sm:col-span-2 lg:col-span-3 grid gap-1.5">
<Label htmlFor="report-filter-json">filter_json</Label>
<Textarea
id="report-filter-json"
value={filterJsonText}
onChange={(e) => setFilterJsonText(e.target.value)}
rows={5}
className="font-mono text-xs"
/>
</div>
<div className="sm:col-span-2 lg:col-span-3">
<Button type="button" onClick={() => void onCreate()} disabled={submitting}>
{submitting ? "提交中…" : "创建任务"}
</Button>
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row flex-wrap items-end justify-between gap-4">
<div>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</div>
<Button type="button" variant="secondary" size="sm" onClick={() => void load()}>
</Button>
</CardHeader>
<CardContent className="space-y-4">
{err ? <p className="text-sm text-red-600 dark:text-red-400">{err}</p> : null}
{loading && !data ? (
<p className="text-muted-foreground text-sm"></p>
) : null}
{data ? (
<>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-24">ID</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.items.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="text-muted-foreground">
</TableCell>
</TableRow>
) : (
data.items.map((row) => (
<TableRow key={row.id}>
<TableCell className="tabular-nums">{row.id}</TableCell>
<TableCell className="font-mono text-xs">{row.job_no}</TableCell>
<TableCell className="text-sm">{row.report_type}</TableCell>
<TableCell>{row.export_format}</TableCell>
<TableCell>
<Badge variant="secondary">{row.status}</Badge>
</TableCell>
<TableCell className="max-w-[12rem] truncate text-xs text-muted-foreground">
{row.output_path ?? "—"}
</TableCell>
<TableCell className="whitespace-nowrap font-mono text-[11px] text-muted-foreground">
{formatTs(row.created_at)}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{meta ? (
<AdminListPaginationFooter
selectId="report-jobs-per-page"
total={meta.total}
page={meta.current_page}
lastPage={lastPage}
perPage={meta.per_page}
loading={loading}
onPerPageChange={(n) => {
setPerPage(n);
setPage(1);
}}
onPageChange={setPage}
/>
) : null}
</>
) : null}
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,5 @@
export const serviceDeskModuleMeta = {
segment: "service_desk",
title: "客服 / 财务",
description: "PRD §15.4 能力入口:注单、流水、转账失败、期号收支、报表。",
} as const;

View File

@@ -0,0 +1,64 @@
"use client";
import Link from "next/link";
import { buttonVariants } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { cn } from "@/lib/utils";
const items: { title: string; desc: string; href: string; hint: string }[] = [
{
title: "玩家注单",
desc: "按玩家 ID 查注单、失败原因、中奖金额。",
href: "/admin/tickets",
hint: "GET …/players/{id}/ticket-items",
},
{
title: "钱包流水",
desc: "按玩家、单号、日期筛选流水。",
href: "/admin/wallet/transactions",
hint: "GET …/wallet/transactions",
},
{
title: "转账单与失败原因",
desc: "筛选 status=failed 或异常单,查看 fail_reason 列。",
href: "/admin/wallet/transfer-orders",
hint: "GET …/wallet/transfer-orders",
},
{
title: "期号列表 → 期号收支",
desc: "开奖 → 点期号 →「期号收支」看当期投注/派彩与结算批次。",
href: "/admin/draws",
hint: "GET …/draws/{id}/finance-summary",
},
{
title: "报表导出",
desc: "异步导出任务(如钱包流水日报)。",
href: "/admin/reports",
hint: "POST …/report-jobs",
},
];
/** PRD §15.4 客服/财务能力入口聚合 */
export function ServiceDeskConsole(): React.ReactElement {
return (
<div className="grid gap-6 md:grid-cols-2">
{items.map((it) => (
<Card key={it.href}>
<CardHeader>
<CardTitle className="text-base">{it.title}</CardTitle>
<CardDescription>{it.desc}</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<code className="block rounded-md bg-muted px-2 py-1.5 font-mono text-[11px] text-muted-foreground">
{it.hint}
</code>
<Link href={it.href} className={cn(buttonVariants({ size: "sm" }), "w-fit")}>
</Link>
</CardContent>
</Card>
))}
</div>
);
}

View File

@@ -1,5 +1,5 @@
export const ticketsModuleMeta = {
segment: "tickets",
title: "注单 / 票务",
description: "订单流、下注明细与退票规则(占位)。",
title: "玩家注单",
description: "PRD §15.4:按玩家查注单(管理端 ticket-items API)。",
} as const;

View File

@@ -0,0 +1,184 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { getAdminPlayerTicketItems } from "@/api/admin-player-tickets";
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 { LotteryApiBizError } from "@/types/api/errors";
import type { AdminPlayerTicketItemsData } from "@/types/api/admin-player-tickets";
/** PRD §15.4:按玩家主键查注单(`GET …/admin/players/{id}/ticket-items` */
export function PlayerTicketsConsole(): React.ReactElement {
const [playerIdDraft, setPlayerIdDraft] = useState("");
const [drawNoDraft, setDrawNoDraft] = useState("");
const [playerId, setPlayerId] = useState<number | null>(null);
const [drawNo, setDrawNo] = useState("");
const [data, setData] = useState<AdminPlayerTicketItemsData | null>(null);
const [loading, setLoading] = useState(false);
const [err, setErr] = useState<string | null>(null);
const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(20);
const load = useCallback(async () => {
if (playerId == null || playerId < 1) {
setData(null);
return;
}
setLoading(true);
setErr(null);
try {
const d = await getAdminPlayerTicketItems(playerId, {
page,
per_page: perPage,
draw_no: drawNo.trim() || undefined,
});
setData(d);
} catch (e) {
setErr(e instanceof LotteryApiBizError ? e.message : "加载失败");
setData(null);
} finally {
setLoading(false);
}
}, [playerId, page, perPage, drawNo]);
useEffect(() => {
queueMicrotask(() => {
void load();
});
}, [load]);
const runSearch = () => {
const id = Number(playerIdDraft.trim());
if (Number.isNaN(id) || id < 1) {
setErr("请输入有效玩家 ID");
setPlayerId(null);
setData(null);
return;
}
setErr(null);
setPlayerId(id);
setDrawNo(drawNoDraft.trim());
setPage(1);
};
return (
<Card className="w-full max-w-none">
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>
<code className="rounded bg-muted px-1 text-xs">prd.users.view_cs | view_finance | manage</code>
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-wrap items-end gap-3">
<div className="grid gap-1.5">
<Label htmlFor="pt-player"> ID</Label>
<Input
id="pt-player"
inputMode="numeric"
className="w-40 font-mono"
placeholder="players.id"
value={playerIdDraft}
onChange={(e) => setPlayerIdDraft(e.target.value)}
/>
</div>
<div className="grid min-w-[10rem] flex-1 gap-1.5">
<Label htmlFor="pt-draw"> draw_no</Label>
<Input
id="pt-draw"
className="font-mono text-sm"
placeholder="如 20260520-001"
value={drawNoDraft}
onChange={(e) => setDrawNoDraft(e.target.value)}
/>
</div>
<Button type="button" onClick={() => runSearch()}>
</Button>
</div>
{err ? <p className="text-sm text-destructive">{err}</p> : null}
{loading && playerId != null ? (
<p className="text-muted-foreground text-sm"></p>
) : null}
{data ? (
<>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.items.length === 0 ? (
<TableRow>
<TableCell colSpan={9} className="text-muted-foreground">
</TableCell>
</TableRow>
) : (
data.items.map((row) => (
<TableRow key={row.ticket_no}>
<TableCell className="font-mono text-xs">{row.ticket_no}</TableCell>
<TableCell className="font-mono text-xs">{row.order_no ?? "—"}</TableCell>
<TableCell className="font-mono text-xs">{row.draw_no ?? "—"}</TableCell>
<TableCell className="text-xs">{row.play_code}</TableCell>
<TableCell className="font-mono text-xs">{row.original_number ?? "—"}</TableCell>
<TableCell className="text-right tabular-nums text-xs">
{row.actual_deduct_amount}
</TableCell>
<TableCell className="text-xs">{row.status}</TableCell>
<TableCell className="max-w-[14rem] text-xs text-muted-foreground">
{row.fail_reason_text ?? row.fail_reason_code ?? "—"}
</TableCell>
<TableCell className="text-right tabular-nums text-xs">
{row.win_amount + row.jackpot_win_amount}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
<AdminListPaginationFooter
selectId="player-tickets-per-page"
total={data.total}
page={data.page}
lastPage={Math.max(1, data.last_page)}
perPage={data.per_page}
loading={loading}
onPerPageChange={(n) => {
setPerPage(n);
setPage(1);
}}
onPageChange={setPage}
/>
</>
) : null}
</CardContent>
</Card>
);
}

View File

@@ -32,7 +32,6 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
import { LotteryApiBizError } from "@/types/api/errors";
import type {
@@ -183,28 +182,7 @@ function walletAdminSelectDisplayedLabel(
return options.find((o) => o.value === v)?.label ?? v;
}
export function WalletConsole(): React.ReactElement {
return (
<Tabs defaultValue="txns" className="w-full">
<TabsList variant="line" className="mb-6 w-full justify-start">
<TabsTrigger value="txns"></TabsTrigger>
<TabsTrigger value="orders"></TabsTrigger>
<TabsTrigger value="player"></TabsTrigger>
</TabsList>
<TabsContent value="orders">
<TransferOrdersPanel />
</TabsContent>
<TabsContent value="txns">
<WalletTxnsPanel />
</TabsContent>
<TabsContent value="player">
<PlayerWalletPanel />
</TabsContent>
</Tabs>
);
}
function TransferOrdersPanel(): React.ReactElement {
export function TransferOrdersPanel(): React.ReactElement {
const formatTs = useAdminDateTimeFormatter();
const [data, setData] = useState<AdminTransferOrderListData | null>(null);
const [loading, setLoading] = useState(true);
@@ -267,7 +245,8 @@ function TransferOrdersPanel(): React.ReactElement {
<CardTitle></CardTitle>
<CardDescription>
§5.11 {" "}
<code className="rounded bg-muted px-1">player_id</code> ID
<code className="rounded bg-muted px-1">player_id</code> ID PRD §15.4{" "}
<code className="rounded bg-muted px-1">status=failed</code>
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
@@ -393,6 +372,7 @@ function TransferOrdersPanel(): React.ReactElement {
<TableHead className="w-14"></TableHead>
<TableHead className="whitespace-nowrap"></TableHead>
<TableHead className="whitespace-nowrap"></TableHead>
<TableHead className="min-w-0 max-w-[14rem]"></TableHead>
<TableHead className="min-w-0 whitespace-normal leading-tight">
</TableHead>
@@ -405,7 +385,7 @@ function TransferOrdersPanel(): React.ReactElement {
<TableBody>
{data.items.length === 0 ? (
<TableRow>
<TableCell colSpan={9} className="text-muted-foreground">
<TableCell colSpan={10} className="text-muted-foreground">
</TableCell>
</TableRow>
@@ -432,6 +412,9 @@ function TransferOrdersPanel(): React.ReactElement {
<TableCell>
<Badge variant={statusBadgeVariant(row.status)}>{row.status}</Badge>
</TableCell>
<TableCell className="max-w-[14rem] whitespace-normal break-words text-xs text-muted-foreground">
{row.fail_reason?.trim() ? row.fail_reason : "—"}
</TableCell>
<TableCell className="min-w-0 whitespace-normal font-mono text-[11px] leading-snug text-muted-foreground">
{formatTs(row.created_at)}
</TableCell>
@@ -465,7 +448,7 @@ function TransferOrdersPanel(): React.ReactElement {
);
}
function WalletTxnsPanel(): React.ReactElement {
export function WalletTxnsPanel(): React.ReactElement {
const formatTs = useAdminDateTimeFormatter();
const [data, setData] = useState<AdminWalletTxnListData | null>(null);
const [loading, setLoading] = useState(true);
@@ -758,7 +741,7 @@ function WalletTxnsPanel(): React.ReactElement {
);
}
function PlayerWalletPanel(): React.ReactElement {
export function PlayerWalletPanel(): React.ReactElement {
const [playerId, setPlayerId] = useState("");
const [result, setResult] = useState<AdminPlayerWalletsData | null>(null);
const [err, setErr] = useState<string | null>(null);

View File

@@ -0,0 +1,63 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { cn } from "@/lib/utils";
import { adminHasAnyPermission } from "@/lib/admin-permissions";
import { useAdminProfile } from "@/stores/admin-session";
const RECONCILE_PERMS = [
"prd.wallet_reconcile.manage",
"prd.wallet_reconcile.view",
"prd.wallet_reconcile.view_cs",
] as const;
const USER_PERMS = [
"prd.users.manage",
"prd.users.view_finance",
"prd.users.view_cs",
] as const;
const tabs: { href: string; label: string; requiredAny: readonly string[] }[] = [
{ href: "/admin/wallet/transactions", label: "钱包流水", requiredAny: RECONCILE_PERMS },
{ href: "/admin/wallet/transfer-orders", label: "转账单", requiredAny: RECONCILE_PERMS },
{ href: "/admin/wallet/player", label: "玩家钱包", requiredAny: USER_PERMS },
];
export function WalletSubnav(): React.ReactElement {
const pathname = usePathname();
const profile = useAdminProfile();
const perms = profile?.permissions;
return (
<nav
aria-label="钱包子页"
className="mb-6 flex flex-wrap gap-2 border-b border-border pb-3"
>
{tabs.map((t) => {
const allowed = adminHasAnyPermission(perms, [...t.requiredAny]);
const active = pathname === t.href || pathname.startsWith(`${t.href}/`);
const className = cn(
"rounded-lg px-3 py-1.5 text-sm font-medium transition-colors",
active
? "bg-primary text-primary-foreground"
: "bg-muted/60 text-foreground hover:bg-muted",
!allowed && "cursor-not-allowed opacity-45",
);
if (!allowed) {
return (
<span key={t.href} className={className} title="当前账号无访问该页的权限">
{t.label}
</span>
);
}
return (
<Link key={t.href} href={t.href} className={className}>
{t.label}
</Link>
);
})}
</nav>
);
}

View File

@@ -17,11 +17,15 @@ export function readProfile(): AdminProfile | null {
typeof v?.username === "string" &&
typeof v?.nickname === "string"
) {
const permissions = Array.isArray(v.permissions)
? v.permissions.filter((s): s is string => typeof s === "string")
: [];
return {
id: v.id,
username: v.username,
nickname: v.nickname,
email: typeof v.email === "string" || v.email === null ? v.email : null,
permissions,
};
}
} catch {

View File

@@ -0,0 +1,24 @@
export type AdminAuditLogRow = {
id: number;
operator_type: string;
operator_id: number;
module_code: string;
action_code: string;
target_type: string | null;
target_id: number | null;
before_json: Record<string, unknown> | null;
after_json: Record<string, unknown> | null;
ip: string | null;
user_agent: string | null;
created_at: string | null;
};
export type AdminAuditLogListData = {
items: AdminAuditLogRow[];
meta: {
current_page: number;
per_page: number;
total: number;
last_page: number;
};
};

View File

@@ -18,6 +18,8 @@ export type AdminProfile = {
username: string;
nickname: string;
email: string | null;
/** 与 Laravel `admin_permissions.slug` 一致(如 `prd.*`);超管为全量列表 */
permissions?: string[];
};
/** `POST /api/v1/admin/auth/login` 成功信封内的 `data` */

View File

@@ -0,0 +1,24 @@
export type AdminDrawFinanceSettlementBatchRow = {
id: number;
status: string;
total_ticket_count: number;
total_win_count: number;
total_payout_amount: number;
total_jackpot_payout_amount: number;
finished_at: string | null;
};
export type AdminDrawFinanceSummaryData = {
draw_id: number;
draw_no: string;
draw_status: string;
currency_code: string | null;
order_count: number;
ticket_item_count: number;
total_bet_minor: number;
total_win_payout_minor: number;
total_jackpot_win_minor: number;
total_payout_minor: number;
approx_house_gross_minor: number;
settlement_batches: AdminDrawFinanceSettlementBatchRow[];
};

View File

@@ -0,0 +1,26 @@
export type AdminPlayerTicketItemRow = {
ticket_no: string;
order_no: string | null;
draw_no: string | null;
currency_code: string | null;
play_code: string;
original_number: string | null;
total_bet_amount: number;
actual_deduct_amount: number;
status: string;
fail_reason_code: string | null;
fail_reason_text: string | null;
win_amount: number;
jackpot_win_amount: number;
placed_at: string | null;
updated_at: string | null;
};
export type AdminPlayerTicketItemsData = {
player_id: number;
items: AdminPlayerTicketItemRow[];
total: number;
page: number;
per_page: number;
last_page: number;
};

View File

@@ -0,0 +1,52 @@
export type AdminReconcileJobRow = {
id: number;
job_no: string;
admin_user_id: number | null;
reconcile_type: string;
status: string;
period_start: string | null;
period_end: string | null;
summary_json: Record<string, unknown> | null;
finished_at: string | null;
created_at: string | null;
};
export type AdminReconcileJobListData = {
items: AdminReconcileJobRow[];
meta: {
current_page: number;
per_page: number;
total: number;
last_page: number;
};
};
export type AdminReconcileJobCreateResponse = {
id: number;
job_no: string;
status: string;
summary_json: Record<string, unknown> | null;
item_count: number;
};
export type AdminReconcileItemRow = {
id: number;
side_a_ref: string | null;
side_b_ref: string | null;
difference_amount: number;
status: string;
resolved_at: string | null;
created_at: string | null;
};
export type AdminReconcileItemsData = {
job_id: number;
job_no: string;
items: AdminReconcileItemRow[];
meta: {
current_page: number;
per_page: number;
total: number;
last_page: number;
};
};

View File

@@ -0,0 +1,30 @@
export type AdminReportJobRow = {
id: number;
job_no: string;
admin_user_id: number | null;
report_type: string;
export_format: string;
filter_json: Record<string, unknown> | null;
status: string;
output_path: string | null;
error_message: string | null;
finished_at: string | null;
created_at: string | null;
};
export type AdminReportJobListData = {
items: AdminReportJobRow[];
meta: {
current_page: number;
per_page: number;
total: number;
last_page: number;
};
};
export type AdminReportJobCreateResponse = {
id: number;
job_no: string;
status: string;
output_path: string | null;
};

View File

@@ -14,6 +14,14 @@ export type {
AdminDrawPublishResponse,
AdminDrawShowData,
} from "./admin-draws";
export type {
AdminDrawFinanceSettlementBatchRow,
AdminDrawFinanceSummaryData,
} from "./admin-draw-finance";
export type {
AdminPlayerTicketItemRow,
AdminPlayerTicketItemsData,
} from "./admin-player-tickets";
export type {
AdminPlayerWalletsData,
AdminPlayerWalletRow,
@@ -22,6 +30,19 @@ export type {
AdminWalletTxnItem,
AdminWalletTxnListData,
} from "./admin-wallet";
export type {
AdminReportJobCreateResponse,
AdminReportJobListData,
AdminReportJobRow,
} from "./admin-reports";
export type {
AdminReconcileItemRow,
AdminReconcileItemsData,
AdminReconcileJobCreateResponse,
AdminReconcileJobListData,
AdminReconcileJobRow,
} from "./admin-reconcile";
export type { AdminAuditLogListData, AdminAuditLogRow } from "./admin-audit";
export type {
AdminPlayTypeRow,
AdminPlayTypesData,