feat: 添加货币管理功能,更新国际化支持,移除报表相关代码

This commit is contained in:
2026-05-21 16:24:56 +08:00
parent 6ecbaf5fb4
commit 055c613a6d
87 changed files with 1615 additions and 1319 deletions

View File

@@ -0,0 +1,11 @@
"use client";
import { CurrencySettingsPanel } from "@/modules/settings/currency-settings-panel";
export function CurrencyManagementScreen() {
return (
<div className="flex w-full max-w-none flex-col gap-6">
<CurrencySettingsPanel />
</div>
);
}

View File

@@ -0,0 +1,377 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import {
deleteAdminCurrency,
getAdminCurrencies,
postAdminCurrency,
putAdminCurrency,
} from "@/api/admin-currencies";
import { Button } from "@/components/ui/button";
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { adminHasAnyPermission } from "@/lib/admin-permissions";
import { useAdminProfile } from "@/stores/admin-session";
import { LotteryApiBizError } from "@/types/api/errors";
import type { AdminCurrencyRow } from "@/types/api/admin-currency";
type CurrencyFormState = {
code: string;
name: string;
decimal_places: string;
is_enabled: boolean;
is_bettable: boolean;
};
const EMPTY_FORM: CurrencyFormState = {
code: "",
name: "",
decimal_places: "2",
is_enabled: true,
is_bettable: false,
};
function toFormState(row: AdminCurrencyRow): CurrencyFormState {
return {
code: row.code,
name: row.name,
decimal_places: String(row.decimal_places),
is_enabled: row.is_enabled,
is_bettable: row.is_enabled && row.is_bettable,
};
}
export function CurrencySettingsPanel() {
const { t } = useTranslation(["config", "adminUsers"]);
const profile = useAdminProfile();
const canManage = adminHasAnyPermission(profile?.permissions, ["prd.currency.manage"]);
const [items, setItems] = useState<AdminCurrencyRow[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [dialogOpen, setDialogOpen] = useState(false);
const [mode, setMode] = useState<"create" | "edit">("create");
const [editingCode, setEditingCode] = useState<string | null>(null);
const [form, setForm] = useState<CurrencyFormState>(EMPTY_FORM);
const [deleteTarget, setDeleteTarget] = useState<AdminCurrencyRow | null>(null);
const [deleteBusy, setDeleteBusy] = useState(false);
const load = useCallback(async () => {
if (!canManage) {
setItems([]);
setLoading(false);
return;
}
setLoading(true);
try {
const data = await getAdminCurrencies();
setItems(data.items);
} catch (error) {
toast.error(
error instanceof LotteryApiBizError
? error.message
: t("currencies.loadFailed", { ns: "config" }),
);
} finally {
setLoading(false);
}
}, [canManage, t]);
useEffect(() => {
queueMicrotask(() => {
void load();
});
}, [load]);
function openCreate(): void {
setMode("create");
setEditingCode(null);
setForm(EMPTY_FORM);
setDialogOpen(true);
}
function openEdit(row: AdminCurrencyRow): void {
setMode("edit");
setEditingCode(row.code);
setForm(toFormState(row));
setDialogOpen(true);
}
function updateForm<K extends keyof CurrencyFormState>(key: K, value: CurrencyFormState[K]): void {
setForm((prev) => {
const next = { ...prev, [key]: value };
if (key === "is_enabled" && value === false) {
next.is_bettable = false;
}
return next;
});
}
async function handleSubmit(): Promise<void> {
const payload = {
name: form.name.trim(),
decimal_places: Number.parseInt(form.decimal_places || "0", 10),
is_enabled: form.is_enabled,
is_bettable: form.is_enabled && form.is_bettable,
};
if (mode === "create") {
if (form.code.trim() === "" || payload.name === "") {
toast.error(t("currencies.form.required", { ns: "config" }));
return;
}
}
if (!Number.isFinite(payload.decimal_places) || payload.decimal_places < 0) {
toast.error(t("currencies.form.decimalInvalid", { ns: "config" }));
return;
}
setSaving(true);
try {
if (mode === "create") {
await postAdminCurrency({
code: form.code.trim().toUpperCase(),
...payload,
});
toast.success(t("currencies.createSuccess", { ns: "config" }));
} else if (editingCode !== null) {
await putAdminCurrency(editingCode, payload);
toast.success(t("currencies.updateSuccess", { ns: "config" }));
}
setDialogOpen(false);
await load();
} catch (error) {
toast.error(
error instanceof LotteryApiBizError
? error.message
: t(mode === "create" ? "currencies.createFailed" : "currencies.updateFailed", { ns: "config" }),
);
} finally {
setSaving(false);
}
}
async function confirmDelete(): Promise<void> {
if (deleteTarget === null) {
return;
}
setDeleteBusy(true);
try {
await deleteAdminCurrency(deleteTarget.code);
toast.success(t("currencies.deleteSuccess", { ns: "config", code: deleteTarget.code }));
setDeleteTarget(null);
await load();
} catch (error) {
toast.error(
error instanceof LotteryApiBizError
? error.message
: t("currencies.deleteFailed", { ns: "config" }),
);
} finally {
setDeleteBusy(false);
}
}
if (!canManage) {
return null;
}
return (
<Card className="admin-list-card">
<CardHeader className="admin-list-header flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-1">
<CardTitle className="admin-list-title">{t("currencies.title", { ns: "config" })}</CardTitle>
<p className="text-sm text-muted-foreground">
{t("currencies.description", { ns: "config" })}
</p>
</div>
<div className="flex items-center gap-2">
<AdminTableExportButton tableId="admin-currencies-table" filename="币种管理" sheetName="币种管理" />
<Button onClick={openCreate}>{t("currencies.actions.create", { ns: "config" })}</Button>
</div>
</CardHeader>
<CardContent className="admin-list-content">
<div className="admin-table-shell">
<Table id="admin-currencies-table">
<TableHeader>
<TableRow>
<TableHead className="whitespace-nowrap">{t("currencies.table.code", { ns: "config" })}</TableHead>
<TableHead>{t("currencies.table.name", { ns: "config" })}</TableHead>
<TableHead className="whitespace-nowrap">{t("currencies.table.decimals", { ns: "config" })}</TableHead>
<TableHead className="whitespace-nowrap">{t("currencies.table.enabled", { ns: "config" })}</TableHead>
<TableHead className="whitespace-nowrap">{t("currencies.table.bettable", { ns: "config" })}</TableHead>
<TableHead className="whitespace-nowrap text-center">{t("currencies.table.actions", { ns: "config" })}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={6} className="text-center text-sm text-muted-foreground">
{t("currencies.loading", { ns: "config" })}
</TableCell>
</TableRow>
) : items.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-center text-sm text-muted-foreground">
{t("currencies.empty", { ns: "config" })}
</TableCell>
</TableRow>
) : (
items.map((row) => (
<TableRow key={row.code}>
<TableCell className="font-mono">{row.code}</TableCell>
<TableCell>{row.name}</TableCell>
<TableCell>{row.decimal_places}</TableCell>
<TableCell>{row.is_enabled ? t("system.states.enabled", { ns: "config" }) : t("system.states.disabled", { ns: "config" })}</TableCell>
<TableCell>{row.is_bettable ? t("system.states.enabled", { ns: "config" }) : t("system.states.disabled", { ns: "config" })}</TableCell>
<TableCell className="text-center">
<div className="flex items-center justify-center gap-2">
<Button variant="outline" size="sm" onClick={() => openEdit(row)}>
{t("currencies.actions.edit", { ns: "config" })}
</Button>
<Button variant="destructive" size="sm" onClick={() => setDeleteTarget(row)}>
{t("currencies.actions.delete", { ns: "config" })}
</Button>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</CardContent>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent showCloseButton className="sm:max-w-md">
<DialogHeader>
<DialogTitle>
{t(mode === "create" ? "currencies.dialog.createTitle" : "currencies.dialog.editTitle", {
ns: "config",
})}
</DialogTitle>
<DialogDescription>
{t("currencies.dialog.description", { ns: "config" })}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="currency-code">{t("currencies.form.code", { ns: "config" })}</Label>
<Input
id="currency-code"
value={form.code}
onChange={(e) => updateForm("code", e.target.value.toUpperCase())}
disabled={saving || mode === "edit"}
/>
</div>
<div className="space-y-2">
<Label htmlFor="currency-name">{t("currencies.form.name", { ns: "config" })}</Label>
<Input
id="currency-name"
value={form.name}
onChange={(e) => updateForm("name", e.target.value)}
disabled={saving}
/>
</div>
<div className="space-y-2">
<Label htmlFor="currency-decimals">{t("currencies.form.decimals", { ns: "config" })}</Label>
<Input
id="currency-decimals"
type="number"
min="0"
max="12"
step="1"
value={form.decimal_places}
onChange={(e) => updateForm("decimal_places", e.target.value)}
disabled={saving}
/>
</div>
<div className="flex items-center justify-between rounded-xl border border-border/70 p-3">
<div className="space-y-1">
<p className="text-sm font-medium">{t("currencies.form.enabled", { ns: "config" })}</p>
<p className="text-xs text-muted-foreground">{t("currencies.form.enabledHint", { ns: "config" })}</p>
</div>
<Checkbox
checked={form.is_enabled}
onCheckedChange={(checked) => updateForm("is_enabled", checked === true)}
disabled={saving}
/>
</div>
<div className="flex items-center justify-between rounded-xl border border-border/70 p-3">
<div className="space-y-1">
<p className="text-sm font-medium">{t("currencies.form.bettable", { ns: "config" })}</p>
<p className="text-xs text-muted-foreground">{t("currencies.form.bettableHint", { ns: "config" })}</p>
</div>
<Checkbox
checked={form.is_enabled && form.is_bettable}
onCheckedChange={(checked) => updateForm("is_bettable", checked === true)}
disabled={saving || !form.is_enabled}
/>
</div>
<div className="flex justify-end gap-3">
<Button variant="outline" onClick={() => setDialogOpen(false)} disabled={saving}>
{t("actions.cancel", { ns: "adminUsers" })}
</Button>
<Button onClick={() => void handleSubmit()} disabled={saving}>
{saving ? t("saving", { ns: "adminUsers" }) : t("actions.save", { ns: "adminUsers" })}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
<Dialog open={deleteTarget !== null} onOpenChange={(open) => !open && setDeleteTarget(null)}>
<DialogContent showCloseButton className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t("currencies.deleteDialog.title", { ns: "config" })}</DialogTitle>
<DialogDescription>
{t("currencies.deleteDialog.description", {
ns: "config",
code: deleteTarget?.code ?? "",
})}
</DialogDescription>
</DialogHeader>
<div className="flex justify-end gap-3">
<Button variant="outline" onClick={() => setDeleteTarget(null)} disabled={deleteBusy}>
{t("actions.cancel", { ns: "adminUsers" })}
</Button>
<Button variant="destructive" onClick={() => void confirmDelete()} disabled={deleteBusy}>
{deleteBusy ? t("deleting", { ns: "adminUsers" }) : t("currencies.actions.delete", { ns: "config" })}
</Button>
</div>
</DialogContent>
</Dialog>
</Card>
);
}

View File

@@ -11,7 +11,6 @@ import {
import { WalletConfigDocScreen } from "@/modules/config/doc/wallet-config-doc-screen";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { LotteryApiBizError } from "@/types/api/errors";
@@ -31,6 +30,45 @@ interface RuntimeDraft {
autoSettlement: boolean;
}
function BinaryChoice({
active,
disabled,
onChange,
leftLabel,
rightLabel,
}: {
active: boolean;
disabled: boolean;
onChange: (value: boolean) => void;
leftLabel: string;
rightLabel: string;
}) {
return (
<div className="inline-flex rounded-full border border-border/60 bg-background p-1">
<Button
type="button"
size="sm"
variant={!active ? "default" : "ghost"}
className={!active ? "h-8 rounded-full px-3" : "h-8 rounded-full px-3 text-muted-foreground"}
disabled={disabled}
onClick={() => onChange(false)}
>
{leftLabel}
</Button>
<Button
type="button"
size="sm"
variant={active ? "default" : "ghost"}
className={active ? "h-8 rounded-full px-3" : "h-8 rounded-full px-3 text-muted-foreground"}
disabled={disabled}
onClick={() => onChange(true)}
>
{rightLabel}
</Button>
</div>
);
}
export function SystemSettingsScreen() {
const { t } = useTranslation(["common", "config", "adminUsers"]);
const [draft, setDraft] = useState<RuntimeDraft>({
@@ -120,82 +158,89 @@ export function SystemSettingsScreen() {
</CardTitle>
</div>
</CardHeader>
</Card>
<Card>
<CardHeader>
<CardTitle>{t("system.title", { ns: "config" })}</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid gap-6 md:grid-cols-2">
<div className="space-y-3 rounded-xl border border-border/70 p-4">
<Label htmlFor="manual-review">{t("system.fields.manualReview", { ns: "config" })}</Label>
<div className="flex items-center gap-3">
<Checkbox
id="manual-review"
checked={draft.requireManualReview}
onCheckedChange={(checked) => updateDraft("requireManualReview", checked === true)}
disabled={loading || saving}
/>
<Label htmlFor="manual-review" className="text-sm font-medium">
{draft.requireManualReview
? t("system.states.enabled", { ns: "config" })
: t("system.states.disabled", { ns: "config" })}
</Label>
<CardContent className="space-y-8">
<section className="space-y-4">
<div className="flex flex-wrap items-end justify-between gap-3">
<div className="space-y-1">
<h3 className="text-base font-semibold">{t("system.title", { ns: "config" })}</h3>
</div>
</div>
<div className="space-y-3 rounded-xl border border-border/70 p-4">
<Label htmlFor="auto-settlement">{t("system.fields.autoSettlement", { ns: "config" })}</Label>
<div className="flex items-center gap-3">
<Checkbox
id="auto-settlement"
checked={draft.autoSettlement}
onCheckedChange={(checked) => updateDraft("autoSettlement", checked === true)}
<div className="space-y-5 rounded-2xl border border-border/60 bg-muted/10 px-4 py-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="space-y-1">
<Label className="text-sm font-medium">{t("system.fields.manualReview", { ns: "config" })}</Label>
</div>
<BinaryChoice
active={draft.requireManualReview}
disabled={loading || saving}
onChange={(value) => updateDraft("requireManualReview", value)}
leftLabel={t("system.states.disabled", { ns: "config" })}
rightLabel={t("system.states.enabled", { ns: "config" })}
/>
<Label htmlFor="auto-settlement" className="text-sm font-medium">
{draft.autoSettlement
? t("system.states.enabled", { ns: "config" })
: t("system.states.disabled", { ns: "config" })}
</div>
<div className="h-px bg-border/60" />
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="space-y-1">
<Label className="text-sm font-medium">{t("system.fields.autoSettlement", { ns: "config" })}</Label>
</div>
<BinaryChoice
active={draft.autoSettlement}
disabled={loading || saving}
onChange={(value) => updateDraft("autoSettlement", value)}
leftLabel={t("system.states.disabled", { ns: "config" })}
rightLabel={t("system.states.enabled", { ns: "config" })}
/>
</div>
<div className="h-px bg-border/60" />
<div className="grid gap-2">
<Label htmlFor="cooldown-minutes" className="text-sm font-medium">
{t("system.fields.cooldownMinutes", { ns: "config" })}
</Label>
<Input
id="cooldown-minutes"
type="number"
min="0"
step="1"
value={draft.cooldownMinutes}
onChange={(e) => updateDraft("cooldownMinutes", e.target.value)}
disabled={loading || saving}
className="max-w-[240px]"
/>
</div>
<div className="flex items-center gap-4 pt-2">
<Button onClick={() => void handleSave()} disabled={!dirty || loading || saving}>
{saving ? t("saving", { ns: "adminUsers" }) : t("actions.save", { ns: "adminUsers" })}
</Button>
{dirty && (
<Button
variant="outline"
onClick={() => {
setDraft(saved);
setDirty(false);
}}
>
{t("system.discard", { ns: "config" })}
</Button>
)}
</div>
</div>
</div>
</section>
<div className="space-y-2">
<Label htmlFor="cooldown-minutes">{t("system.fields.cooldownMinutes", { ns: "config" })}</Label>
<Input
id="cooldown-minutes"
type="number"
min="0"
step="1"
value={draft.cooldownMinutes}
onChange={(e) => updateDraft("cooldownMinutes", e.target.value)}
disabled={loading || saving}
/>
</div>
<div className="flex items-center gap-4">
<Button onClick={() => void handleSave()} disabled={!dirty || loading || saving}>
{saving ? t("saving", { ns: "adminUsers" }) : t("actions.save", { ns: "adminUsers" })}
</Button>
{dirty && (
<Button
variant="outline"
onClick={() => {
setDraft(saved);
setDirty(false);
}}
>
{t("system.discard", { ns: "config" })}
</Button>
)}
</div>
<section className="space-y-4 border-t border-border/60 pt-6">
<div className="space-y-1">
<h3 className="text-base font-semibold">{t("wallet.title", { ns: "config" })}</h3>
</div>
<WalletConfigDocScreen embedded />
</section>
</CardContent>
</Card>
<WalletConfigDocScreen />
</div>
);
}