445 lines
14 KiB
TypeScript
445 lines
14 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useState } from "react";
|
||
import { useSession } from "next-auth/react";
|
||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||
import { Badge } from "@/components/ui/badge";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Input } from "@/components/ui/input";
|
||
import { Label } from "@/components/ui/label";
|
||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogDescription,
|
||
DialogFooter,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
} from "@/components/ui/dialog";
|
||
import { Crown, Check, X, Loader2, AlertTriangle, CheckCircle } from "lucide-react";
|
||
import { api } from "@/lib/api";
|
||
|
||
interface PlanFeature {
|
||
name: string;
|
||
included: boolean;
|
||
}
|
||
|
||
interface PlanDetail {
|
||
id: string;
|
||
name: string;
|
||
price: number;
|
||
max_queries: number;
|
||
features: PlanFeature[];
|
||
}
|
||
|
||
interface SubscriptionData {
|
||
id: string;
|
||
plan: string;
|
||
status: string;
|
||
start_date: string;
|
||
end_date: string;
|
||
amount: number | null;
|
||
payment_method: string | null;
|
||
created_at: string;
|
||
}
|
||
|
||
function ProfileTab() {
|
||
const { data: session, update } = useSession();
|
||
const [name, setName] = useState(session?.user?.name || "");
|
||
const [loading, setLoading] = useState(false);
|
||
const [error, setError] = useState("");
|
||
const [success, setSuccess] = useState(false);
|
||
|
||
const handleSave = async (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
if (!session?.accessToken) return;
|
||
setLoading(true);
|
||
setError("");
|
||
setSuccess(false);
|
||
try {
|
||
await api.auth.updateProfile(session.accessToken, { name });
|
||
await update({ name });
|
||
setSuccess(true);
|
||
} catch (err: unknown) {
|
||
const message = err instanceof Error ? err.message : "保存失败";
|
||
setError(message);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<form onSubmit={handleSave} className="space-y-4">
|
||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||
{success && <p className="text-sm text-emerald-600">个人资料已更新</p>}
|
||
<div className="space-y-2">
|
||
<Label htmlFor="profileName">用户名</Label>
|
||
<Input
|
||
id="profileName"
|
||
value={name}
|
||
onChange={(e) => setName(e.target.value)}
|
||
placeholder="请输入用户名"
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label htmlFor="profileEmail">邮箱</Label>
|
||
<Input
|
||
id="profileEmail"
|
||
type="email"
|
||
value={session?.user?.email || ""}
|
||
disabled
|
||
/>
|
||
</div>
|
||
<Button type="submit" disabled={loading}>
|
||
{loading ? "保存中..." : "保存"}
|
||
</Button>
|
||
</form>
|
||
);
|
||
}
|
||
|
||
function PasswordTab() {
|
||
const { data: session } = useSession();
|
||
const [oldPassword, setOldPassword] = useState("");
|
||
const [newPassword, setNewPassword] = useState("");
|
||
const [confirmPassword, setConfirmPassword] = useState("");
|
||
const [loading, setLoading] = useState(false);
|
||
const [error, setError] = useState("");
|
||
const [success, setSuccess] = useState(false);
|
||
|
||
const handleSubmit = async (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
setError("");
|
||
setSuccess(false);
|
||
|
||
if (newPassword.length < 8) {
|
||
setError("新密码至少需要 8 位");
|
||
return;
|
||
}
|
||
if (newPassword !== confirmPassword) {
|
||
setError("两次输入的新密码不一致");
|
||
return;
|
||
}
|
||
if (!session?.accessToken) {
|
||
setError("登录已过期,请重新登录");
|
||
return;
|
||
}
|
||
|
||
setLoading(true);
|
||
try {
|
||
await api.auth.changePassword(session.accessToken, oldPassword, newPassword);
|
||
setSuccess(true);
|
||
setOldPassword("");
|
||
setNewPassword("");
|
||
setConfirmPassword("");
|
||
} catch (err: unknown) {
|
||
const message = err instanceof Error ? err.message : "修改失败";
|
||
setError(message);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<form onSubmit={handleSubmit} className="space-y-4">
|
||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||
{success && <p className="text-sm text-emerald-600">密码修改成功</p>}
|
||
<div className="space-y-2">
|
||
<Label htmlFor="oldPassword">当前密码</Label>
|
||
<Input
|
||
id="oldPassword"
|
||
type="password"
|
||
placeholder="请输入当前密码"
|
||
value={oldPassword}
|
||
onChange={(e) => setOldPassword(e.target.value)}
|
||
required
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label htmlFor="newPassword">新密码</Label>
|
||
<Input
|
||
id="newPassword"
|
||
type="password"
|
||
placeholder="请输入新密码(至少8位)"
|
||
value={newPassword}
|
||
onChange={(e) => setNewPassword(e.target.value)}
|
||
required
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label htmlFor="confirmNewPassword">确认新密码</Label>
|
||
<Input
|
||
id="confirmNewPassword"
|
||
type="password"
|
||
placeholder="请再次输入新密码"
|
||
value={confirmPassword}
|
||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||
required
|
||
/>
|
||
</div>
|
||
<Button type="submit" disabled={loading}>
|
||
{loading ? "修改中..." : "修改密码"}
|
||
</Button>
|
||
</form>
|
||
);
|
||
}
|
||
|
||
function SubscriptionTab() {
|
||
const { data: session } = useSession();
|
||
const [plans, setPlans] = useState<PlanDetail[]>([]);
|
||
const [currentSub, setCurrentSub] = useState<SubscriptionData | null>(null);
|
||
const [loading, setLoading] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [success, setSuccess] = useState<string | null>(null);
|
||
|
||
const [dialogOpen, setDialogOpen] = useState(false);
|
||
const [dialogType, setDialogType] = useState<"subscribe" | "cancel">("subscribe");
|
||
const [selectedPlan, setSelectedPlan] = useState("");
|
||
const [actionLoading, setActionLoading] = useState(false);
|
||
|
||
const token = session?.accessToken;
|
||
const currentPlan = currentSub?.plan || "free";
|
||
|
||
useEffect(() => {
|
||
loadData();
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [token]);
|
||
|
||
async function loadData() {
|
||
setLoading(true);
|
||
setError(null);
|
||
try {
|
||
const plansData = await api.subscriptions.getPlans();
|
||
setPlans(plansData);
|
||
if (token) {
|
||
try {
|
||
const subData = await api.subscriptions.getCurrent(token);
|
||
setCurrentSub(subData);
|
||
} catch {
|
||
// 暂无订阅记录,保持 null
|
||
setCurrentSub(null);
|
||
}
|
||
}
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : "加载失败");
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}
|
||
|
||
function openSubscribeDialog(planId: string) {
|
||
setSelectedPlan(planId);
|
||
setDialogType("subscribe");
|
||
setDialogOpen(true);
|
||
}
|
||
|
||
function openCancelDialog() {
|
||
setDialogType("cancel");
|
||
setDialogOpen(true);
|
||
}
|
||
|
||
async function handleConfirm() {
|
||
if (!token) return;
|
||
setActionLoading(true);
|
||
setSuccess(null);
|
||
try {
|
||
if (dialogType === "subscribe") {
|
||
await api.subscriptions.subscribe(token, selectedPlan);
|
||
setSuccess("订阅成功");
|
||
} else {
|
||
await api.subscriptions.cancel(token);
|
||
setSuccess("订阅已取消");
|
||
}
|
||
await loadData();
|
||
setDialogOpen(false);
|
||
setTimeout(() => setSuccess(null), 3000);
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : "操作失败");
|
||
setDialogOpen(false);
|
||
} finally {
|
||
setActionLoading(false);
|
||
}
|
||
}
|
||
|
||
const currentPlanName = plans.find((p) => p.id === currentPlan)?.name || currentPlan;
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
{error && (
|
||
<div className="flex items-center gap-2 rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||
<AlertTriangle className="h-4 w-4 shrink-0" />
|
||
<span>{error}</span>
|
||
</div>
|
||
)}
|
||
|
||
{success && (
|
||
<div className="flex items-center gap-2 rounded-md bg-emerald-50 px-3 py-2 text-sm text-emerald-700">
|
||
<CheckCircle className="h-4 w-4 shrink-0" />
|
||
<span>{success}</span>
|
||
</div>
|
||
)}
|
||
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle className="text-base">当前套餐</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="space-y-4">
|
||
<div className="flex items-center gap-3">
|
||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-amber-100">
|
||
<Crown className="h-5 w-5 text-amber-600" />
|
||
</div>
|
||
<div>
|
||
<p className="font-medium">{currentPlanName}</p>
|
||
<div className="flex items-center gap-2">
|
||
<Badge variant="secondary">当前套餐</Badge>
|
||
{currentSub?.status && (
|
||
<span className="text-xs text-muted-foreground">
|
||
状态: {currentSub.status === "active" ? "生效中" : currentSub.status}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{currentPlan !== "free" && currentSub && (
|
||
<div className="text-sm text-muted-foreground">
|
||
<p>有效期: {currentSub.start_date} 至 {currentSub.end_date}</p>
|
||
{currentSub.amount && <p>金额: ¥{currentSub.amount}</p>}
|
||
</div>
|
||
)}
|
||
{currentPlan !== "free" && (
|
||
<Button variant="destructive" size="sm" onClick={openCancelDialog}>
|
||
取消订阅
|
||
</Button>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<div>
|
||
<h3 className="mb-4 text-lg font-semibold">套餐对比</h3>
|
||
{loading ? (
|
||
<div className="flex items-center justify-center py-12">
|
||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||
</div>
|
||
) : (
|
||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||
{plans.map((plan) => {
|
||
const isCurrent = plan.id === currentPlan;
|
||
const canUpgrade = plan.id !== currentPlan;
|
||
return (
|
||
<Card
|
||
key={plan.id}
|
||
className={isCurrent ? "border-primary ring-1 ring-primary" : ""}
|
||
>
|
||
<CardHeader className="pb-3">
|
||
<CardTitle className="text-base">{plan.name}</CardTitle>
|
||
<CardDescription>
|
||
<span className="text-2xl font-bold text-foreground">¥{plan.price}</span>
|
||
<span className="text-muted-foreground">/月</span>
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<ul className="space-y-2">
|
||
{plan.features.map((feature, idx) => (
|
||
<li key={idx} className="flex items-center gap-2 text-sm">
|
||
{feature.included ? (
|
||
<Check className="h-4 w-4 shrink-0 text-emerald-500" />
|
||
) : (
|
||
<X className="h-4 w-4 shrink-0 text-muted-foreground/50" />
|
||
)}
|
||
<span className={feature.included ? "" : "text-muted-foreground/60"}>
|
||
{feature.name}
|
||
</span>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
{isCurrent ? (
|
||
<Badge className="mt-4 w-full justify-center" variant="default">
|
||
当前套餐
|
||
</Badge>
|
||
) : canUpgrade ? (
|
||
<Button
|
||
className="mt-4 w-full"
|
||
size="sm"
|
||
onClick={() => openSubscribeDialog(plan.id)}
|
||
>
|
||
立即升级
|
||
</Button>
|
||
) : null}
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||
<DialogContent>
|
||
<DialogHeader>
|
||
<DialogTitle>
|
||
{dialogType === "subscribe" ? "确认订阅" : "取消订阅"}
|
||
</DialogTitle>
|
||
<DialogDescription>
|
||
{dialogType === "subscribe"
|
||
? `确认使用模拟支付订阅「${plans.find((p) => p.id === selectedPlan)?.name || selectedPlan}」套餐?`
|
||
: "确认取消当前订阅?取消后将在当前计费周期结束后恢复为免费版。"}
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
<DialogFooter>
|
||
<Button variant="outline" onClick={() => setDialogOpen(false)} disabled={actionLoading}>
|
||
取消
|
||
</Button>
|
||
<Button onClick={handleConfirm} disabled={actionLoading} variant={dialogType === "cancel" ? "destructive" : "default"}>
|
||
{actionLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||
确认
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default function SettingsPage() {
|
||
return (
|
||
<div className="space-y-6">
|
||
<div>
|
||
<h2 className="text-2xl font-bold tracking-tight">设置</h2>
|
||
<p className="text-muted-foreground">管理您的账户和订阅信息</p>
|
||
</div>
|
||
|
||
<Tabs defaultValue="profile" className="w-full">
|
||
<TabsList>
|
||
<TabsTrigger value="profile">个人资料</TabsTrigger>
|
||
<TabsTrigger value="password">密码修改</TabsTrigger>
|
||
<TabsTrigger value="subscription">订阅管理</TabsTrigger>
|
||
</TabsList>
|
||
<TabsContent value="profile" className="mt-4">
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle className="text-base">个人资料</CardTitle>
|
||
<CardDescription>管理您的基本信息</CardDescription>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<ProfileTab />
|
||
</CardContent>
|
||
</Card>
|
||
</TabsContent>
|
||
<TabsContent value="password" className="mt-4">
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle className="text-base">密码修改</CardTitle>
|
||
<CardDescription>更改您的登录密码</CardDescription>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<PasswordTab />
|
||
</CardContent>
|
||
</Card>
|
||
</TabsContent>
|
||
<TabsContent value="subscription" className="mt-4">
|
||
<SubscriptionTab />
|
||
</TabsContent>
|
||
</Tabs>
|
||
</div>
|
||
);
|
||
}
|