diff --git a/src/components/user/columns.tsx b/src/components/user/columns.tsx new file mode 100644 index 0000000..63189cc --- /dev/null +++ b/src/components/user/columns.tsx @@ -0,0 +1,96 @@ +import type { ColumnDef } from "@tanstack/react-table" +import { Badge } from "@/components/ui/badge" +import type { User, UserTier } from "@/types/user" + +const TIER_LABELS: Record = { + free: "免费", + pro: "Pro", + proplus: "Pro+", +} + +const TIER_VARIANTS: Record = { + free: "secondary", + pro: "default", + proplus: "destructive", +} + +const AUTH_TYPE_LABELS: Record = { + huawei: "华为", + guest: "游客", + phone: "手机", + apple: "Apple", + google: "Google", +} + +export function getColumns(): ColumnDef[] { + return [ + { + accessorKey: "nickname", + header: "昵称", + cell: ({ row }) => { + const nickname = row.getValue("nickname") as string | null + return ( + + {nickname || 未设置} + + ) + }, + }, + { + accessorKey: "authType", + header: "登录方式", + cell: ({ row }) => { + const authType = row.getValue("authType") as string + return ( + + {AUTH_TYPE_LABELS[authType] ?? authType} + + ) + }, + }, + { + accessorKey: "tier", + header: "订阅", + cell: ({ row }) => { + const tier = row.getValue("tier") as UserTier + return {TIER_LABELS[tier]} + }, + }, + { + accessorKey: "xpTotal", + header: "总 XP", + cell: ({ row }) => ( + + {(row.getValue("xpTotal") as number).toLocaleString()} + + ), + }, + { + accessorKey: "streakDays", + header: "连续天数", + cell: ({ row }) => ( + + {row.getValue("streakDays")} 天 + + ), + }, + { + accessorKey: "heartsRemaining", + header: "红心", + cell: ({ row }) => ( + + {row.getValue("heartsRemaining")} + + ), + }, + { + accessorKey: "createdAt", + header: "注册时间", + cell: ({ row }) => ( + + {new Date(row.getValue("createdAt") as string).toLocaleDateString("zh-CN")} + + ), + }, + ] +} diff --git a/src/lib/api/user-api.ts b/src/lib/api/user-api.ts new file mode 100644 index 0000000..5373656 --- /dev/null +++ b/src/lib/api/user-api.ts @@ -0,0 +1,28 @@ +import { apiClient } from "@/lib/api-client" +import type { PaginatedResponse, ApiResponse } from "@/types/api" +import type { User, UserTier } from "@/types/user" + +export interface FetchUsersParams { + page?: number + limit?: number + search?: string + tier?: UserTier +} + +export async function fetchUsers( + params: FetchUsersParams = {} +): Promise> { + const searchParams = new URLSearchParams() + if (params.page) searchParams.set("page", String(params.page)) + if (params.limit) searchParams.set("limit", String(params.limit)) + if (params.search) searchParams.set("search", params.search) + if (params.tier) searchParams.set("tier", params.tier) + + return apiClient + .get("users", { searchParams }) + .json>() +} + +export async function fetchUser(id: string): Promise> { + return apiClient.get(`users/${id}`).json>() +} diff --git a/src/routes/users/index.tsx b/src/routes/users/index.tsx index 45ad92f..9a8b398 100644 --- a/src/routes/users/index.tsx +++ b/src/routes/users/index.tsx @@ -1,10 +1,205 @@ +import { useCallback, useEffect, useState } from "react" +import { + useReactTable, + getCoreRowModel, + flexRender, +} from "@tanstack/react-table" +import { Search } from "lucide-react" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { getColumns } from "@/components/user/columns" +import { fetchUsers } from "@/lib/api/user-api" +import type { User, UserTier } from "@/types/user" + +const PAGE_SIZE = 20 + +const TIER_FILTER_OPTIONS: Record = { + free: "免费", + pro: "Pro", + proplus: "Pro+", +} + export default function UsersPage() { + const [users, setUsers] = useState([]) + const [loading, setLoading] = useState(true) + const [total, setTotal] = useState(0) + const [page, setPage] = useState(1) + const [search, setSearch] = useState("") + const [tierFilter, setTierFilter] = useState("all") + + const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)) + + const loadUsers = useCallback(async () => { + setLoading(true) + try { + const res = await fetchUsers({ + page, + limit: PAGE_SIZE, + search: search || undefined, + tier: tierFilter !== "all" ? tierFilter : undefined, + }) + setUsers(res.data) + setTotal(res.pagination.total) + } catch { + setUsers([]) + } finally { + setLoading(false) + } + }, [page, search, tierFilter]) + + useEffect(() => { + loadUsers() + }, [loadUsers]) + + const columns = getColumns() + + const table = useReactTable({ + data: users, + columns, + getCoreRowModel: getCoreRowModel(), + }) + return (
-

用户管理

-
- Phase 1c — 用户列表(只读) + {/* 页面头部 */} +
+

用户管理

+ 共 {total} 名用户
+ + {/* 筛选栏 */} +
+
+ + { + setSearch(e.target.value) + setPage(1) + }} + /> +
+ + +
+ + {/* 表格 */} +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} + + ))} + + ))} + + + {loading ? ( + + + 加载中... + + + ) : users.length === 0 ? ( + + + 暂无用户数据 + + + ) : ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + + ))} + + )) + )} + +
+
+ + {/* 分页 */} + {totalPages > 1 && ( +
+ + 第 {page}/{totalPages} 页 + +
+ + +
+
+ )}
) } diff --git a/src/types/user.ts b/src/types/user.ts index 8b26e6a..d10ebe0 100644 --- a/src/types/user.ts +++ b/src/types/user.ts @@ -1,11 +1,19 @@ -export type UserTier = "free" | "pro" | "pro_plus" +export type AuthType = "huawei" | "guest" | "phone" | "apple" | "google" +export type UserTier = "free" | "pro" | "proplus" export interface User { id: string - displayName?: string + authType: AuthType + authId: string + nickname?: string + avatarUrl?: string tier: UserTier - streak: number - totalXp: number + xpTotal: number + streakDays: number + streakLastDate?: string + heartsRemaining: number + dailyXpGoal: number + dailyXpEarned: number createdAt: string - lastActiveAt?: string + updatedAt: string }