import { ArrowLeft, CheckCircle2, Coins, Gift, Search, Sparkles, Users, } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState, type FormEvent, } from "react"; import { useSearchParams } from "react-router-dom"; import { toast } from "sonner"; import { adminApi, ApiError } from "../../api/client"; import type { LedgerQuery, SortOrder, UserDetail, UsersQuery, UserSummary, UserUsageAggregate, } from "../../api/types"; import { DataTable, type DataColumn } from "../../components/data-table"; import { DateRangeControl } from "../../components/date-range-control"; import { FilterControl } from "../../components/filter-control"; import { Badge, Button, Card, Dialog, ErrorState, Input, LoadingState, PageHeader, Textarea, } from "../../components/primitives"; import { TableToolbar } from "../../components/table-toolbar"; import { useCursorPage } from "../../hooks/use-cursor-page"; import { createIdempotencyKey, formatDateTime, formatNumber, statusLabel, usageTypeLabel, } from "../../lib/format"; import { useAuth } from "../auth/auth-context"; import { LedgerFilters } from "../credits/ledger-filters"; import { LedgerTable } from "../credits/ledger-table"; import { useLedgerFilters } from "../credits/use-ledger-filters"; export function UsersPage() { const { auth } = useAuth(); const [searchParams, setSearchParams] = useSearchParams(); const role = auth.status === "authenticated" ? auth.role : "ANALYST"; const selectedUserId = searchParams.get("userId") ?? undefined; function selectUser(userId?: string) { setSearchParams((current) => { const params = new URLSearchParams(current); if (userId) params.set("userId", userId); else params.delete("userId"); return params; }); } return selectedUserId ? ( selectUser()} /> ) : ( ); } function UserList({ onSelect }: { onSelect: (userId: string) => void }) { const [searchParams, setSearchParams] = useSearchParams(); const initialQuery = searchParams.get("q") ?? ""; const [query, setQuery] = useState(initialQuery); const [activeQuery, setActiveQuery] = useState(initialQuery); const [from, setFrom] = useState(searchParams.get("from") ?? ""); const [until, setUntil] = useState(searchParams.get("until") ?? ""); const [status, setStatus] = useState<"" | UserSummary["status"]>( (searchParams.get("status") as UserSummary["status"] | null) ?? "", ); const [order, setOrder] = useState( searchParams.get("order") === "asc" ? "asc" : "desc", ); const usersQuery = useMemo( () => ({ q: activeQuery || undefined, from: from || undefined, until: until || undefined, status: status || undefined, sort: "createdAt", order, limit: 50, }), [activeQuery, from, order, status, until], ); const fetchUsers = useCallback((value: UsersQuery) => adminApi.users(value), []); const { items, nextCursor, loading, loadingMore, error, loadMore, reload, } = useCursorPage(usersQuery, fetchUsers); useEffect(() => { const params = new URLSearchParams(); if (activeQuery) params.set("q", activeQuery); if (from) params.set("from", from); if (until) params.set("until", until); if (status) params.set("status", status); params.set("sort", "createdAt"); params.set("order", order); setSearchParams(params, { replace: true }); }, [activeQuery, from, order, setSearchParams, status, until]); function submit(event: FormEvent) { event.preventDefault(); const search = query.trim(); setActiveQuery(search); } const columns = useMemo[]>( () => [ { id: "user", header: "用户", cell: ({ row }) => ( ), }, { accessorKey: "status", header: "状态", cell: ({ getValue }) => , }, { accessorKey: "consumedCredits", header: "累计使用", cell: ({ getValue }) => ( {formatNumber(Number(getValue()))} ), }, { accessorKey: "creditBalance", header: "当前积分", cell: ({ getValue }) => ( {formatNumber(Number(getValue()))} ), }, { accessorKey: "createdAt", header: "注册时间", cell: ({ getValue }) => ( {formatDateTime(String(getValue()))} ), }, { id: "actions", header: "操作", cell: ({ row }) => ( ), }, ], [onSelect], ); return (
{ setQuery(""); setActiveQuery(""); setFrom(""); setUntil(""); setStatus(""); setOrder("desc"); }} > { setFrom(value.from ?? ""); setUntil(value.until ?? ""); }} />
{activeQuery ? "查询结果" : "全部用户"} · {formatNumber(items.length)} 位
{error ? (
) : loading ? ( ) : ( setOrder(nextOrder)} footer={ nextCursor ? (
) : null } /> )}
); } function UserDetailView({ userId, canGrant, onBack, }: { userId: string; canGrant: boolean; onBack: () => void; }) { const [user, setUser] = useState(); const [loading, setLoading] = useState(true); const [error, setError] = useState(); const [grantOpen, setGrantOpen] = useState(false); const filters = useLedgerFilters("ledger"); const requestVersion = useRef(0); const fetchLedger = useCallback( (value: LedgerQuery) => adminApi.ledger(userId, value), [userId], ); const ledgerPage = useCursorPage(filters.query, fetchLedger); const load = useCallback(async () => { const version = ++requestVersion.current; setLoading(true); setError(undefined); try { const detail = await adminApi.user(userId); if (requestVersion.current !== version) return; setUser(detail); } catch (requestError) { if (requestVersion.current === version) setError(requestError); } finally { if (requestVersion.current === version) setLoading(false); } }, [userId]); useEffect(() => { void load(); }, [load]); if (error || ledgerPage.error) { return ( { void load(); ledgerPage.reload(); }} /> ); } if (loading || ledgerPage.loading || !user) return ; const usage = user.usage ?? []; const requests = usage.reduce((total, item) => total + item.requests, 0); const chargedCredits = usage.reduce((total, item) => total + item.chargedCredits, 0); const inviterUserId = user.referral?.inviterUserId ?? user.referredByUserId; const qualifiedUsage = user.qualifiedUsage || requests > 0; return (
setGrantOpen(true)}> 人工赠送积分 ) : undefined } />
{(user.displayName || "用").slice(0, 1)}
{user.displayName || "未命名用户"}

