feat(admin, i18n): enhance reports, draws, config, and player workflows
This commit is contained in:
@@ -12,6 +12,7 @@ import { getAdminPlayerTicketItems } from "@/api/admin-player-tickets";
|
||||
import { getAdminTransferOrders, getAdminWalletTransactions } from "@/api/admin-wallet";
|
||||
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
||||
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
|
||||
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
|
||||
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
||||
import { AdminLoadingState, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
@@ -42,6 +43,7 @@ import { LotteryApiBizError } from "@/types/api/errors";
|
||||
import type { AdminPlayerRow, AdminPlayerWalletRow } from "@/types/api/admin-player";
|
||||
import type { AdminPlayerTicketItemRow } from "@/types/api/admin-player-tickets";
|
||||
import type { AdminTransferOrderItem, AdminWalletTxnItem } from "@/types/api/admin-wallet";
|
||||
import { Eye } from "lucide-react";
|
||||
|
||||
function playerStatusLabel(status: number, t: (key: string) => string): string {
|
||||
if (status === 0) return t("statusNormal");
|
||||
@@ -309,6 +311,9 @@ export function PlayerDetailConsole({ playerId }: { playerId: number }) {
|
||||
<ProfileField label={t("authSource")}>
|
||||
{playerAuthSourceLabel(player, t)}
|
||||
</ProfileField>
|
||||
<ProfileField label={t("riskTags", { defaultValue: "风控标签" })}>
|
||||
{player.risk_tags && player.risk_tags.length > 0 ? player.risk_tags.join(", ") : "—"}
|
||||
</ProfileField>
|
||||
<ProfileField label={t("status")}>
|
||||
<PlayerStatusBadge status={player.status} t={t} />
|
||||
</ProfileField>
|
||||
@@ -408,7 +413,10 @@ export function PlayerDetailConsole({ playerId }: { playerId: number }) {
|
||||
<TabsContent value="tickets" className="mt-0">
|
||||
<Card className="admin-list-card">
|
||||
<CardHeader className="admin-list-header">
|
||||
<CardTitle className="admin-list-title">{t("tabTickets")}</CardTitle>
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="admin-list-title">{t("tabTickets")}</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">{t("ticketTableHint")}</p>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="admin-list-content">
|
||||
<div className="admin-table-shell">
|
||||
@@ -416,21 +424,29 @@ export function PlayerDetailConsole({ playerId }: { playerId: number }) {
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("ticketNo", { ns: "tickets" })}</TableHead>
|
||||
<TableHead>{t("orderNo", { ns: "tickets" })}</TableHead>
|
||||
<TableHead>{t("drawNo", { ns: "tickets" })}</TableHead>
|
||||
<TableHead>{t("playCode", { ns: "tickets" })}</TableHead>
|
||||
<TableHead>{t("number", { ns: "tickets" })}</TableHead>
|
||||
<TableHead className="text-center">{t("actualDeduct", { ns: "tickets" })}</TableHead>
|
||||
<TableHead>{t("status", { ns: "tickets" })}</TableHead>
|
||||
<TableHead>{t("failReason", { ns: "tickets" })}</TableHead>
|
||||
<TableHead className="text-center">{t("winAmount", { ns: "tickets" })}</TableHead>
|
||||
<TableHead>{t("placedAt", { ns: "tickets" })}</TableHead>
|
||||
<TableHead>{t("updatedAt", { ns: "tickets" })}</TableHead>
|
||||
<TableHead className="sticky right-0 z-20 w-12 bg-muted text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
|
||||
{t("table.actions", { ns: "common" })}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{ticketsLoading && tickets.length === 0 ? (
|
||||
<AdminTableLoadingRow colSpan={7} />
|
||||
<AdminTableLoadingRow colSpan={11} />
|
||||
) : null}
|
||||
{tickets.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">{playCodeLabel(row.play_code)}</TableCell>
|
||||
<TableCell className="font-mono text-xs">{row.original_number ?? "—"}</TableCell>
|
||||
@@ -442,13 +458,36 @@ export function PlayerDetailConsole({ playerId }: { playerId: number }) {
|
||||
{ticketStatusText(row.status, t)}
|
||||
</AdminStatusBadge>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[14rem] text-xs text-muted-foreground">
|
||||
{row.fail_reason_text ?? row.fail_reason_code ?? "—"}
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-xs tabular-nums">
|
||||
{row.jackpot_win_amount_minor > 0
|
||||
? `${row.win_amount_formatted} + ${row.jackpot_win_amount_formatted}`
|
||||
: row.win_amount_formatted}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
{row.placed_at ? formatDt(row.placed_at) : "—"}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
{row.updated_at ? formatDt(row.updated_at) : "—"}
|
||||
</TableCell>
|
||||
<TableCell className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
|
||||
<AdminRowActionsMenu
|
||||
actions={[
|
||||
{
|
||||
key: "view-ticket-in-list",
|
||||
label: t("viewTicketInList", { ns: "tickets" }),
|
||||
icon: Eye,
|
||||
href: `/admin/tickets?player_id=${player.id}&number=${encodeURIComponent(row.ticket_no)}${row.draw_no ? `&draw_no=${encodeURIComponent(row.draw_no)}` : ""}`,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{!ticketsLoading && tickets.length === 0 ? (
|
||||
<AdminTableNoResourceRow colSpan={7} className="text-muted-foreground" />
|
||||
<AdminTableNoResourceRow colSpan={11} className="text-muted-foreground" />
|
||||
) : null}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
@@ -82,6 +82,17 @@ const PLAYER_STATUS_OPTIONS = [
|
||||
{ value: 2, label: "statusBanned" },
|
||||
];
|
||||
|
||||
function parseRiskTagsInput(text: string): string[] {
|
||||
return Array.from(
|
||||
new Set(
|
||||
text
|
||||
.split(/[,,\s]+/)
|
||||
.map((tag) => tag.trim())
|
||||
.filter((tag) => tag.length > 0),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function PlayersConsole(): React.ReactElement {
|
||||
const { t } = useTranslation(["players", "common"]);
|
||||
const tRef = useTranslationRef(["players", "common"]);
|
||||
@@ -121,6 +132,7 @@ export function PlayersConsole(): React.ReactElement {
|
||||
const [formNickname, setFormNickname] = useState("");
|
||||
const [formDefaultCurrency, setFormDefaultCurrency] = useState("NPR");
|
||||
const [formStatus, setFormStatus] = useState(0);
|
||||
const [formRiskTags, setFormRiskTags] = useState("");
|
||||
const [formAgentNodeId, setFormAgentNodeId] = useState<number | undefined>(undefined);
|
||||
const [createAgentOptions, setCreateAgentOptions] = useState<FlatAgentOption[]>([]);
|
||||
const [createAgentLoading, setCreateAgentLoading] = useState(false);
|
||||
@@ -211,6 +223,7 @@ export function PlayersConsole(): React.ReactElement {
|
||||
setFormNickname("");
|
||||
setFormDefaultCurrency("NPR");
|
||||
setFormStatus(0);
|
||||
setFormRiskTags("");
|
||||
setAccountOpen(true);
|
||||
}
|
||||
|
||||
@@ -269,6 +282,7 @@ export function PlayersConsole(): React.ReactElement {
|
||||
setFormNickname(row.nickname ?? "");
|
||||
setFormDefaultCurrency(row.default_currency);
|
||||
setFormStatus(row.status);
|
||||
setFormRiskTags((row.risk_tags ?? []).join(", "));
|
||||
setAccountOpen(true);
|
||||
}
|
||||
|
||||
@@ -337,6 +351,11 @@ export function PlayersConsole(): React.ReactElement {
|
||||
if (formStatus !== editingPlayer?.status) {
|
||||
body.status = formStatus;
|
||||
}
|
||||
const nextRiskTags = parseRiskTagsInput(formRiskTags);
|
||||
const prevRiskTags = editingPlayer?.risk_tags ?? [];
|
||||
if (JSON.stringify(nextRiskTags) !== JSON.stringify(prevRiskTags)) {
|
||||
body.risk_tags = nextRiskTags;
|
||||
}
|
||||
|
||||
if (Object.keys(body).length === 0) {
|
||||
toast.success(t("noChanges"));
|
||||
@@ -517,6 +536,7 @@ export function PlayersConsole(): React.ReactElement {
|
||||
<TableHead>{t("sitePlayerId")}</TableHead>
|
||||
<TableHead>{t("username")}</TableHead>
|
||||
<TableHead>{t("nickname")}</TableHead>
|
||||
<TableHead className="whitespace-nowrap">{t("riskTags", { defaultValue: "风控标签" })}</TableHead>
|
||||
<TableHead className="whitespace-nowrap">{t("currency")}</TableHead>
|
||||
<TableHead className="whitespace-nowrap">{t("fundingMode")}</TableHead>
|
||||
<TableHead className="whitespace-nowrap text-center">{t("balance")}</TableHead>
|
||||
@@ -528,12 +548,13 @@ export function PlayersConsole(): React.ReactElement {
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading && items.length === 0 ? (
|
||||
<AdminTableLoadingRow colSpan={13} />
|
||||
<AdminTableLoadingRow colSpan={14} />
|
||||
) : items.length === 0 ? (
|
||||
<AdminTableNoResourceRow colSpan={13} className="text-muted-foreground" />
|
||||
<AdminTableNoResourceRow colSpan={14} className="text-muted-foreground" />
|
||||
) : (
|
||||
items.map((row) => {
|
||||
const balances = playerBalanceCells(row, formatAdminMinorUnits);
|
||||
const riskTags = row.risk_tags ?? [];
|
||||
return (
|
||||
<TableRow key={row.id}>
|
||||
<TableCell className="tabular-nums">#{row.id}</TableCell>
|
||||
@@ -546,6 +567,22 @@ export function PlayersConsole(): React.ReactElement {
|
||||
</TableCell>
|
||||
<TableCell>{row.username ?? "—"}</TableCell>
|
||||
<TableCell>{row.nickname ?? "—"}</TableCell>
|
||||
<TableCell className="max-w-[16rem]">
|
||||
{riskTags.length > 0 ? (
|
||||
<div className="flex flex-nowrap items-center gap-1 overflow-x-auto whitespace-nowrap" title={riskTags.join(", ")}>
|
||||
{riskTags.map((tag) => (
|
||||
<span
|
||||
key={`${row.id}-${tag}`}
|
||||
className="inline-flex shrink-0 items-center rounded-full border border-amber-200 bg-amber-50 px-2 py-0.5 text-[11px] font-medium leading-4 text-amber-900"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{row.default_currency}</TableCell>
|
||||
<TableCell>
|
||||
<PlayerFundingModeBadge row={row} />
|
||||
@@ -791,24 +828,61 @@ export function PlayersConsole(): React.ReactElement {
|
||||
</>
|
||||
)}
|
||||
{accountMode === "edit" && (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="player-edit-status">{t("status")}</Label>
|
||||
<Select
|
||||
value={String(formStatus)}
|
||||
onValueChange={(v) => setFormStatus(Number(v))}
|
||||
>
|
||||
<SelectTrigger id="player-edit-status">
|
||||
<SelectValue>{playerStatusLabelT(formStatus, t)}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PLAYER_STATUS_OPTIONS.map((o) => (
|
||||
<SelectItem key={o.value} value={String(o.value)}>
|
||||
{t(o.label)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<>
|
||||
<div className="rounded-lg border bg-muted/30 px-3 py-2.5 text-sm">
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("fundingMode", { defaultValue: "资金模式" })}
|
||||
</p>
|
||||
<p className="mt-1 font-medium">
|
||||
{editingPlayer ? <PlayerFundingModeBadge row={editingPlayer} /> : "—"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("authSource", { defaultValue: "登录来源" })}
|
||||
</p>
|
||||
<p className="mt-1 font-medium">
|
||||
{editingPlayer?.auth_source === "main_site_sso"
|
||||
? t("authMainSite", { defaultValue: "主站 SSO" })
|
||||
: editingPlayer?.auth_source === "lottery_native"
|
||||
? t("authNative", { defaultValue: "彩票端" })
|
||||
: editingPlayer?.auth_source ?? "—"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="player-edit-risk-tags">
|
||||
{t("riskTags", { defaultValue: "风控标签" })}
|
||||
</Label>
|
||||
<Input
|
||||
id="player-edit-risk-tags"
|
||||
value={formRiskTags}
|
||||
placeholder={t("riskTagsPlaceholder", { defaultValue: "如:高频、大额、需复核;多个标签用逗号分隔" })}
|
||||
onChange={(e) => setFormRiskTags(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="player-edit-status">{t("status")}</Label>
|
||||
<Select
|
||||
value={String(formStatus)}
|
||||
onValueChange={(v) => setFormStatus(Number(v))}
|
||||
>
|
||||
<SelectTrigger id="player-edit-status">
|
||||
<SelectValue>{playerStatusLabelT(formStatus, t)}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PLAYER_STATUS_OPTIONS.map((o) => (
|
||||
<SelectItem key={o.value} value={String(o.value)}>
|
||||
{t(o.label)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
|
||||
Reference in New Issue
Block a user