feat(config): add wallet configuration management page

新增钱包配置管理页面,包含转入转出限额配置功能,添加对应路由导航与API接口,完善配置元信息
This commit is contained in:
2026-05-14 11:17:57 +08:00
parent 2dfffd1fd1
commit afa592dd91
6 changed files with 253 additions and 1 deletions

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

@@ -0,0 +1,30 @@
import { adminRequest } from "@/lib/admin-http";
import { API_V1_PREFIX } from "@/api/paths";
const A = `${API_V1_PREFIX}/admin`;
export type AdminSettingItem = {
key: string;
value: unknown;
group: string;
description: string | null;
};
export type AdminSettingListResponse = {
items: AdminSettingItem[];
};
export async function getAdminSettings(
group: string,
): Promise<AdminSettingListResponse> {
return adminRequest.get<AdminSettingListResponse>(`${A}/settings`, {
params: { group },
});
}
export async function updateAdminSetting(
key: string,
value: unknown,
): Promise<AdminSettingItem> {
return adminRequest.put<AdminSettingItem>(`${A}/settings/${key}`, { value });
}

View File

@@ -15,6 +15,7 @@ const SECTIONS = [
{ href: "/admin/config/rebate", title: "佣金 / 回水" },
{ href: "/admin/config/risk-cap", title: "风控封顶" },
{ href: "/admin/config/versions", title: "配置版本历史" },
{ href: "/admin/config/wallet", title: "钱包配置" },
] as const;
export default function AdminConfigHubPage() {

View File

@@ -0,0 +1,16 @@
import { ModuleScaffold } from "@/components/admin/module-scaffold";
import { configWalletMeta } from "@/modules/config/meta";
import { WalletConfigDocScreen } from "@/modules/config/doc/wallet-config-doc-screen";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: configWalletMeta.title,
};
export default function AdminConfigWalletPage() {
return (
<ModuleScaffold className="max-w-3xl">
<WalletConfigDocScreen />
</ModuleScaffold>
);
}

View File

@@ -6,11 +6,11 @@ import { usePathname } from "next/navigation";
import { cn } from "@/lib/utils";
const LINKS: { href: string; label: string; match?: "exact" | "prefix" }[] = [
{ href: "/admin/config", label: "概览", match: "exact" },
{ href: "/admin/config/plays", label: "玩法配置" },
{ href: "/admin/config/odds", label: "赔率配置" },
{ href: "/admin/config/rebate", label: "佣金 / 回水" },
{ href: "/admin/config/risk-cap", label: "风控封顶" },
{ href: "/admin/config/wallet", label: "钱包配置" },
];
function linkActive(pathname: string, href: string, match: "exact" | "prefix"): boolean {

View File

@@ -0,0 +1,200 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
import {
getAdminSettings,
updateAdminSetting,
} from "@/api/admin-settings";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { LotteryApiBizError } from "@/types/api/errors";
const WALLET_GROUP = "wallet";
const KEYS = {
IN_MIN: "wallet.transfer_in_min_minor",
IN_MAX: "wallet.transfer_in_max_minor",
OUT_MIN: "wallet.transfer_out_min_minor",
OUT_MAX: "wallet.transfer_out_max_minor",
} as const;
function minorUnitsToDisplay(n: unknown, decimals = 2): string {
const num = Number(n);
if (!Number.isFinite(num)) return "";
return (num / 100).toFixed(decimals);
}
function displayToMinorUnits(s: string): number {
const n = parseFloat(s);
if (Number.isNaN(n) || n < 0) return 0;
return Math.round(n * 100);
}
interface Draft {
inMin: string;
inMax: string;
outMin: string;
outMax: string;
}
export function WalletConfigDocScreen() {
const [draft, setDraft] = useState<Draft>({
inMin: "",
inMax: "",
outMin: "",
outMax: "",
});
const [saved, setSaved] = useState<Draft>({ inMin: "", inMax: "", outMin: "", outMax: "" });
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [dirty, setDirty] = useState(false);
const load = useCallback(async () => {
setLoading(true);
try {
const res = await getAdminSettings(WALLET_GROUP);
const kv: Record<string, unknown> = {};
for (const item of res.items) {
kv[item.key] = item.value;
}
const d: Draft = {
inMin: minorUnitsToDisplay(kv[KEYS.IN_MIN] ?? 100),
inMax: minorUnitsToDisplay(kv[KEYS.IN_MAX] ?? 0),
outMin: minorUnitsToDisplay(kv[KEYS.OUT_MIN] ?? 100),
outMax: minorUnitsToDisplay(kv[KEYS.OUT_MAX] ?? 0),
};
setDraft(d);
setSaved(d);
setDirty(false);
} catch (e) {
toast.error("加载失败");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load();
}, [load]);
const handleChange = (field: keyof Draft, value: string) => {
setDraft((prev) => ({ ...prev, [field]: value }));
setDirty(true);
};
const handleSave = async () => {
setSaving(true);
try {
await updateAdminSetting(KEYS.IN_MIN, displayToMinorUnits(draft.inMin));
await updateAdminSetting(KEYS.IN_MAX, displayToMinorUnits(draft.inMax));
await updateAdminSetting(KEYS.OUT_MIN, displayToMinorUnits(draft.outMin));
await updateAdminSetting(KEYS.OUT_MAX, displayToMinorUnits(draft.outMax));
toast.success("保存成功");
setSaved(draft);
setDirty(false);
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : "保存失败");
} finally {
setSaving(false);
}
};
return (
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<p className="text-sm text-muted-foreground">
NPR 100 = 1.00 NPR 1
</p>
<div className="grid gap-6 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="in-min"></Label>
<Input
id="in-min"
type="number"
min="0"
step="0.01"
placeholder="例如 1.00"
value={draft.inMin}
onChange={(e) => handleChange("inMin", e.target.value)}
disabled={loading || saving}
/>
<p className="text-xs text-muted-foreground">
</p>
</div>
<div className="space-y-2">
<Label htmlFor="in-max"></Label>
<Input
id="in-max"
type="number"
min="0"
step="0.01"
placeholder="例如 10000.00"
value={draft.inMax}
onChange={(e) => handleChange("inMax", e.target.value)}
disabled={loading || saving}
/>
<p className="text-xs text-muted-foreground">
</p>
</div>
<div className="space-y-2">
<Label htmlFor="out-min"></Label>
<Input
id="out-min"
type="number"
min="0"
step="0.01"
placeholder="例如 1.00"
value={draft.outMin}
onChange={(e) => handleChange("outMin", e.target.value)}
disabled={loading || saving}
/>
<p className="text-xs text-muted-foreground">
</p>
</div>
<div className="space-y-2">
<Label htmlFor="out-max"></Label>
<Input
id="out-max"
type="number"
min="0"
step="0.01"
placeholder="例如 10000.00"
value={draft.outMax}
onChange={(e) => handleChange("outMax", e.target.value)}
disabled={loading || saving}
/>
<p className="text-xs text-muted-foreground">
</p>
</div>
</div>
<div className="flex items-center gap-4">
<Button onClick={() => void handleSave()} disabled={!dirty || loading || saving}>
{saving ? "保存中…" : "保存"}
</Button>
{dirty && (
<Button
variant="outline"
onClick={() => {
setDraft(saved);
setDirty(false);
}}
>
</Button>
)}
</div>
</CardContent>
</Card>
);
}

View File

@@ -27,3 +27,8 @@ export const configVersionsMeta = {
title: "配置版本历史",
description: "",
} as const;
export const configWalletMeta = {
title: "钱包配置",
description: "",
} as const;