geo/frontend/app/(dashboard)/dashboard/settings/page.tsx

445 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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>
);
}