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

1326 lines
48 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 Link from "next/link";
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, Compass, Bell, TrendingDown, TrendingUp, Users, Globe, Key, Eye, EyeOff, Shield, Zap, CircleDot, Info } from "lucide-react";
import { api } from "@/lib/api";
import { fetchWithAuth } from "@/lib/api/client";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
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>
);
}
function OnboardingTab() {
const { data: _session } = useSession();
const [showConfirm, setShowConfirm] = useState(false);
const handleRestartOnboarding = () => {
setShowConfirm(true);
};
const confirmRestart = () => {
window.location.href = "/onboarding";
};
return (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<Compass className="h-5 w-5 text-blue-600" />
</CardTitle>
<CardDescription>
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="rounded-lg border bg-muted/50 p-4">
<h4 className="font-medium mb-2"></h4>
<ul className="text-sm text-muted-foreground space-y-1">
<li className="flex items-center gap-2">
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-blue-100 text-xs text-blue-600">1</span>
</li>
<li className="flex items-center gap-2">
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-blue-100 text-xs text-blue-600">2</span>
</li>
<li className="flex items-center gap-2">
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-blue-100 text-xs text-blue-600">3</span>
AI平台
</li>
<li className="flex items-center gap-2">
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-blue-100 text-xs text-blue-600">4</span>
</li>
<li className="flex items-center gap-2">
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-blue-100 text-xs text-blue-600">5</span>
</li>
</ul>
</div>
<div className="flex items-center gap-3">
<Button onClick={handleRestartOnboarding}>
<Compass className="mr-2 h-4 w-4" />
</Button>
<span className="text-sm text-muted-foreground">
5
</span>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base"></CardTitle>
<CardDescription>
</CardDescription>
</CardHeader>
<CardContent>
<div className="grid gap-3 sm:grid-cols-2">
<Link href="/dashboard">
<Button variant="outline" className="w-full justify-start">
<Compass className="mr-2 h-4 w-4" />
</Button>
</Link>
<Link href="/brands">
<Button variant="outline" className="w-full justify-start">
<Compass className="mr-2 h-4 w-4" />
</Button>
</Link>
<Link href="/compare">
<Button variant="outline" className="w-full justify-start">
<Compass className="mr-2 h-4 w-4" />
</Button>
</Link>
<Link href="/dashboard/queries">
<Button variant="outline" className="w-full justify-start">
<Compass className="mr-2 h-4 w-4" />
</Button>
</Link>
</div>
</CardContent>
</Card>
<Dialog open={showConfirm} onOpenChange={setShowConfirm}>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setShowConfirm(false)}>
</Button>
<Button onClick={confirmRestart}>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
// ============================================================
// 告警设置标签页
// ============================================================
interface AlertSettingItem {
id: string;
brand_id: string;
user_id: string;
alert_type: string;
enabled: boolean;
threshold: number | null;
created_at: string;
updated_at: string;
}
interface BrandItem {
id: string;
name: string;
}
const ALERT_TYPE_LABELS: Record<string, { label: string; description: string; icon: React.ElementType }> = {
score_drop: {
label: "评分下降",
description: "品牌可见性评分下降超过阈值时通知",
icon: TrendingDown,
},
score_rise: {
label: "评分上升",
description: "品牌可见性评分上升超过阈值时通知",
icon: TrendingUp,
},
negative_sentiment: {
label: "负面情感",
description: "AI回答中出现对品牌的负面评价时通知",
icon: AlertTriangle,
},
competitor_overtake: {
label: "竞品超越",
description: "竞品评分超过我方品牌时通知",
icon: Users,
},
new_platform_mention: {
label: "新平台提及",
description: "品牌在新的AI平台被首次提及时通知",
icon: Globe,
},
};
const DEFAULT_THRESHOLDS: Record<string, number> = {
score_drop: 5,
score_rise: 5,
negative_sentiment: 1,
competitor_overtake: 0,
new_platform_mention: 1,
};
function AlertSettingsTab() {
const { data: session } = useSession();
const [brands, setBrands] = useState<BrandItem[]>([]);
const [selectedBrandId, setSelectedBrandId] = useState<string>("");
const [settings, setSettings] = useState<AlertSettingItem[]>([]);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const token = session?.accessToken;
// 加载品牌列表
useEffect(() => {
if (!token) return;
loadBrands();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [token]);
// 品牌变化时加载设置
useEffect(() => {
if (!token || !selectedBrandId) return;
loadSettings();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [token, selectedBrandId]);
async function loadBrands() {
if (!token) return;
try {
const data = await api.brands.list(token);
const brandList = (data as { items: BrandItem[] }).items || [];
setBrands(brandList);
if (brandList.length > 0 && !selectedBrandId) {
setSelectedBrandId(brandList[0].id);
}
} catch {
// 静默失败
}
}
async function loadSettings() {
if (!token || !selectedBrandId) return;
setLoading(true);
setError(null);
try {
const data = await api.alerts.getSettings(selectedBrandId, token);
setSettings((data as { items: AlertSettingItem[] }).items || []);
} catch (err) {
setError(err instanceof Error ? err.message : "加载告警设置失败");
} finally {
setLoading(false);
}
}
async function handleSave() {
if (!token) return;
setSaving(true);
setError(null);
setSuccess(false);
try {
const updateData = settings.map((s) => ({
brand_id: s.brand_id,
alert_type: s.alert_type,
enabled: s.enabled,
threshold: s.threshold ?? undefined,
}));
await api.alerts.updateSettings(updateData, token);
setSuccess(true);
setTimeout(() => setSuccess(false), 3000);
} catch (err) {
setError(err instanceof Error ? err.message : "保存失败");
} finally {
setSaving(false);
}
}
function handleToggle(alertType: string) {
setSettings((prev) =>
prev.map((s) =>
s.alert_type === alertType ? { ...s, enabled: !s.enabled } : s,
),
);
}
function handleThresholdChange(alertType: string, value: number) {
setSettings((prev) =>
prev.map((s) =>
s.alert_type === alertType ? { ...s, threshold: value } : s,
),
);
}
// 确保所有告警类型都有设置项
const allSettings = Object.keys(ALERT_TYPE_LABELS).map((alertType) => {
const existing = settings.find((s) => s.alert_type === alertType);
return (
existing || {
id: "",
brand_id: selectedBrandId,
user_id: "",
alert_type: alertType,
enabled: true,
threshold: DEFAULT_THRESHOLDS[alertType] ?? 5,
created_at: "",
updated_at: "",
}
);
});
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></span>
</div>
)}
{/* 品牌选择 */}
{brands.length > 1 && (
<Card>
<CardHeader>
<CardTitle className="text-base"></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<select
className="w-full rounded-md border bg-background px-3 py-2 text-sm"
value={selectedBrandId}
onChange={(e) => setSelectedBrandId(e.target.value)}
>
{brands.map((brand) => (
<option key={brand.id} value={brand.id}>
{brand.name}
</option>
))}
</select>
</CardContent>
</Card>
)}
{/* 告警类型设置 */}
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<Bell className="h-5 w-5 text-blue-600" />
</CardTitle>
<CardDescription>
</CardDescription>
</CardHeader>
<CardContent>
{loading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : (
<div className="space-y-4">
{allSettings.map((setting) => {
const config = ALERT_TYPE_LABELS[setting.alert_type];
if (!config) return null;
const IconComponent = config.icon;
return (
<div
key={setting.alert_type}
className="flex items-start gap-4 rounded-lg border p-4"
>
{/* 开关 */}
<div className="flex items-center">
<input
type="checkbox"
id={`alert-${setting.alert_type}`}
checked={setting.enabled}
onChange={() => handleToggle(setting.alert_type)}
className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
</div>
{/* 图标和描述 */}
<div className="flex-1">
<label
htmlFor={`alert-${setting.alert_type}`}
className="flex cursor-pointer items-center gap-2"
>
<IconComponent className="h-4 w-4 text-muted-foreground" />
<span className="font-medium text-sm">{config.label}</span>
</label>
<p className="mt-1 text-xs text-muted-foreground">
{config.description}
</p>
{/* 阈值设置 */}
{setting.enabled && setting.alert_type !== "competitor_overtake" && (
<div className="mt-3 flex items-center gap-3">
<Label className="text-xs whitespace-nowrap">
:
</Label>
<Input
type="number"
min={0}
max={100}
step={0.5}
value={setting.threshold ?? DEFAULT_THRESHOLDS[setting.alert_type] ?? 5}
onChange={(e) =>
handleThresholdChange(
setting.alert_type,
parseFloat(e.target.value) || 0,
)
}
className="h-8 w-24 text-sm"
/>
<span className="text-xs text-muted-foreground">
{setting.alert_type === "score_drop" || setting.alert_type === "score_rise"
? "分(评分变化超过此值触发)"
: setting.alert_type === "negative_sentiment"
? "次(负面提及次数达到此值触发)"
: "次(新平台提及次数达到此值触发)"}
</span>
</div>
)}
</div>
</div>
);
})}
</div>
)}
<div className="mt-6 flex items-center gap-3">
<Button onClick={handleSave} disabled={saving || loading}>
{saving ? "保存中..." : "保存设置"}
</Button>
<span className="text-xs text-muted-foreground">
</span>
</div>
</CardContent>
</Card>
{/* 说明 */}
<Card>
<CardHeader>
<CardTitle className="text-base"></CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3 text-sm text-muted-foreground">
<div className="flex items-start gap-2">
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-red-100 text-[10px] font-bold text-red-600">!</span>
<div>
<span className="font-medium text-foreground"> (Critical)</span> -
</div>
</div>
<div className="flex items-start gap-2">
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-orange-100 text-[10px] font-bold text-orange-600">!</span>
<div>
<span className="font-medium text-foreground"> (Warning)</span> -
</div>
</div>
<div className="flex items-start gap-2">
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-blue-100 text-[10px] font-bold text-blue-600">i</span>
<div>
<span className="font-medium text-foreground"> (Info)</span> -
</div>
</div>
<p className="mt-2 text-xs">
190
</p>
</div>
</CardContent>
</Card>
</div>
);
}
type TierType = "free" | "low" | "mid" | "high";
type KeyStatus = "configured" | "unconfigured" | "validating" | "invalid";
interface EngineProfile {
label: string;
group: "domestic" | "international";
tier: TierType;
inputPrice: number;
outputPrice: number;
hasFreeTier: boolean;
requiresOwnKey: boolean;
envVar: string;
}
const ENGINE_PROFILES: Record<string, EngineProfile> = {
deepseek: { label: "DeepSeek", group: "domestic", tier: "free", inputPrice: 0.25, outputPrice: 6.0, hasFreeTier: true, requiresOwnKey: false, envVar: "DEEPSEEK_API_KEY" },
qwen: { label: "通义千问", group: "domestic", tier: "free", inputPrice: 0.3, outputPrice: 0.6, hasFreeTier: true, requiresOwnKey: false, envVar: "DASHSCOPE_API_KEY" },
wenxin: { label: "文心一言", group: "domestic", tier: "free", inputPrice: 0.012, outputPrice: 0.012, hasFreeTier: true, requiresOwnKey: false, envVar: "BAIDU_QIANFAN_API_KEY" },
kimi: { label: "Kimi", group: "domestic", tier: "low", inputPrice: 12.0, outputPrice: 12.0, hasFreeTier: true, requiresOwnKey: false, envVar: "MOONSHOT_API_KEY" },
doubao: { label: "豆包", group: "domestic", tier: "low", inputPrice: 0.5, outputPrice: 0.9, hasFreeTier: true, requiresOwnKey: false, envVar: "DOUBAO_API_KEY" },
gemini: { label: "Google Gemini", group: "international", tier: "low", inputPrice: 0.5, outputPrice: 2.0, hasFreeTier: true, requiresOwnKey: false, envVar: "GOOGLE_API_KEY" },
yuanbao: { label: "腾讯元宝", group: "domestic", tier: "mid", inputPrice: 0.8, outputPrice: 2.0, hasFreeTier: true, requiresOwnKey: false, envVar: "HUNYUAN_API_KEY" },
chatgpt: { label: "ChatGPT", group: "international", tier: "high", inputPrice: 1.0, outputPrice: 4.0, hasFreeTier: false, requiresOwnKey: true, envVar: "OPENAI_API_KEY" },
perplexity: { label: "Perplexity", group: "international", tier: "high", inputPrice: 35.0, outputPrice: 35.0, hasFreeTier: false, requiresOwnKey: true, envVar: "PERPLEXITY_API_KEY" },
};
const TIER_CONFIG: Record<TierType, { label: string; className: string }> = {
free: { label: "免费", className: "bg-emerald-100 text-emerald-700 hover:bg-emerald-100" },
low: { label: "低成本", className: "bg-blue-100 text-blue-700 hover:bg-blue-100" },
mid: { label: "中成本", className: "bg-amber-100 text-amber-700 hover:bg-amber-100" },
high: { label: "高成本", className: "bg-red-100 text-red-700 hover:bg-red-100" },
};
const KEY_STATUS_CONFIG: Record<KeyStatus, { label: string; className: string; icon: React.ElementType }> = {
configured: { label: "已配置", className: "bg-emerald-100 text-emerald-700", icon: CheckCircle },
unconfigured: { label: "未配置", className: "bg-gray-100 text-gray-500", icon: X },
validating: { label: "验证中", className: "bg-blue-100 text-blue-700", icon: Loader2 },
invalid: { label: "无效", className: "bg-red-100 text-red-700", icon: AlertTriangle },
};
function ApiConfigTab() {
const { data: session } = useSession();
const token = session?.accessToken;
const [keyStatuses, setKeyStatuses] = useState<Record<string, KeyStatus>>({});
const [dialogOpen, setDialogOpen] = useState(false);
const [selectedEngine, setSelectedEngine] = useState<string>("");
const [apiKeyInput, setApiKeyInput] = useState("");
const [showKey, setShowKey] = useState(false);
const [saving, setSaving] = useState(false);
const [validating, setValidating] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
useEffect(() => {
loadKeyStatuses();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [token]);
async function loadKeyStatuses() {
if (!token) return;
try {
const data = await fetchWithAuth("/api/v1/api-keys/", {}, token) as { items: { engine_type: string; status: string; key_hint: string; source: string }[] };
const statuses: Record<string, KeyStatus> = {};
for (const k of data.items) {
statuses[k.engine_type] = k.status === "active" ? "configured" : k.status === "invalid" ? "invalid" : "configured";
}
Object.keys(ENGINE_PROFILES).forEach((engine) => {
if (!(engine in statuses)) {
statuses[engine] = "unconfigured";
}
});
setKeyStatuses(statuses);
} catch {
const statuses: Record<string, KeyStatus> = {};
Object.keys(ENGINE_PROFILES).forEach((engine) => {
statuses[engine] = "unconfigured";
});
setKeyStatuses(statuses);
}
}
async function handleAddKey() {
if (!token || !selectedEngine || !apiKeyInput.trim()) return;
setSaving(true);
setError(null);
try {
await fetchWithAuth("/api/v1/api-keys/", {
method: "POST",
body: JSON.stringify({ engine_type: selectedEngine, api_key: apiKeyInput.trim(), source: "user" }),
}, token);
setKeyStatuses((prev) => ({ ...prev, [selectedEngine]: "configured" }));
setSuccess(`${ENGINE_PROFILES[selectedEngine].label} API Key 已保存`);
setDialogOpen(false);
setSelectedEngine("");
setApiKeyInput("");
setTimeout(() => setSuccess(null), 3000);
} catch (err) {
setError(err instanceof Error ? err.message : "保存失败");
} finally {
setSaving(false);
}
}
async function handleDeleteKey(engine: string) {
if (!token) return;
try {
const listData = await fetchWithAuth("/api/v1/api-keys/", {}, token) as { items: { engine_type: string; key_hint: string; source: string; status: string }[] };
const keyItem = listData.items.find((k) => k.engine_type === engine);
if (!keyItem) {
setError("未找到该引擎的API Key");
return;
}
await fetchWithAuth(`/api/v1/api-keys/${engine}/${encodeURIComponent(keyItem.key_hint)}`, {
method: "DELETE",
}, token);
setKeyStatuses((prev) => ({ ...prev, [engine]: "unconfigured" }));
setSuccess(`${ENGINE_PROFILES[engine].label} API Key 已删除`);
setTimeout(() => setSuccess(null), 3000);
} catch (err) {
setError(err instanceof Error ? err.message : "删除失败");
}
}
async function handleValidateKey(engine: string) {
if (!token) return;
setValidating(engine);
setKeyStatuses((prev) => ({ ...prev, [engine]: "validating" }));
try {
const data = await fetchWithAuth("/api/v1/api-keys/verify", {
method: "POST",
body: JSON.stringify({ engine_type: engine }),
}, token) as { engine_type: string; status: string };
const isValid = data.status === "active";
setKeyStatuses((prev) => ({ ...prev, [engine]: isValid ? "configured" : "invalid" }));
setSuccess(isValid ? `${ENGINE_PROFILES[engine].label} API Key 验证通过` : `${ENGINE_PROFILES[engine].label} API Key 无效`);
setTimeout(() => setSuccess(null), 3000);
} catch {
setKeyStatuses((prev) => ({ ...prev, [engine]: "invalid" }));
setError("验证请求失败");
setTimeout(() => setError(null), 3000);
} finally {
setValidating(null);
}
}
function openAddDialog(engine?: string) {
setSelectedEngine(engine || "");
setApiKeyInput("");
setShowKey(false);
setError(null);
setDialogOpen(true);
}
const domesticEngines = Object.entries(ENGINE_PROFILES).filter(([, p]) => p.group === "domestic");
const internationalEngines = Object.entries(ENGINE_PROFILES).filter(([, p]) => p.group === "international");
function renderEngineCard(engineKey: string, profile: EngineProfile) {
const status = keyStatuses[engineKey] || "unconfigured";
const statusConfig = KEY_STATUS_CONFIG[status];
const tierConfig = TIER_CONFIG[profile.tier];
const StatusIcon = statusConfig.icon;
const isValidating = validating === engineKey;
return (
<div key={engineKey} className="flex flex-col gap-3 rounded-lg border p-4">
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-muted">
<Key className="h-4 w-4 text-muted-foreground" />
</div>
<div>
<div className="flex items-center gap-2">
<span className="font-medium text-sm">{profile.label}</span>
<Badge className={tierConfig.className}>{tierConfig.label}</Badge>
</div>
<p className="text-xs text-muted-foreground mt-0.5">
¥{profile.inputPrice}/Token · ¥{profile.outputPrice}/Token
</p>
</div>
</div>
<Badge className={statusConfig.className}>
<StatusIcon className={`mr-1 h-3 w-3 ${isValidating ? "animate-spin" : ""}`} />
{statusConfig.label}
</Badge>
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
{profile.hasFreeTier && (
<span className="flex items-center gap-1">
<Zap className="h-3 w-3 text-emerald-500" />
</span>
)}
{profile.requiresOwnKey && (
<span className="flex items-center gap-1">
<Shield className="h-3 w-3 text-amber-500" />
Key
</span>
)}
<span className="flex items-center gap-1">
<CircleDot className="h-3 w-3" />
{profile.envVar}
</span>
</div>
<div className="flex items-center gap-2 mt-auto">
{status === "unconfigured" ? (
<Button size="sm" onClick={() => openAddDialog(engineKey)}>
<Key className="mr-1 h-3 w-3" />
Key
</Button>
) : (
<>
<Button
size="sm"
variant="outline"
onClick={() => handleValidateKey(engineKey)}
disabled={isValidating}
>
{isValidating ? (
<Loader2 className="mr-1 h-3 w-3 animate-spin" />
) : (
<Shield className="mr-1 h-3 w-3" />
)}
</Button>
<Button
size="sm"
variant="outline"
className="text-destructive hover:text-destructive"
onClick={() => handleDeleteKey(engineKey)}
disabled={isValidating}
>
</Button>
</>
)}
</div>
</div>
);
}
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>
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-base flex items-center gap-2">
<Key className="h-5 w-5 text-blue-600" />
</CardTitle>
<CardDescription className="mt-1">AI引擎API密钥配置</CardDescription>
</div>
<Button size="sm" variant="outline" onClick={() => openAddDialog()}>
<Key className="mr-1 h-3 w-3" />
Key
</Button>
</div>
</CardHeader>
<CardContent>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{domesticEngines.map(([key, profile]) => renderEngineCard(key, profile))}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<Globe className="h-5 w-5 text-purple-600" />
</CardTitle>
<CardDescription>AI引擎API密钥配置</CardDescription>
</CardHeader>
<CardContent>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{internationalEngines.map(([key, profile]) => renderEngineCard(key, profile))}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<Info className="h-5 w-5 text-muted-foreground" />
</CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3 text-sm">
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
{(["free", "low", "mid", "high"] as TierType[]).map((tier) => (
<div key={tier} className="rounded-lg border p-3">
<Badge className={TIER_CONFIG[tier].className}>{TIER_CONFIG[tier].label}</Badge>
<p className="mt-2 text-xs text-muted-foreground">
{tier === "free" && "输入/输出价格极低,含免费额度"}
{tier === "low" && "输入/输出价格较低,部分含免费额度"}
{tier === "mid" && "中等价格,适合高频调用场景"}
{tier === "high" && "价格较高需自带API Key"}
</p>
</div>
))}
</div>
<p className="text-xs text-muted-foreground">
/Token"需自带Key"KeyAPI密钥
</p>
</div>
</CardContent>
</Card>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>API Key</DialogTitle>
<DialogDescription>AI引擎配置API密钥</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label></Label>
<Select value={selectedEngine} onValueChange={setSelectedEngine}>
<SelectTrigger>
<SelectValue placeholder="请选择引擎" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel></SelectLabel>
{domesticEngines.map(([key, profile]) => (
<SelectItem key={key} value={key}>{profile.label}</SelectItem>
))}
</SelectGroup>
<SelectGroup>
<SelectLabel></SelectLabel>
{internationalEngines.map(([key, profile]) => (
<SelectItem key={key} value={key}>{profile.label}</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>API Key</Label>
<div className="relative">
<Input
type={showKey ? "text" : "password"}
placeholder="请输入API Key"
value={apiKeyInput}
onChange={(e) => setApiKeyInput(e.target.value)}
className="pr-10"
/>
<Button
type="button"
variant="ghost"
size="sm"
className="absolute right-0 top-0 h-full px-3"
onClick={() => setShowKey(!showKey)}
>
{showKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</Button>
</div>
{selectedEngine && ENGINE_PROFILES[selectedEngine] && (
<p className="text-xs text-muted-foreground">
: {ENGINE_PROFILES[selectedEngine].envVar}
</p>
)}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDialogOpen(false)} disabled={saving}>
</Button>
<Button onClick={handleAddKey} disabled={saving || !selectedEngine || !apiKeyInput.trim()}>
{saving && <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>
<TabsTrigger value="api">API配置</TabsTrigger>
<TabsTrigger value="alerts"></TabsTrigger>
<TabsTrigger value="onboarding"></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>
<TabsContent value="api" className="mt-4">
<ApiConfigTab />
</TabsContent>
<TabsContent value="alerts" className="mt-4">
<AlertSettingsTab />
</TabsContent>
<TabsContent value="onboarding" className="mt-4">
<OnboardingTab />
</TabsContent>
</Tabs>
</div>
);
}