Optimize user detail page for asset assessment and tag info. #VERCEL_SKIP Co-authored-by: null <4804959+fnvtk@users.noreply.github.com>
155 lines
5.0 KiB
TypeScript
155 lines
5.0 KiB
TypeScript
"use client"
|
||
|
||
import { useEffect, useMemo, useState } from "react"
|
||
|
||
type Row = {
|
||
id: string
|
||
name: string
|
||
email: string
|
||
phone: string
|
||
rfmScore: number
|
||
lastActiveAt: string
|
||
tags: string[]
|
||
}
|
||
|
||
type Pagination = { page: number; pageSize: number; total: number; totalPages: number }
|
||
|
||
// The API may return different shapes; normalize them here.
|
||
function normalizeResponse(json: any): { items: Row[]; total: number; pagination?: Pagination } {
|
||
// v2 shape: { success, data: { items, pagination, totalValue } }
|
||
if (json && json.data && Array.isArray(json.data.items)) {
|
||
const items = json.data.items as Row[]
|
||
const total = json.data?.pagination?.total ?? json.total ?? items.length
|
||
return { items, total, pagination: json.data.pagination }
|
||
}
|
||
|
||
// v1 shape: { data: Row[], pagination }
|
||
if (json && Array.isArray(json.data)) {
|
||
const items = json.data as Row[]
|
||
const total = json.pagination?.total ?? json.total ?? items.length
|
||
return { items, total, pagination: json.pagination }
|
||
}
|
||
|
||
// shape: { items: Row[], pagination }
|
||
if (json && Array.isArray(json.items)) {
|
||
const items = json.items as Row[]
|
||
const total = json.pagination?.total ?? json.total ?? items.length
|
||
return { items, total, pagination: json.pagination }
|
||
}
|
||
|
||
// raw array
|
||
if (Array.isArray(json)) {
|
||
return { items: json as Row[], total: (json as Row[]).length }
|
||
}
|
||
|
||
// unknown shape
|
||
return { items: [], total: 0 }
|
||
}
|
||
|
||
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 = await res.json()
|
||
const { items, total } = normalizeResponse(json)
|
||
if (!aborted) {
|
||
setData(Array.isArray(items) ? items : [])
|
||
setTotal(typeof total === "number" ? total : 0)
|
||
}
|
||
} catch (e: any) {
|
||
if (!aborted) setError(e?.message || "未知错误")
|
||
} finally {
|
||
if (!aborted) setLoading(false)
|
||
}
|
||
}
|
||
|
||
load()
|
||
return () => {
|
||
aborted = true
|
||
controller.abort()
|
||
}
|
||
}, [queryString])
|
||
|
||
const rows = useMemo(() => (Array.isArray(data) ? data : []), [data])
|
||
|
||
if (loading) {
|
||
return <div className="rounded-lg border bg-white p-4">加载中...</div>
|
||
}
|
||
if (error) {
|
||
return <div className="rounded-lg border bg-white p-4 text-red-600">加载失败:{error}</div>
|
||
}
|
||
|
||
return (
|
||
<div className="rounded-lg border bg-white">
|
||
<div className="flex items-center justify-between p-3 border-b">
|
||
<div className="text-sm text-gray-600">共 {total} 条</div>
|
||
</div>
|
||
<div className="overflow-x-auto">
|
||
<table className="min-w-full text-sm">
|
||
<thead className="bg-gray-50">
|
||
<tr className="text-left text-gray-500">
|
||
<th className="px-3 py-2 font-medium">姓名</th>
|
||
<th className="px-3 py-2 font-medium">邮箱</th>
|
||
<th className="px-3 py-2 font-medium">手机号</th>
|
||
<th className="px-3 py-2 font-medium">RFM</th>
|
||
<th className="px-3 py-2 font-medium">最近活跃</th>
|
||
<th className="px-3 py-2 font-medium">标签</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{rows.map((r) => (
|
||
<tr key={r.id} className="border-t">
|
||
<td className="px-3 py-2">{r.name}</td>
|
||
<td className="px-3 py-2">{r.email}</td>
|
||
<td className="px-3 py-2">{r.phone}</td>
|
||
<td className="px-3 py-2">{r.rfmScore}</td>
|
||
<td className="px-3 py-2">{formatDateTime(r.lastActiveAt)}</td>
|
||
<td className="px-3 py-2">
|
||
<div className="flex flex-wrap gap-1">
|
||
{(r.tags || []).slice(0, 3).map((t) => (
|
||
<span key={t} className="px-2 py-0.5 rounded-full bg-gray-100 text-gray-700">
|
||
{t}
|
||
</span>
|
||
))}
|
||
{(r.tags || []).length > 3 && (
|
||
<span className="px-2 py-0.5 rounded-full border">+{r.tags.length - 3}</span>
|
||
)}
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
{!rows.length && (
|
||
<tr>
|
||
<td className="px-3 py-8 text-center text-gray-500" colSpan={6}>
|
||
无匹配数据
|
||
</td>
|
||
</tr>
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function formatDateTime(iso: string) {
|
||
try {
|
||
const d = new Date(iso)
|
||
return `${d.toLocaleDateString()} ${d.toLocaleTimeString()}`
|
||
} catch {
|
||
return iso
|
||
}
|
||
}
|