1326 lines
48 KiB
TypeScript
1326 lines
48 KiB
TypeScript
"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">
|
||
同一品牌同一类型的告警在1小时内不会重复触发。告警数据保留90天。
|
||
</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"的引擎不提供平台共享Key,需配置您自己的API密钥。
|
||
</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>
|
||
);
|
||
}
|