feat: 添加日期处理库和日历选择器,更新管理员抽奖模块

This commit is contained in:
2026-05-09 17:40:35 +08:00
parent f19cdb48ad
commit ac3f28459b
28 changed files with 2186 additions and 117 deletions

View File

@@ -9,12 +9,21 @@ import {
getAdminTransferOrders,
getAdminWalletTransactions,
} from "@/api/admin-wallet";
import { AdminDateRangeField } from "@/components/admin/admin-date-range-field";
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 { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
@@ -138,6 +147,42 @@ const emptyTxnFilters: TxnFilters = {
abnormalOnly: false,
};
/** 下拉「不限」值;请求时转为空串不传参 */
const WALLET_FILTER_ALL = "__all__";
/** 与 {@see WalletTransactionListController}、{@see LotteryTransferService} 当前写入的 biz_type 一致 */
const WALLET_TXN_BIZ_OPTIONS: { value: string; label: string }[] = [
{ value: "transfer_in", label: "主站转入" },
{ value: "transfer_out", label: "主站转出" },
{ value: "transfer_out_refund", label: "转出失败回补" },
];
/** 与 {@see WalletTransactionListController::ALLOWED_STATUS} 一致 */
const WALLET_TXN_STATUS_OPTIONS: { value: string; label: string }[] = [
{ value: "posted", label: "已记账" },
{ value: "pending_reconcile", label: "待对账" },
];
/** 与 {@see TransferOrderListController::ALLOWED_STATUS} 一致 */
const TRANSFER_ORDER_STATUS_OPTIONS: { value: string; label: string }[] = [
{ value: "processing", label: "处理中" },
{ value: "success", label: "成功" },
{ value: "failed", label: "失败" },
{ value: "pending_reconcile", label: "待对账" },
];
/** Base UI 的 SelectValue 会直接显示 `value`,需把哨兵转成「不限」、其余转成选项文案 */
function walletAdminSelectDisplayedLabel(
raw: unknown,
options: readonly { value: string; label: string }[],
): string {
const v = raw == null ? "" : String(raw);
if (v === "" || v === WALLET_FILTER_ALL) {
return "不限";
}
return options.find((o) => o.value === v)?.label ?? v;
}
export function WalletConsole(): React.ReactElement {
return (
<Tabs defaultValue="txns" className="w-full">
@@ -165,6 +210,7 @@ function TransferOrdersPanel(): React.ReactElement {
const [loading, setLoading] = useState(true);
const [err, setErr] = useState<string | null>(null);
const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(20);
const [draft, setDraft] = useState<TransferFilters>(emptyTransferFilters);
const [applied, setApplied] = useState<TransferFilters>(emptyTransferFilters);
@@ -176,7 +222,7 @@ function TransferOrdersPanel(): React.ReactElement {
applied.playerId.trim() === "" ? undefined : Number(applied.playerId);
const d = await getAdminTransferOrders({
page,
per_page: 20,
per_page: perPage,
abnormal: applied.abnormalOnly || undefined,
player_id:
player_id !== undefined && !Number.isNaN(player_id) && player_id > 0
@@ -196,7 +242,7 @@ function TransferOrdersPanel(): React.ReactElement {
} finally {
setLoading(false);
}
}, [page, applied]);
}, [page, perPage, applied]);
useEffect(() => {
void load();
@@ -207,6 +253,12 @@ function TransferOrdersPanel(): React.ReactElement {
setPage(1);
};
const resetFilters = () => {
setDraft(emptyTransferFilters);
setApplied(emptyTransferFilters);
setPage(1);
};
return (
<Card>
<CardHeader>
@@ -240,7 +292,7 @@ function TransferOrdersPanel(): React.ReactElement {
<Label htmlFor="to-account"></Label>
<Input
id="to-account"
placeholder="site_player_id / 用户名"
placeholder="主站玩家 ID 或用户名(模糊)"
value={draft.playerAccount}
onChange={(e) => setDraft((d) => ({ ...d, playerAccount: e.target.value }))}
/>
@@ -255,32 +307,47 @@ function TransferOrdersPanel(): React.ReactElement {
onChange={(e) => setDraft((d) => ({ ...d, playerId: e.target.value }))}
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="to-from"></Label>
<Input
id="to-from"
type="date"
value={draft.createdFrom}
onChange={(e) => setDraft((d) => ({ ...d, createdFrom: e.target.value }))}
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="to-to"></Label>
<Input
id="to-to"
type="date"
value={draft.createdTo}
onChange={(e) => setDraft((d) => ({ ...d, createdTo: e.target.value }))}
<div className="sm:col-span-2 lg:col-span-2 xl:col-span-2">
<AdminDateRangeField
id="to-created-range"
label="请求日期范围"
from={draft.createdFrom}
to={draft.createdTo}
onRangeChange={(r) =>
setDraft((d) => ({ ...d, createdFrom: r.from, createdTo: r.to }))
}
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="to-status"></Label>
<Input
id="to-status"
placeholder="逗号分隔,如 pending_reconcile,failed"
value={draft.statusCsv}
onChange={(e) => setDraft((d) => ({ ...d, statusCsv: e.target.value }))}
/>
<Select
modal={false}
value={
draft.statusCsv === "" || !TRANSFER_ORDER_STATUS_OPTIONS.some((o) => o.value === draft.statusCsv)
? WALLET_FILTER_ALL
: draft.statusCsv
}
onValueChange={(v) =>
setDraft((d) => ({
...d,
statusCsv: v == null || v === WALLET_FILTER_ALL ? "" : String(v),
}))
}
>
<SelectTrigger id="to-status" className="h-8 w-full">
<SelectValue>
{(v) => walletAdminSelectDisplayedLabel(v, TRANSFER_ORDER_STATUS_OPTIONS)}
</SelectValue>
</SelectTrigger>
<SelectContent align="start" sideOffset={6}>
<SelectItem value={WALLET_FILTER_ALL}></SelectItem>
{TRANSFER_ORDER_STATUS_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col justify-end gap-2 sm:col-span-2 lg:col-span-1">
<span className="text-sm font-medium leading-none"></span>
@@ -299,6 +366,9 @@ function TransferOrdersPanel(): React.ReactElement {
<Button type="button" size="sm" onClick={() => runSearch()}>
</Button>
<Button type="button" size="sm" variant="outline" onClick={() => resetFilters()}>
</Button>
<Button type="button" size="sm" variant="secondary" onClick={() => void load()}>
</Button>
@@ -373,32 +443,19 @@ function TransferOrdersPanel(): React.ReactElement {
</TableBody>
</Table>
</div>
<div className="flex flex-col gap-3 border-t border-border pt-4 sm:flex-row sm:items-center sm:justify-between">
<p className="text-xs text-muted-foreground">
{data.total} · {data.page} /{" "}
{Math.max(1, Math.ceil(data.total / data.per_page))}
</p>
<div className="flex flex-wrap justify-end gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={page <= 1 || loading}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
</Button>
<Button
type="button"
variant="outline"
size="sm"
disabled={loading || data.page >= Math.ceil(data.total / data.per_page)}
onClick={() => setPage((p) => p + 1)}
>
</Button>
</div>
</div>
<AdminListPaginationFooter
selectId="wallet-transfer-orders-per-page"
total={data.total}
page={page}
lastPage={Math.max(1, Math.ceil(data.total / Math.max(1, data.per_page)))}
perPage={perPage}
loading={loading}
onPerPageChange={(next) => {
setPerPage(next);
setPage(1);
}}
onPageChange={setPage}
/>
</>
) : null}
</CardContent>
@@ -412,6 +469,7 @@ function WalletTxnsPanel(): React.ReactElement {
const [loading, setLoading] = useState(true);
const [err, setErr] = useState<string | null>(null);
const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(20);
const [draft, setDraft] = useState<TxnFilters>(emptyTxnFilters);
const [applied, setApplied] = useState<TxnFilters>(emptyTxnFilters);
@@ -423,7 +481,7 @@ function WalletTxnsPanel(): React.ReactElement {
applied.playerId.trim() === "" ? undefined : Number(applied.playerId);
const d = await getAdminWalletTransactions({
page,
per_page: 20,
per_page: perPage,
abnormal: applied.abnormalOnly || undefined,
player_id:
player_id !== undefined && !Number.isNaN(player_id) && player_id > 0
@@ -444,7 +502,7 @@ function WalletTxnsPanel(): React.ReactElement {
} finally {
setLoading(false);
}
}, [page, applied]);
}, [page, perPage, applied]);
useEffect(() => {
void load();
@@ -455,6 +513,12 @@ function WalletTxnsPanel(): React.ReactElement {
setPage(1);
};
const resetFilters = () => {
setDraft(emptyTxnFilters);
setApplied(emptyTxnFilters);
setPage(1);
};
return (
<Card>
<CardHeader>
@@ -488,7 +552,7 @@ function WalletTxnsPanel(): React.ReactElement {
<Label htmlFor="tx-account"></Label>
<Input
id="tx-account"
placeholder="site_player_id / 用户名"
placeholder="主站玩家 ID 或用户名(模糊)"
value={draft.playerAccount}
onChange={(e) => setDraft((d) => ({ ...d, playerAccount: e.target.value }))}
/>
@@ -505,38 +569,75 @@ function WalletTxnsPanel(): React.ReactElement {
</div>
<div className="grid gap-1.5">
<Label htmlFor="tx-biz"></Label>
<Input
id="tx-biz"
placeholder="如 transfer_in"
value={draft.bizType}
onChange={(e) => setDraft((d) => ({ ...d, bizType: e.target.value }))}
/>
<Select
modal={false}
value={
draft.bizType === "" || !WALLET_TXN_BIZ_OPTIONS.some((o) => o.value === draft.bizType)
? WALLET_FILTER_ALL
: draft.bizType
}
onValueChange={(v) =>
setDraft((d) => ({
...d,
bizType: v == null || v === WALLET_FILTER_ALL ? "" : String(v),
}))
}
>
<SelectTrigger id="tx-biz" className="h-8 w-full">
<SelectValue>
{(v) => walletAdminSelectDisplayedLabel(v, WALLET_TXN_BIZ_OPTIONS)}
</SelectValue>
</SelectTrigger>
<SelectContent align="start" sideOffset={6}>
<SelectItem value={WALLET_FILTER_ALL}></SelectItem>
{WALLET_TXN_BIZ_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-1.5">
<Label htmlFor="tx-status"></Label>
<Input
id="tx-status"
placeholder="posted 或逗号分隔"
value={draft.statusCsv}
onChange={(e) => setDraft((d) => ({ ...d, statusCsv: e.target.value }))}
/>
<Select
modal={false}
value={
draft.statusCsv === "" || !WALLET_TXN_STATUS_OPTIONS.some((o) => o.value === draft.statusCsv)
? WALLET_FILTER_ALL
: draft.statusCsv
}
onValueChange={(v) =>
setDraft((d) => ({
...d,
statusCsv: v == null || v === WALLET_FILTER_ALL ? "" : String(v),
}))
}
>
<SelectTrigger id="tx-status" className="h-8 w-full">
<SelectValue>
{(v) => walletAdminSelectDisplayedLabel(v, WALLET_TXN_STATUS_OPTIONS)}
</SelectValue>
</SelectTrigger>
<SelectContent align="start" sideOffset={6}>
<SelectItem value={WALLET_FILTER_ALL}></SelectItem>
{WALLET_TXN_STATUS_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-1.5">
<Label htmlFor="tx-from"></Label>
<Input
id="tx-from"
type="date"
value={draft.createdFrom}
onChange={(e) => setDraft((d) => ({ ...d, createdFrom: e.target.value }))}
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="tx-to"></Label>
<Input
id="tx-to"
type="date"
value={draft.createdTo}
onChange={(e) => setDraft((d) => ({ ...d, createdTo: e.target.value }))}
<div className="sm:col-span-2 lg:col-span-2 xl:col-span-2">
<AdminDateRangeField
id="tx-created-range"
label="请求日期范围"
from={draft.createdFrom}
to={draft.createdTo}
onRangeChange={(r) =>
setDraft((d) => ({ ...d, createdFrom: r.from, createdTo: r.to }))
}
/>
</div>
<div className="flex flex-col justify-end gap-2 sm:col-span-2 lg:col-span-1">
@@ -556,6 +657,9 @@ function WalletTxnsPanel(): React.ReactElement {
<Button type="button" size="sm" onClick={() => runSearch()}>
</Button>
<Button type="button" size="sm" variant="outline" onClick={() => resetFilters()}>
</Button>
<Button type="button" size="sm" variant="secondary" onClick={() => void load()}>
</Button>
@@ -630,32 +734,19 @@ function WalletTxnsPanel(): React.ReactElement {
</TableBody>
</Table>
</div>
<div className="flex flex-col gap-3 border-t border-border pt-4 sm:flex-row sm:items-center sm:justify-between">
<p className="text-xs text-muted-foreground">
{data.total} · {data.page} /{" "}
{Math.max(1, Math.ceil(data.total / data.per_page))}
</p>
<div className="flex flex-wrap justify-end gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={page <= 1 || loading}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
</Button>
<Button
type="button"
variant="outline"
size="sm"
disabled={loading || data.page >= Math.ceil(data.total / data.per_page)}
onClick={() => setPage((p) => p + 1)}
>
</Button>
</div>
</div>
<AdminListPaginationFooter
selectId="wallet-transactions-per-page"
total={data.total}
page={page}
lastPage={Math.max(1, Math.ceil(data.total / Math.max(1, data.per_page)))}
perPage={perPage}
loading={loading}
onPerPageChange={(next) => {
setPerPage(next);
setPage(1);
}}
onPageChange={setPage}
/>
</>
) : null}
</CardContent>