feat: 添加财务摘要接口,更新管理员抽奖模块和导航,优化权限管理逻辑
This commit is contained in:
19
src/api/admin-audit.ts
Normal file
19
src/api/admin-audit.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
17
src/api/admin-player-tickets.ts
Normal file
17
src/api/admin-player-tickets.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
48
src/api/admin-reconcile.ts
Normal file
48
src/api/admin-reconcile.ts
Normal 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
30
src/api/admin-reports.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
16
src/app/admin/(shell)/audit-logs/page.tsx
Normal file
16
src/app/admin/(shell)/audit-logs/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
13
src/app/admin/(shell)/draws/[drawId]/finance/page.tsx
Normal file
13
src/app/admin/(shell)/draws/[drawId]/finance/page.tsx
Normal 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} />;
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
16
src/app/admin/(shell)/reconcile/page.tsx
Normal file
16
src/app/admin/(shell)/reconcile/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
16
src/app/admin/(shell)/reports/page.tsx
Normal file
16
src/app/admin/(shell)/reports/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
22
src/app/admin/(shell)/service-desk/page.tsx
Normal file
22
src/app/admin/(shell)/service-desk/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
12
src/app/admin/(shell)/wallet/layout.tsx
Normal file
12
src/app/admin/(shell)/wallet/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
16
src/app/admin/(shell)/wallet/player/page.tsx
Normal file
16
src/app/admin/(shell)/wallet/player/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
16
src/app/admin/(shell)/wallet/transactions/page.tsx
Normal file
16
src/app/admin/(shell)/wallet/transactions/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
16
src/app/admin/(shell)/wallet/transfer-orders/page.tsx
Normal file
16
src/app/admin/(shell)/wallet/transfer-orders/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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}>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
17
src/lib/admin-nav-visibility.ts
Normal file
17
src/lib/admin-nav-visibility.ts
Normal 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));
|
||||
}
|
||||
11
src/lib/admin-permissions.ts
Normal file
11
src/lib/admin-permissions.ts
Normal 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));
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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" },
|
||||
];
|
||||
|
||||
190
src/modules/audit/audit-logs-console.tsx
Normal file
190
src/modules/audit/audit-logs-console.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
5
src/modules/audit/meta.ts
Normal file
5
src/modules/audit/meta.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export const auditModuleMeta = {
|
||||
segment: "audit",
|
||||
title: "审计日志",
|
||||
description: "运营留痕查询(权限范围由后端按角色过滤)。",
|
||||
} as const;
|
||||
@@ -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>
|
||||
|
||||
172
src/modules/draws/draw-finance-console.tsx
Normal file
172
src/modules/draws/draw-finance-console.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
17
src/modules/draws/draw-prd.ts
Normal file
17
src/modules/draws/draw-prd.ts
Normal 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;
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 与各列实际高度不一致;移动端单列自上而下 */}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export const playersModuleMeta = {
|
||||
segment: "players",
|
||||
title: "用户",
|
||||
description: "玩家列表、冻结/解冻、账号资料(路由已就绪,待接管理端 API)。",
|
||||
title: "玩家查询",
|
||||
description: "按玩家 ID 查钱包等(PRD 15.3);列表/冻结待管理端用户 API。",
|
||||
} as const;
|
||||
|
||||
26
src/modules/players/players-console.tsx
Normal file
26
src/modules/players/players-console.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
5
src/modules/reconcile/meta.ts
Normal file
5
src/modules/reconcile/meta.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export const reconcileModuleMeta = {
|
||||
segment: "reconcile",
|
||||
title: "对账",
|
||||
description: "钱包对账任务列表、创建与明细(PRD §8 钱包对账)。",
|
||||
} as const;
|
||||
354
src/modules/reconcile/reconcile-console.tsx
Normal file
354
src/modules/reconcile/reconcile-console.tsx
Normal 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_start(ISO)</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_end(ISO)</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>
|
||||
);
|
||||
}
|
||||
5
src/modules/reports/meta.ts
Normal file
5
src/modules/reports/meta.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export const reportsModuleMeta = {
|
||||
segment: "reports",
|
||||
title: "报表导出",
|
||||
description: "创建异步导出任务并查看历史记录(PRD §8 报表权限)。",
|
||||
} as const;
|
||||
256
src/modules/reports/reports-console.tsx
Normal file
256
src/modules/reports/reports-console.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
5
src/modules/service-desk/meta.ts
Normal file
5
src/modules/service-desk/meta.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export const serviceDeskModuleMeta = {
|
||||
segment: "service_desk",
|
||||
title: "客服 / 财务",
|
||||
description: "PRD §15.4 能力入口:注单、流水、转账失败、期号收支、报表。",
|
||||
} as const;
|
||||
64
src/modules/service-desk/service-desk-console.tsx
Normal file
64
src/modules/service-desk/service-desk-console.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
export const ticketsModuleMeta = {
|
||||
segment: "tickets",
|
||||
title: "注单 / 票务",
|
||||
description: "订单流、下注明细与退票规则(占位)。",
|
||||
title: "玩家注单",
|
||||
description: "PRD §15.4:按玩家查注单(管理端 ticket-items API)。",
|
||||
} as const;
|
||||
|
||||
184
src/modules/tickets/player-tickets-console.tsx
Normal file
184
src/modules/tickets/player-tickets-console.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
63
src/modules/wallet/wallet-subnav.tsx
Normal file
63
src/modules/wallet/wallet-subnav.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
24
src/types/api/admin-audit.ts
Normal file
24
src/types/api/admin-audit.ts
Normal 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;
|
||||
};
|
||||
};
|
||||
@@ -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` */
|
||||
|
||||
24
src/types/api/admin-draw-finance.ts
Normal file
24
src/types/api/admin-draw-finance.ts
Normal 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[];
|
||||
};
|
||||
26
src/types/api/admin-player-tickets.ts
Normal file
26
src/types/api/admin-player-tickets.ts
Normal 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;
|
||||
};
|
||||
52
src/types/api/admin-reconcile.ts
Normal file
52
src/types/api/admin-reconcile.ts
Normal 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;
|
||||
};
|
||||
};
|
||||
30
src/types/api/admin-reports.ts
Normal file
30
src/types/api/admin-reports.ts
Normal 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;
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user