394 lines
15 KiB
TypeScript
394 lines
15 KiB
TypeScript
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;
|