Organize project by 5 core modules based on requirement docs. Co-authored-by: null <4804959+fnvtk@users.noreply.github.com>
179 lines
5.8 KiB
TypeScript
179 lines
5.8 KiB
TypeScript
"use client"
|
|
|
|
import { useEffect, useMemo, useState } from "react"
|
|
import { Badge } from "@/components/ui/badge"
|
|
import { Skeleton } from "@/components/ui/skeleton"
|
|
|
|
type Row = {
|
|
id: string
|
|
name: string
|
|
email: string
|
|
phone: string
|
|
rfmScore: number
|
|
lastActiveAt: string
|
|
tags: string[]
|
|
}
|
|
|
|
type ApiResp = {
|
|
data: Row[]
|
|
pagination: { page: number; pageSize: number; total: number; totalPages: number }
|
|
}
|
|
|
|
export default function UserList({ queryString }: { queryString: string }) {
|
|
const [data, setData] = useState<Row[]>([])
|
|
const [total, setTotal] = useState(0)
|
|
const [loading, setLoading] = useState(false)
|
|
const [error, setError] = useState<string | null>(null)
|
|
|
|
useEffect(() => {
|
|
let aborted = false
|
|
const controller = new AbortController()
|
|
|
|
async function load() {
|
|
setLoading(true)
|
|
setError(null)
|
|
try {
|
|
const res = await fetch(`/api/users?${queryString}`, {
|
|
signal: controller.signal,
|
|
cache: "no-store",
|
|
})
|
|
if (!res.ok) throw new Error(`请求失败: ${res.status}`)
|
|
const json: ApiResp = await res.json()
|
|
if (!aborted) {
|
|
setData(json.data || [])
|
|
setTotal(json.pagination?.total || 0)
|
|
}
|
|
} catch (e: any) {
|
|
if (!aborted && e.name !== "AbortError") {
|
|
setError(e?.message || "未知错误")
|
|
}
|
|
} finally {
|
|
if (!aborted) setLoading(false)
|
|
}
|
|
}
|
|
|
|
load()
|
|
return () => {
|
|
aborted = true
|
|
controller.abort()
|
|
}
|
|
}, [queryString])
|
|
|
|
const rows = useMemo(() => data, [data])
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="rounded-lg border bg-white p-4">
|
|
<div className="space-y-3">
|
|
<Skeleton className="h-4 w-32" />
|
|
<div className="space-y-2">
|
|
{Array.from({ length: 5 }).map((_, i) => (
|
|
<Skeleton key={i} className="h-12 w-full" />
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<div className="rounded-lg border bg-white p-4">
|
|
<div className="text-red-600 text-center py-8">
|
|
<div className="text-lg font-medium mb-2">加载失败</div>
|
|
<div className="text-sm">{error}</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="rounded-lg border bg-white">
|
|
<div className="flex items-center justify-between p-4 border-b bg-gray-50">
|
|
<div className="text-sm text-gray-600">
|
|
共 <span className="font-medium text-gray-900">{total}</span> 条用户数据
|
|
</div>
|
|
{total > 0 && <div className="text-xs text-gray-500">显示前 {Math.min(20, total)} 条</div>}
|
|
</div>
|
|
|
|
<div className="overflow-x-auto">
|
|
<table className="min-w-full text-sm">
|
|
<thead className="bg-gray-50 border-b">
|
|
<tr className="text-left text-gray-600">
|
|
<th className="px-4 py-3 font-medium">姓名</th>
|
|
<th className="px-4 py-3 font-medium">邮箱</th>
|
|
<th className="px-4 py-3 font-medium">手机号</th>
|
|
<th className="px-4 py-3 font-medium">RFM分数</th>
|
|
<th className="px-4 py-3 font-medium">最近活跃</th>
|
|
<th className="px-4 py-3 font-medium">标签</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-gray-100">
|
|
{rows.map((r) => (
|
|
<tr key={r.id} className="hover:bg-gray-50 transition-colors">
|
|
<td className="px-4 py-3 font-medium text-gray-900">{r.name}</td>
|
|
<td className="px-4 py-3 text-gray-600">{r.email}</td>
|
|
<td className="px-4 py-3 text-gray-600 font-mono">{r.phone}</td>
|
|
<td className="px-4 py-3">
|
|
<Badge
|
|
variant={r.rfmScore >= 70 ? "default" : r.rfmScore >= 40 ? "secondary" : "outline"}
|
|
className="font-medium"
|
|
>
|
|
{r.rfmScore}
|
|
</Badge>
|
|
</td>
|
|
<td className="px-4 py-3 text-gray-600 text-xs">{formatDateTime(r.lastActiveAt)}</td>
|
|
<td className="px-4 py-3">
|
|
<div className="flex flex-wrap gap-1">
|
|
{(r.tags || []).slice(0, 3).map((t) => (
|
|
<Badge key={t} variant="outline" className="text-xs px-2 py-0.5">
|
|
{t}
|
|
</Badge>
|
|
))}
|
|
{(r.tags || []).length > 3 && (
|
|
<Badge variant="outline" className="text-xs px-2 py-0.5">
|
|
+{r.tags.length - 3}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
{!rows.length && (
|
|
<tr>
|
|
<td className="px-4 py-12 text-center text-gray-500" colSpan={6}>
|
|
<div className="flex flex-col items-center gap-2">
|
|
<div className="text-lg">暂无数据</div>
|
|
<div className="text-sm">尝试调整筛选条件</div>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function formatDateTime(iso: string) {
|
|
try {
|
|
const d = new Date(iso)
|
|
const now = new Date()
|
|
const diffMs = now.getTime() - d.getTime()
|
|
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24))
|
|
|
|
if (diffDays === 0) {
|
|
return "今天 " + d.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" })
|
|
} else if (diffDays === 1) {
|
|
return "昨天 " + d.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" })
|
|
} else if (diffDays < 7) {
|
|
return `${diffDays}天前`
|
|
} else {
|
|
return d.toLocaleDateString("zh-CN", { month: "short", day: "numeric" })
|
|
}
|
|
} catch {
|
|
return iso.slice(0, 10)
|
|
}
|
|
}
|