feat: 实现用户列表基础版(只读,Phase 1c)

This commit is contained in:
Wang Zhuoxuan 2026-04-08 00:04:32 +08:00
parent 7b41df191f
commit cd8d384a35
4 changed files with 335 additions and 8 deletions

View File

@ -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<UserTier, string> = {
free: "免费",
pro: "Pro",
proplus: "Pro+",
}
const TIER_VARIANTS: Record<UserTier, "secondary" | "default" | "destructive"> = {
free: "secondary",
pro: "default",
proplus: "destructive",
}
const AUTH_TYPE_LABELS: Record<string, string> = {
huawei: "华为",
guest: "游客",
phone: "手机",
apple: "Apple",
google: "Google",
}
export function getColumns(): ColumnDef<User>[] {
return [
{
accessorKey: "nickname",
header: "昵称",
cell: ({ row }) => {
const nickname = row.getValue("nickname") as string | null
return (
<span className="font-medium">
{nickname || <span className="text-muted-foreground"></span>}
</span>
)
},
},
{
accessorKey: "authType",
header: "登录方式",
cell: ({ row }) => {
const authType = row.getValue("authType") as string
return (
<span className="text-muted-foreground">
{AUTH_TYPE_LABELS[authType] ?? authType}
</span>
)
},
},
{
accessorKey: "tier",
header: "订阅",
cell: ({ row }) => {
const tier = row.getValue("tier") as UserTier
return <Badge variant={TIER_VARIANTS[tier]}>{TIER_LABELS[tier]}</Badge>
},
},
{
accessorKey: "xpTotal",
header: "总 XP",
cell: ({ row }) => (
<span className="text-muted-foreground">
{(row.getValue("xpTotal") as number).toLocaleString()}
</span>
),
},
{
accessorKey: "streakDays",
header: "连续天数",
cell: ({ row }) => (
<span className="text-muted-foreground">
{row.getValue("streakDays")}
</span>
),
},
{
accessorKey: "heartsRemaining",
header: "红心",
cell: ({ row }) => (
<span className="text-muted-foreground">
{row.getValue("heartsRemaining")}
</span>
),
},
{
accessorKey: "createdAt",
header: "注册时间",
cell: ({ row }) => (
<span className="text-muted-foreground text-xs">
{new Date(row.getValue("createdAt") as string).toLocaleDateString("zh-CN")}
</span>
),
},
]
}

28
src/lib/api/user-api.ts Normal file
View File

@ -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<PaginatedResponse<User>> {
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<PaginatedResponse<User>>()
}
export async function fetchUser(id: string): Promise<ApiResponse<User>> {
return apiClient.get(`users/${id}`).json<ApiResponse<User>>()
}

View File

@ -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<UserTier, string> = {
free: "免费",
pro: "Pro",
proplus: "Pro+",
}
export default function UsersPage() {
const [users, setUsers] = useState<User[]>([])
const [loading, setLoading] = useState(true)
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [search, setSearch] = useState("")
const [tierFilter, setTierFilter] = useState<UserTier | "all">("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 (
<div className="space-y-6">
{/* 页面头部 */}
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold"></h1>
<div className="flex h-64 items-center justify-center rounded-lg border border-dashed text-muted-foreground">
Phase 1c
<span className="text-sm text-muted-foreground"> {total} </span>
</div>
{/* 筛选栏 */}
<div className="flex flex-wrap items-center gap-3">
<div className="relative max-w-xs flex-1">
<Search className="absolute left-2.5 top-2.5 size-4 text-muted-foreground" />
<Input
placeholder="搜索昵称..."
className="pl-9"
value={search}
onChange={(e) => {
setSearch(e.target.value)
setPage(1)
}}
/>
</div>
<Select
value={tierFilter}
onValueChange={(val) => {
setTierFilter(val as UserTier | "all")
setPage(1)
}}
>
<SelectTrigger className="w-28">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{Object.entries(TIER_FILTER_OPTIONS).map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 表格 */}
<div className="rounded-lg border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center text-muted-foreground"
>
...
</TableCell>
</TableRow>
) : users.length === 0 ? (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center text-muted-foreground"
>
</TableCell>
</TableRow>
) : (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{/* 分页 */}
{totalPages > 1 && (
<div className="flex items-center justify-between text-sm text-muted-foreground">
<span>
{page}/{totalPages}
</span>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
disabled={page <= 1}
onClick={() => setPage((p) => p - 1)}
>
</Button>
<Button
variant="outline"
size="sm"
disabled={page >= totalPages}
onClick={() => setPage((p) => p + 1)}
>
</Button>
</div>
</div>
)}
</div>
)
}

View File

@ -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
}