当前积分

{formatNumber(user.creditBalance)}
{user.referral ? ( ) : null}
filters.update({ sort, order })} onReferenceFilter={(referenceId) => filters.update({ referenceId })} footer={ ledgerPage.nextCursor ? (
) : null } />
{usage.length > 0 ? : null}
{canGrant ? ( { await load(); ledgerPage.reload(); }} /> ) : null}
); } export function GrantDialog({ open, user, onOpenChange, onSuccess, }: { open: boolean; user: UserDetail; onOpenChange: (open: boolean) => void; onSuccess: () => Promise; }) { const [step, setStep] = useState<"input" | "confirm">("input"); const [amount, setAmount] = useState(""); const [reason, setReason] = useState(""); const [error, setError] = useState(""); const [submitting, setSubmitting] = useState(false); const [outcomeUnknown, setOutcomeUnknown] = useState(false); const [idempotencyKey, setIdempotencyKey] = useState(createIdempotencyKey); function reset() { setStep("input"); setAmount(""); setReason(""); setError(""); setSubmitting(false); setOutcomeUnknown(false); setIdempotencyKey(createIdempotencyKey()); } function handleOpenChange(nextOpen: boolean) { if (!nextOpen && outcomeUnknown) { setError("赠送结果尚未确认,请在当前窗口使用同一请求安全重试"); return; } onOpenChange(nextOpen); if (!nextOpen) reset(); } function review() { const numericAmount = Number(amount); if (!Number.isSafeInteger(numericAmount) || numericAmount < 1 || numericAmount > 100_000) { setError("积分必须是 1 至 100,000 的整数"); return; } if (reason.trim().length < 4) { setError("请填写至少 4 个字符的赠送原因"); return; } setError(""); setStep("confirm"); } async function submit() { setSubmitting(true); setError(""); try { await adminApi.grantCredits({ userId: user.userId, amount: Number(amount), reason: reason.trim(), idempotencyKey, }); onOpenChange(false); reset(); toast.success("积分赠送成功,账本已更新"); await onSuccess(); } catch (requestError) { const unknown = !(requestError instanceof ApiError) || requestError.status === 0 || requestError.status >= 500; setOutcomeUnknown(unknown); setError( unknown ? "赠送结果尚未确认;重试会复用同一请求,不会重复到账" : requestError instanceof ApiError ? requestError.message : "赠送失败,请重试", ); setSubmitting(false); } } return ( {step === "input" ? (
setAmount(event.target.value)} type="number" min={1} max={100000} step={1} inputMode="numeric" />