feat: 添加日期处理库和日历选择器,更新管理员抽奖模块
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user