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() {
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
{/* 页面头部 */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-2xl font-bold">用户管理</h1>
|
<h1 className="text-2xl font-bold">用户管理</h1>
|
||||||
<div className="flex h-64 items-center justify-center rounded-lg border border-dashed text-muted-foreground">
|
<span className="text-sm text-muted-foreground">共 {total} 名用户</span>
|
||||||
Phase 1c — 用户列表(只读)
|
|
||||||
</div>
|
</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>
|
</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 {
|
export interface User {
|
||||||
id: string
|
id: string
|
||||||
displayName?: string
|
authType: AuthType
|
||||||
|
authId: string
|
||||||
|
nickname?: string
|
||||||
|
avatarUrl?: string
|
||||||
tier: UserTier
|
tier: UserTier
|
||||||
streak: number
|
xpTotal: number
|
||||||
totalXp: number
|
streakDays: number
|
||||||
|
streakLastDate?: string
|
||||||
|
heartsRemaining: number
|
||||||
|
dailyXpGoal: number
|
||||||
|
dailyXpEarned: number
|
||||||
createdAt: string
|
createdAt: string
|
||||||
lastActiveAt?: string
|
updatedAt: string
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user