feat(frontend-v2): ProfilePage 真实数据页 (#987-#990) 接 profile API 6 区块

This commit is contained in:
QihuAI Design committed 2026-08-23 12:28:41 +08:00
1 parent 39fcdbf6da
commit 8a9c2e4868
1 file changed
+393
@@ -0,0 +1,393 @@
import { useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
User as UserIcon,
Crown,
Coins,
History,
KeyRound,
ShoppingBag,
Loader2,
ChevronLeft,
ChevronRight,
Mail,
Phone,
CalendarDays,
type LucideIcon,
} from 'lucide-react';
import {
useProfile,
useProfileBalance,
useProfileMembership,
useProfileLoginLogs,
useProfileApiKeys,
useProfilePurchasedApps,
} from '@/hooks/useApi';
function StatCard({
icon: Icon,
label,
value,
hint,
accent,
}: {
icon: LucideIcon;
label: string;
value: string;
hint?: string;
accent: string;
}) {
return (
<Card>
<CardContent className="p-5">
<div className="flex items-center justify-between mb-3">
<span className="text-sm text-muted-foreground">{label}</span>
<div className={`w-9 h-9 rounded-lg flex items-center justify-center ${accent}`}>
<Icon size={18} />
</div>
</div>
<div className="text-2xl font-semibold text-foreground tabular-nums">{value}</div>
{hint && <p className="text-xs text-muted-foreground mt-1">{hint}</p>}
</CardContent>
</Card>
);
}
function formatNumber(n: number): string {
return n.toLocaleString('zh-CN');
}
function formatTime(iso: string | null): string {
if (!iso) return '—';
const d = new Date(iso);
if (isNaN(d.getTime())) return iso;
return d.toLocaleString('zh-CN', { hour12: false });
}
function InfoRow({ icon: Icon, label, value }: { icon: LucideIcon; label: string; value: string }) {
return (
<div className="flex items-center gap-3 text-sm">
<Icon size={15} className="text-muted-foreground shrink-0" />
<span className="text-muted-foreground w-16 shrink-0">{label}</span>
<span className="text-foreground truncate">{value}</span>
</div>
);
}
export function ProfilePage() {
const [logPage, setLogPage] = useState(1);
const logPageSize = 8;
const { data: profile, isLoading: pLoading } = useProfile();
const { data: balance, isLoading: bLoading } = useProfileBalance();
const { data: membership } = useProfileMembership();
const { data: loginLogs, isLoading: logsLoading, isFetching: logsFetching } = useProfileLoginLogs(
logPage,
logPageSize
);
const { data: apiKeys, isLoading: keysLoading } = useProfileApiKeys();
const { data: purchasedApps, isLoading: appsLoading } = useProfilePurchasedApps();
const logTotal = loginLogs?.total ?? 0;
const logItems = loginLogs?.items ?? [];
const logTotalPages = Math.max(1, Math.ceil(logTotal / logPageSize));
const isExpired = membership?.is_expired ?? false;
const hasMembership = membership?.has_membership ?? false;
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-foreground">个人中心</h2>
<p className="text-sm text-muted-foreground mt-1">
管理账户资料、会员状态、余额与 API 密钥
</p>
</div>
{profile?.created_at && (
<Badge variant="outline" className="gap-1">
<CalendarDays size={12} /> 注册于 {formatTime(profile.created_at).split(' ')[0]}
</Badge>
)}
</div>
{/* Profile card + membership */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
{/* Profile identity */}
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle>账户资料</CardTitle>
<CardDescription>当前登录账户的基础信息</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center gap-4">
<div className="w-14 h-14 rounded-full bg-primary/10 text-primary flex items-center justify-center text-xl font-semibold shrink-0">
{profile?.username?.[0]?.toUpperCase() ?? '—'}
</div>
<div className="min-w-0">
<p className="text-base font-medium text-foreground truncate">
{pLoading ? '加载中…' : profile?.username ?? '—'}
</p>
<p className="text-xs text-muted-foreground mt-0.5">
{profile?.role ?? 'user'} · {profile?.status ?? 'active'}
</p>
</div>
</div>
<div className="h-px bg-border" />
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<InfoRow icon={Mail} label="邮箱" value={profile?.email || '未设置'} />
<InfoRow icon={Phone} label="手机" value={profile?.phone || '未设置'} />
<InfoRow
icon={UserIcon}
label="用户ID"
value={profile?.id ? profile.id.slice(0, 12) : '—'}
/>
<InfoRow
icon={CalendarDays}
label="更新"
value={formatTime(profile?.updated_at ?? null).split(' ')[0]}
/>
</div>
</CardContent>
</Card>
{/* Membership */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Crown size={16} className="text-amber-500" /> 会员状态
</CardTitle>
<CardDescription>当前订阅与有效期</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{!hasMembership ? (
<div className="text-center py-6">
<Crown size={28} className="text-muted-foreground mx-auto mb-2" />
<p className="text-sm text-muted-foreground">尚未开通会员</p>
</div>
) : (
<>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">套餐</span>
<Badge
variant={isExpired ? 'destructive' : 'default'}
className={isExpired ? '' : 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300'}
>
{isExpired ? '已过期' : '生效中'}
</Badge>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">计划 ID</span>
<span className="text-foreground">{membership?.plan_id ?? '—'}</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">生效</span>
<span className="text-foreground">{formatTime(membership?.start_at ?? null).split(' ')[0]}</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">到期</span>
<span className="text-foreground">{formatTime(membership?.expire_at ?? null).split(' ')[0]}</span>
</div>
</>
)}
</CardContent>
</Card>
</div>
{/* Balance stat row */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<StatCard
icon={Coins}
label="Token 余额"
value={bLoading ? '—' : formatNumber(balance ?? 0)}
hint="当前可用 tokens"
accent="bg-primary/10 text-primary"
/>
<StatCard
icon={KeyRound}
label="API 密钥"
value={keysLoading ? '—' : `${apiKeys?.length ?? 0}`}
hint="已创建的密钥数"
accent="bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300"
/>
<StatCard
icon={ShoppingBag}
label="已购应用"
value={appsLoading ? '—' : `${purchasedApps?.length ?? 0}`}
hint="已下单的应用服务"
accent="bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300"
/>
</div>
{/* Login logs */}
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<div>
<CardTitle className="flex items-center gap-2">
<History size={16} className="text-muted-foreground" /> 登录记录
</CardTitle>
<CardDescription>最近账户登录 IP 与设备信息</CardDescription>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="overflow-x-auto rounded-lg border border-border">
<table className="w-full text-sm">
<thead>
<tr className="bg-muted/50 text-muted-foreground">
<th className="text-left font-medium px-4 py-2.5">时间</th>
<th className="text-left font-medium px-4 py-2.5">IP</th>
<th className="text-left font-medium px-4 py-2.5">User Agent</th>
</tr>
</thead>
<tbody>
{logsLoading ? (
<tr>
<td colSpan={3} className="text-center py-10 text-muted-foreground">
<Loader2 size={20} className="animate-spin mx-auto text-primary" />
</td>
</tr>
) : logItems.length === 0 ? (
<tr>
<td colSpan={3} className="text-center py-10 text-muted-foreground">
暂无登录记录
</td>
</tr>
) : (
logItems.map((log) => (
<tr key={log.id} className="border-t border-border hover:bg-muted/30 transition-colors">
<td className="px-4 py-2.5 text-muted-foreground whitespace-nowrap">
{formatTime(log.login_at)}
</td>
<td className="px-4 py-2.5 text-foreground tabular-nums">{log.ip}</td>
<td className="px-4 py-2.5 text-muted-foreground truncate max-w-[280px]">
{log.user_agent}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Pagination */}
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">
第 {logPage} / {logTotalPages} 页 · 共 {formatNumber(logTotal)} 条
</span>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
className="gap-1"
disabled={logPage <= 1 || logsFetching}
onClick={() => setLogPage((p) => Math.max(1, p - 1))}
>
<ChevronLeft size={14} /> 上一页
</Button>
<Button
variant="outline"
size="sm"
className="gap-1"
disabled={logPage >= logTotalPages || logsFetching}
onClick={() => setLogPage((p) => Math.min(logTotalPages, p + 1))}
>
下一页 <ChevronRight size={14} />
</Button>
</div>
</div>
</CardContent>
</Card>
{/* Two-column: API keys + purchased apps */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* API Keys */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<KeyRound size={16} className="text-muted-foreground" /> API 密钥
</CardTitle>
<CardDescription>用于第三方集成的访问凭证</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{keysLoading ? (
<div className="text-center py-8">
<Loader2 size={20} className="animate-spin mx-auto text-primary" />
</div>
) : !apiKeys || apiKeys.length === 0 ? (
<div className="text-center py-8 text-muted-foreground text-sm">尚未创建密钥</div>
) : (
apiKeys.map((k) => (
<div
key={k.id}
className="flex items-center justify-between gap-3 rounded-lg border border-border px-3 py-2.5"
>
<div className="min-w-0">
<p className="text-sm text-foreground font-medium truncate">{k.name}</p>
<p className="text-xs text-muted-foreground tabular-nums font-mono truncate">
{k.key_preview}
</p>
</div>
<Badge variant={k.status === 'active' ? 'default' : 'secondary'}>
{k.status === 'active' ? '启用' : k.status}
</Badge>
</div>
))
)}
</CardContent>
</Card>
{/* Purchased apps */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<ShoppingBag size={16} className="text-muted-foreground" /> 已购应用
</CardTitle>
<CardDescription>已下单的应用服务列表</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{appsLoading ? (
<div className="text-center py-8">
<Loader2 size={20} className="animate-spin mx-auto text-primary" />
</div>
) : !purchasedApps || purchasedApps.length === 0 ? (
<div className="text-center py-8 text-muted-foreground text-sm">暂无已购应用</div>
) : (
purchasedApps.map((app) => (
<div
key={app.id}
className="flex items-center gap-3 rounded-lg border border-border px-3 py-2.5"
>
{app.app_icon ? (
<img
src={app.app_icon}
alt=""
className="w-9 h-9 rounded-md object-cover shrink-0"
/>
) : (
<div className="w-9 h-9 rounded-md bg-muted flex items-center justify-center text-muted-foreground shrink-0">
<ShoppingBag size={16} />
</div>
)}
<div className="min-w-0 flex-1">
<p className="text-sm text-foreground font-medium truncate">{app.app_name}</p>
<p className="text-xs text-muted-foreground">
{app.app_type} · {formatTime(app.purchased_at).split(' ')[0]}
</p>
</div>
<span className="text-sm text-foreground tabular-nums shrink-0">
¥{formatNumber(app.price)}
</span>
</div>
))
)}
</CardContent>
</Card>
</div>
</div>
);
}
export default ProfilePage;