feat: 实现用户列表基础版(只读,Phase 1c)
This commit is contained in:
parent
7b41df191f
commit
cd8d384a35
96
src/components/user/columns.tsx
Normal file
96
src/components/user/columns.tsx
Normal 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
28
src/lib/api/user-api.ts
Normal 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>>()
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user