add model config and update settings
This commit is contained in:
+67
-9
@@ -1,17 +1,31 @@
|
||||
import json
|
||||
import os
|
||||
from typing import List, Optional, Dict, Any
|
||||
from fastapi import APIRouter, HTTPException, Body
|
||||
from fastapi import APIRouter, HTTPException, Depends, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from jose import jwt, JWTError
|
||||
from pydantic import BaseModel, Field
|
||||
from app.core.security import SECRET_KEY, ALGORITHM
|
||||
|
||||
router = APIRouter()
|
||||
security = HTTPBearer()
|
||||
|
||||
DATA_FILE = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "data", "llm_config.json")
|
||||
|
||||
|
||||
class CurrentUser(BaseModel):
|
||||
id: int
|
||||
username: str
|
||||
is_admin: bool = False
|
||||
|
||||
class LLMConfig(BaseModel):
|
||||
id: str = Field(..., description="Unique identifier for the LLM configuration")
|
||||
name: Optional[str] = Field(None, description="Display name")
|
||||
provider: str = Field(..., description="Provider name (e.g., openai, azure, anthropic)")
|
||||
model: str = Field(..., description="Model name (e.g., gpt-4, claude-3-opus)")
|
||||
model_type: Optional[str] = Field(None, description="Model type")
|
||||
base_model: Optional[str] = Field(None, description="Base model")
|
||||
protocol_type: Optional[str] = Field(None, description="Protocol type")
|
||||
api_key: Optional[str] = Field(None, description="API Key for the provider")
|
||||
api_base: Optional[str] = Field(None, description="Base URL for the API")
|
||||
extra_headers: Optional[Dict[str, str]] = Field(None, description="Extra headers for the request")
|
||||
@@ -19,21 +33,52 @@ class LLMConfig(BaseModel):
|
||||
|
||||
class LLMConfigCreate(BaseModel):
|
||||
id: str
|
||||
name: Optional[str] = None
|
||||
provider: str
|
||||
model: str
|
||||
model_type: Optional[str] = None
|
||||
base_model: Optional[str] = None
|
||||
protocol_type: Optional[str] = None
|
||||
api_key: Optional[str] = None
|
||||
api_base: Optional[str] = None
|
||||
extra_headers: Optional[Dict[str, str]] = None
|
||||
is_active: bool = True
|
||||
|
||||
class LLMConfigUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
provider: Optional[str] = None
|
||||
model: Optional[str] = None
|
||||
model_type: Optional[str] = None
|
||||
base_model: Optional[str] = None
|
||||
protocol_type: Optional[str] = None
|
||||
api_key: Optional[str] = None
|
||||
api_base: Optional[str] = None
|
||||
extra_headers: Optional[Dict[str, str]] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
|
||||
def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)) -> CurrentUser:
|
||||
unauthorized = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid authentication credentials",
|
||||
)
|
||||
try:
|
||||
payload = jwt.decode(credentials.credentials, SECRET_KEY, algorithms=[ALGORITHM])
|
||||
except JWTError:
|
||||
raise unauthorized
|
||||
user_id = payload.get("id")
|
||||
username = payload.get("sub")
|
||||
is_admin = bool(payload.get("is_admin", False))
|
||||
if user_id is None or username is None:
|
||||
raise unauthorized
|
||||
return CurrentUser(id=user_id, username=username, is_admin=is_admin)
|
||||
|
||||
|
||||
def get_admin_user(current_user: CurrentUser = Depends(get_current_user)) -> CurrentUser:
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin permission required")
|
||||
return current_user
|
||||
|
||||
def _load_data() -> List[Dict[str, Any]]:
|
||||
if not os.path.exists(DATA_FILE):
|
||||
return []
|
||||
@@ -48,37 +93,50 @@ def _save_data(data: List[Dict[str, Any]]):
|
||||
with open(DATA_FILE, "w") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
|
||||
|
||||
def _sanitize_config(item: Dict[str, Any], is_admin: bool) -> Dict[str, Any]:
|
||||
config = item.copy()
|
||||
if not is_admin:
|
||||
config["api_key"] = None
|
||||
return config
|
||||
|
||||
@router.get("/llm", response_model=List[LLMConfig])
|
||||
def list_llm_configs():
|
||||
def list_llm_configs(current_user: CurrentUser = Depends(get_current_user)):
|
||||
data = _load_data()
|
||||
return [LLMConfig(**item) for item in data]
|
||||
return [LLMConfig(**_sanitize_config(item, current_user.is_admin)) for item in data]
|
||||
|
||||
@router.get("/llm/{config_id}", response_model=LLMConfig)
|
||||
def get_llm_config(config_id: str):
|
||||
def get_llm_config(config_id: str, current_user: CurrentUser = Depends(get_current_user)):
|
||||
data = _load_data()
|
||||
for item in data:
|
||||
if item["id"] == config_id:
|
||||
return LLMConfig(**item)
|
||||
return LLMConfig(**_sanitize_config(item, current_user.is_admin))
|
||||
raise HTTPException(status_code=404, detail="LLM configuration not found")
|
||||
|
||||
@router.post("/llm", response_model=LLMConfig)
|
||||
def create_llm_config(config: LLMConfigCreate):
|
||||
def create_llm_config(config: LLMConfigCreate, _: CurrentUser = Depends(get_admin_user)):
|
||||
data = _load_data()
|
||||
if any(item["id"] == config.id for item in data):
|
||||
raise HTTPException(status_code=400, detail="LLM configuration with this ID already exists")
|
||||
|
||||
|
||||
new_config = config.dict()
|
||||
if new_config.get("is_active"):
|
||||
for item in data:
|
||||
item["is_active"] = False
|
||||
data.append(new_config)
|
||||
_save_data(data)
|
||||
return LLMConfig(**new_config)
|
||||
|
||||
@router.put("/llm/{config_id}", response_model=LLMConfig)
|
||||
def update_llm_config(config_id: str, config: LLMConfigUpdate):
|
||||
def update_llm_config(config_id: str, config: LLMConfigUpdate, _: CurrentUser = Depends(get_admin_user)):
|
||||
data = _load_data()
|
||||
for i, item in enumerate(data):
|
||||
if item["id"] == config_id:
|
||||
updated_item = item.copy()
|
||||
update_data = config.dict(exclude_unset=True)
|
||||
if update_data.get("is_active"):
|
||||
for j in range(len(data)):
|
||||
data[j]["is_active"] = False
|
||||
updated_item.update(update_data)
|
||||
data[i] = updated_item
|
||||
_save_data(data)
|
||||
@@ -86,7 +144,7 @@ def update_llm_config(config_id: str, config: LLMConfigUpdate):
|
||||
raise HTTPException(status_code=404, detail="LLM configuration not found")
|
||||
|
||||
@router.delete("/llm/{config_id}")
|
||||
def delete_llm_config(config_id: str):
|
||||
def delete_llm_config(config_id: str, _: CurrentUser = Depends(get_admin_user)):
|
||||
data = _load_data()
|
||||
initial_len = len(data)
|
||||
data = [item for item in data if item["id"] != config_id]
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
[
|
||||
{
|
||||
"id": "m1773487590",
|
||||
"provider": "zhipuai",
|
||||
"model": "glm-4-7-251222",
|
||||
"api_key": "secret",
|
||||
"api_base": "https://ark.cn-beijing.volces.com/api/v3",
|
||||
"extra_headers": null,
|
||||
"is_active": true
|
||||
},
|
||||
{
|
||||
"id": "deny1",
|
||||
"provider": "openai",
|
||||
"model": "gpt-4o",
|
||||
"api_key": null,
|
||||
"api_base": "https://api.openai.com/v1",
|
||||
"extra_headers": null,
|
||||
"is_active": false
|
||||
}
|
||||
]
|
||||
Binary file not shown.
@@ -6,6 +6,7 @@ import { Skills } from "./pages/Skills";
|
||||
import { Settings } from "./pages/Settings";
|
||||
import { Users } from "./pages/Users";
|
||||
import { Login } from "./pages/Login";
|
||||
import { ModelConfigs } from "./pages/ModelConfigs";
|
||||
import { useAuthStore } from "./store/authStore";
|
||||
|
||||
// Protected Route Component
|
||||
@@ -82,6 +83,14 @@ function App() {
|
||||
</MainLayout>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
|
||||
<Route path="/model-configs" element={
|
||||
<ProtectedRoute>
|
||||
<MainLayout>
|
||||
<ModelConfigs />
|
||||
</MainLayout>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Menu, LayoutDashboard, Plus, MoreVertical, User, Search, Wrench, Settings } from "lucide-react";
|
||||
import { Menu, LayoutDashboard, Plus, MoreVertical, User, Search, Wrench, Settings, Brain } from "lucide-react";
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { useAuthStore } from "@/store/authStore";
|
||||
@@ -138,20 +138,33 @@ function SidebarBody() {
|
||||
}}
|
||||
>
|
||||
<Settings className="h-4 w-4 text-zinc-500" />
|
||||
Settings
|
||||
个人设置
|
||||
</button>
|
||||
|
||||
|
||||
{user?.is_admin && (
|
||||
<button
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-indigo-600 hover:bg-indigo-50 transition-colors"
|
||||
onClick={() => {
|
||||
navigate("/users");
|
||||
setShowUserMenu(false);
|
||||
}}
|
||||
>
|
||||
<User className="h-4 w-4" />
|
||||
User Management
|
||||
</button>
|
||||
<>
|
||||
<button
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-zinc-700 hover:bg-zinc-100 transition-colors"
|
||||
onClick={() => {
|
||||
navigate("/model-configs");
|
||||
setShowUserMenu(false);
|
||||
}}
|
||||
>
|
||||
<Brain className="h-4 w-4 text-zinc-500" />
|
||||
模型配置
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-indigo-600 hover:bg-indigo-50 transition-colors"
|
||||
onClick={() => {
|
||||
navigate("/users");
|
||||
setShowUserMenu(false);
|
||||
}}
|
||||
>
|
||||
<User className="h-4 w-4" />
|
||||
用户管理
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="h-px bg-zinc-100 my-1 mx-2" />
|
||||
@@ -160,7 +173,7 @@ function SidebarBody() {
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-red-600 hover:bg-red-50 transition-colors"
|
||||
onClick={handleLogout}
|
||||
>
|
||||
Log out
|
||||
退出登录
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,376 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { api } from "@/lib/api";
|
||||
import { Loader2, Plus, RefreshCw, Search, Trash2, Pencil, Eye, EyeOff, Brain } from "lucide-react";
|
||||
import { useAuthStore } from "@/store/authStore";
|
||||
|
||||
interface ModelConfig {
|
||||
id: string;
|
||||
name?: string;
|
||||
provider: string;
|
||||
model: string;
|
||||
model_type?: string;
|
||||
base_model?: string;
|
||||
protocol_type?: string;
|
||||
api_key?: string;
|
||||
api_base?: string;
|
||||
extra_headers?: Record<string, string>;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
const defaultForm: Omit<ModelConfig, "id"> = {
|
||||
name: "",
|
||||
provider: "openai",
|
||||
model: "",
|
||||
model_type: "LLM",
|
||||
base_model: "",
|
||||
protocol_type: "OpenAI",
|
||||
api_key: "",
|
||||
api_base: "",
|
||||
extra_headers: {},
|
||||
is_active: true,
|
||||
};
|
||||
|
||||
export function ModelConfigs() {
|
||||
const { user } = useAuthStore();
|
||||
const isAdmin = !!user?.is_admin;
|
||||
const [configs, setConfigs] = useState<ModelConfig[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [showApiKey, setShowApiKey] = useState(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [extraConfigText, setExtraConfigText] = useState("{}");
|
||||
const [form, setForm] = useState<Omit<ModelConfig, "id">>(defaultForm);
|
||||
|
||||
const fetchConfigs = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const data = await api.get<ModelConfig[]>("/api/v1/llm");
|
||||
setConfigs(data);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchConfigs();
|
||||
}, []);
|
||||
|
||||
const filteredConfigs = useMemo(() => {
|
||||
const value = keyword.trim().toLowerCase();
|
||||
if (!value) return configs;
|
||||
return configs.filter((item) =>
|
||||
[item.name, item.model, item.provider, item.base_model].filter(Boolean).some((v) => String(v).toLowerCase().includes(value))
|
||||
);
|
||||
}, [configs, keyword]);
|
||||
|
||||
const openCreate = () => {
|
||||
setEditingId(null);
|
||||
setForm(defaultForm);
|
||||
setExtraConfigText("{}");
|
||||
setError("");
|
||||
setShowApiKey(false);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (item: ModelConfig) => {
|
||||
setEditingId(item.id);
|
||||
setForm({
|
||||
name: item.name || "",
|
||||
provider: item.provider || "openai",
|
||||
model: item.model || "",
|
||||
model_type: item.model_type || "LLM",
|
||||
base_model: item.base_model || "",
|
||||
protocol_type: item.protocol_type || "OpenAI",
|
||||
api_key: item.api_key || "",
|
||||
api_base: item.api_base || "",
|
||||
extra_headers: item.extra_headers || {},
|
||||
is_active: item.is_active,
|
||||
});
|
||||
setExtraConfigText(JSON.stringify(item.extra_headers || {}, null, 2));
|
||||
setError("");
|
||||
setShowApiKey(false);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleSave = async (e?: React.FormEvent) => {
|
||||
if (e) e.preventDefault();
|
||||
if (!form.model || !form.provider || !form.api_base) {
|
||||
setError("请填写必填项");
|
||||
return;
|
||||
}
|
||||
setIsSaving(true);
|
||||
setError("");
|
||||
try {
|
||||
let extraHeaders: Record<string, string> = {};
|
||||
if (extraConfigText.trim()) {
|
||||
try {
|
||||
const parsed = JSON.parse(extraConfigText);
|
||||
if (parsed && typeof parsed === "object") extraHeaders = parsed;
|
||||
} catch (err) {
|
||||
setError("额外配置必须是有效的JSON");
|
||||
setIsSaving(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const payload = {
|
||||
...form,
|
||||
extra_headers: extraHeaders,
|
||||
name: form.name || form.model,
|
||||
model_type: form.model_type || "大语言模型",
|
||||
base_model: form.base_model || form.model,
|
||||
protocol_type: form.protocol_type || "OpenAI",
|
||||
};
|
||||
if (editingId) {
|
||||
await api.put(`/api/v1/llm/${editingId}`, payload);
|
||||
} else {
|
||||
const id = `${Date.now()}`;
|
||||
await api.post("/api/v1/llm", { ...payload, id });
|
||||
}
|
||||
setDialogOpen(false);
|
||||
await fetchConfigs();
|
||||
} catch (e: any) {
|
||||
setError(e.message || "保存配置失败");
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!window.confirm("确认删除该模型吗?")) return;
|
||||
try {
|
||||
await api.delete(`/api/v1/llm/${id}`);
|
||||
await fetchConfigs();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSetDefault = async (item: ModelConfig) => {
|
||||
if (!isAdmin || item.is_active) return;
|
||||
try {
|
||||
await api.put(`/api/v1/llm/${item.id}`, { is_active: true });
|
||||
await fetchConfigs();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isAdmin) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col h-full bg-zinc-50/30 overflow-hidden items-center justify-center">
|
||||
<div className="text-zinc-500 text-lg">无权限访问此页面,请使用管理员账号登录。</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col h-full bg-zinc-50/30 overflow-hidden">
|
||||
<div className="h-14 px-6 flex items-center justify-between border-b border-zinc-100 bg-white">
|
||||
<div className="flex items-center gap-2 text-zinc-700 font-medium">
|
||||
<Brain className="h-5 w-5 text-indigo-500" />
|
||||
模型配置
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative">
|
||||
<Search className="h-4 w-4 text-zinc-400 absolute left-3 top-1/2 -translate-y-1/2" />
|
||||
<Input value={keyword} onChange={(e) => setKeyword(e.target.value)} placeholder="搜索模型..." className="w-[200px] pl-9 h-8 text-sm" />
|
||||
</div>
|
||||
<Button variant="outline" size="icon" className="h-8 w-8 text-zinc-500" onClick={fetchConfigs}>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button className="h-8 px-3 bg-indigo-600 hover:bg-indigo-700 text-white text-sm" onClick={openCreate}>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
添加模型
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 p-6 overflow-auto">
|
||||
<div className="bg-white rounded-xl border border-zinc-200 shadow-sm overflow-hidden">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-40">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-zinc-400" />
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>模型名称</TableHead>
|
||||
<TableHead>供应商</TableHead>
|
||||
<TableHead>模型标识</TableHead>
|
||||
<TableHead>模型类型</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead className="text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredConfigs.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center h-24 text-zinc-500">
|
||||
暂无模型数据
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredConfigs.map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell className="font-medium">
|
||||
{item.name || item.model}
|
||||
</TableCell>
|
||||
<TableCell className="capitalize">{item.provider}</TableCell>
|
||||
<TableCell className="text-zinc-500 font-mono text-xs">{item.model}</TableCell>
|
||||
<TableCell className="text-zinc-500">{item.model_type || "大语言模型"}</TableCell>
|
||||
<TableCell>
|
||||
<span
|
||||
onClick={() => handleSetDefault(item)}
|
||||
className={`inline-flex px-2 py-1 rounded-full text-xs font-medium cursor-pointer transition-colors ${item.is_active ? 'bg-emerald-100 text-emerald-700' : 'bg-zinc-100 text-zinc-600 hover:bg-zinc-200'}`}
|
||||
title={item.is_active ? "当前默认模型" : "点击设为默认"}
|
||||
>
|
||||
{item.is_active ? '默认' : '设为默认'}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-zinc-500 hover:text-indigo-600"
|
||||
onClick={() => openEdit(item)}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-zinc-500 hover:text-red-600"
|
||||
onClick={() => handleDelete(item.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[600px] max-h-[90vh] overflow-y-auto">
|
||||
<form onSubmit={handleSave}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingId ? "编辑模型" : "添加模型"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
{error && <div className="text-sm text-red-600 bg-red-50 border border-red-100 rounded-md p-2">{error}</div>}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>模型名称</Label>
|
||||
<Input value={form.name || ""} onChange={(e) => setForm((p) => ({ ...p, name: e.target.value }))} placeholder="如:GPT-4" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>供应商 *</Label>
|
||||
<Select value={form.provider} onValueChange={(v) => setForm((p) => ({ ...p, provider: v || "openai" }))}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="openai">OpenAI</SelectItem>
|
||||
<SelectItem value="zhipuai">ZhipuAI</SelectItem>
|
||||
<SelectItem value="anthropic">Anthropic</SelectItem>
|
||||
<SelectItem value="azure">Azure OpenAI</SelectItem>
|
||||
<SelectItem value="local">Local</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>模型标识 *</Label>
|
||||
<Input value={form.model || ""} onChange={(e) => setForm((p) => ({ ...p, model: e.target.value }))} placeholder="如:gpt-4-turbo" required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>基础模型</Label>
|
||||
<Input value={form.base_model || ""} onChange={(e) => setForm((p) => ({ ...p, base_model: e.target.value }))} placeholder="可选" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>模型类型</Label>
|
||||
<Select value={form.model_type || "大语言模型"} onValueChange={(v) => setForm((p) => ({ ...p, model_type: v || "大语言模型" }))}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="大语言模型">大语言模型</SelectItem>
|
||||
<SelectItem value="多模态模型">多模态模型</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>协议类型</Label>
|
||||
<Select value={form.protocol_type || "OpenAI"} onValueChange={(v) => setForm((p) => ({ ...p, protocol_type: v || "OpenAI" }))}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="OpenAI">OpenAI</SelectItem>
|
||||
<SelectItem value="Anthropic">Anthropic</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>API 域名 *</Label>
|
||||
<Input value={form.api_base || ""} onChange={(e) => setForm((p) => ({ ...p, api_base: e.target.value }))} placeholder="如:https://api.openai.com/v1" required />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>API Key</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={showApiKey ? "text" : "password"}
|
||||
value={form.api_key || ""}
|
||||
onChange={(e) => setForm((p) => ({ ...p, api_key: e.target.value }))}
|
||||
className="pr-10"
|
||||
placeholder="不修改请留空"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-zinc-400 hover:text-zinc-600"
|
||||
onClick={() => setShowApiKey((v) => !v)}
|
||||
>
|
||||
{showApiKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>额外配置 (JSON)</Label>
|
||||
<Textarea value={extraConfigText} onChange={(e) => setExtraConfigText(e.target.value)} className="min-h-[80px] font-mono text-xs" placeholder='{"timeout": "60"}' />
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setDialogOpen(false)}>取消</Button>
|
||||
<Button type="submit" disabled={isSaving} className="bg-indigo-600 hover:bg-indigo-700 text-white">
|
||||
{isSaving ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : null}
|
||||
保存
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+113
-157
@@ -3,188 +3,144 @@ import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription, CardFooter } from "@/components/ui/card";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Save, Loader2 } from "lucide-react";
|
||||
import { api } from "@/lib/api";
|
||||
|
||||
interface LLMConfig {
|
||||
id: string;
|
||||
provider: string;
|
||||
model: string;
|
||||
api_key?: string;
|
||||
api_base?: string;
|
||||
is_active: boolean;
|
||||
}
|
||||
import { useAuthStore } from "@/store/authStore";
|
||||
|
||||
export function Settings() {
|
||||
const [configId, setConfigId] = useState<string | null>(null);
|
||||
const [apiKey, setApiKey] = useState('');
|
||||
const [provider, setProvider] = useState('openai');
|
||||
const [model, setModel] = useState('gpt-4-turbo');
|
||||
const [baseUrl, setBaseUrl] = useState('https://api.openai.com/v1');
|
||||
const [enableVoice, setEnableVoice] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { user, updateUser } = useAuthStore();
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
fetchConfig();
|
||||
}, []);
|
||||
|
||||
const fetchConfig = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const configs = await api.get<LLMConfig[]>('/api/v1/llm');
|
||||
const activeConfig = configs.find(c => c.is_active) || configs[0];
|
||||
if (activeConfig) {
|
||||
setConfigId(activeConfig.id);
|
||||
setProvider(activeConfig.provider);
|
||||
setModel(activeConfig.model);
|
||||
setApiKey(activeConfig.api_key || '');
|
||||
setBaseUrl(activeConfig.api_base || '');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch LLM config", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
if (user) {
|
||||
setEmail(user.email || '');
|
||||
}
|
||||
};
|
||||
}, [user]);
|
||||
const isPasswordMismatch = password !== '' && confirmPassword !== '' && password !== confirmPassword;
|
||||
|
||||
const handleSave = async () => {
|
||||
setError('');
|
||||
setSuccess('');
|
||||
|
||||
if (isPasswordMismatch) {
|
||||
setError("两次输入的密码不一致");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const configData = {
|
||||
provider,
|
||||
model,
|
||||
api_key: apiKey,
|
||||
api_base: baseUrl,
|
||||
is_active: true
|
||||
const updateData: any = {
|
||||
email: email
|
||||
};
|
||||
|
||||
if (configId) {
|
||||
await api.put(`/api/v1/llm/${configId}`, configData);
|
||||
} else {
|
||||
const newId = Date.now().toString();
|
||||
await api.post('/api/v1/llm', { ...configData, id: newId });
|
||||
setConfigId(newId);
|
||||
|
||||
if (password) {
|
||||
updateData.password = password;
|
||||
}
|
||||
alert("Settings saved successfully!");
|
||||
} catch (error) {
|
||||
|
||||
if (user && user.id) {
|
||||
const response = await api.put<any>(`/api/v1/users/${user.id}`, updateData);
|
||||
let successMsg = "个人设置保存成功!";
|
||||
if (password) {
|
||||
successMsg = "个人设置及密码修改成功!";
|
||||
}
|
||||
setSuccess(successMsg);
|
||||
setPassword('');
|
||||
setConfirmPassword('');
|
||||
|
||||
// Update global state with new email
|
||||
updateUser({ email: response.email });
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("Failed to save settings", error);
|
||||
alert("Failed to save settings");
|
||||
setError(error.message || "保存设置失败");
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 h-full overflow-y-auto">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold">Settings</h1>
|
||||
<p className="text-muted-foreground">Configure AI model and application preferences</p>
|
||||
<div className="flex-1 flex flex-col h-full bg-zinc-50/30 overflow-hidden">
|
||||
<div className="h-14 px-6 flex items-center justify-between border-b border-zinc-100 bg-white">
|
||||
<div className="flex items-center gap-2 text-zinc-700 font-medium">
|
||||
<Save className="h-5 w-5 text-indigo-500" />
|
||||
个人设置
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 max-w-2xl">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>LLM Configuration</CardTitle>
|
||||
<CardDescription>Manage your Large Language Model settings</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="provider">Provider</Label>
|
||||
<Select value={provider} onValueChange={(val) => val && setProvider(val)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select provider" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="openai">OpenAI</SelectItem>
|
||||
<SelectItem value="anthropic">Anthropic</SelectItem>
|
||||
<SelectItem value="azure">Azure OpenAI</SelectItem>
|
||||
<SelectItem value="local">Local (Ollama/LM Studio)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="model">Model</Label>
|
||||
<Select value={model} onValueChange={(val) => val && setModel(val)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select model" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="gpt-4-turbo">GPT-4 Turbo</SelectItem>
|
||||
<SelectItem value="gpt-4o">GPT-4o</SelectItem>
|
||||
<SelectItem value="gpt-3.5-turbo">GPT-3.5 Turbo</SelectItem>
|
||||
<SelectItem value="claude-3-opus">Claude 3 Opus</SelectItem>
|
||||
<SelectItem value="claude-3-sonnet">Claude 3 Sonnet</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="api-key">API Key</Label>
|
||||
<Input
|
||||
id="api-key"
|
||||
type="password"
|
||||
placeholder="sk-..."
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="base-url">Base URL (Optional)</Label>
|
||||
<Input
|
||||
id="base-url"
|
||||
placeholder="https://api.openai.com/v1"
|
||||
value={baseUrl}
|
||||
onChange={(e) => setBaseUrl(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Interface Settings</CardTitle>
|
||||
<CardDescription>Customize your experience</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="voice-mode">Voice Mode</Label>
|
||||
<p className="text-sm text-muted-foreground">Enable voice input and output</p>
|
||||
<div className="flex-1 p-6 overflow-auto">
|
||||
<div className="grid gap-6 max-w-2xl mx-auto">
|
||||
{error && <div className="text-sm text-red-600 bg-red-50 border border-red-100 rounded-md p-3">{error}</div>}
|
||||
{success && <div className="text-sm text-emerald-600 bg-emerald-50 border border-emerald-100 rounded-md p-3">{success}</div>}
|
||||
|
||||
<Card className="border-zinc-200 shadow-sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">账号信息</CardTitle>
|
||||
<CardDescription>修改您的登录邮箱和密码</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="username">用户名</Label>
|
||||
<Input
|
||||
id="username"
|
||||
value={user?.username || ''}
|
||||
disabled
|
||||
className="bg-zinc-50 text-zinc-500"
|
||||
/>
|
||||
<p className="text-xs text-zinc-400">用户名不可修改</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="voice-mode"
|
||||
checked={enableVoice}
|
||||
onCheckedChange={setEnableVoice}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="dark-mode">Dark Mode</Label>
|
||||
<p className="text-sm text-muted-foreground">Toggle dark/light theme</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">邮箱地址</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Switch id="dark-mode" defaultChecked />
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button onClick={handleSave} className="ml-auto" disabled={isSaving}>
|
||||
{isSaving ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <Save className="h-4 w-4 mr-2" />}
|
||||
Save Changes
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
<div className="space-y-2 pt-4 border-t border-zinc-100">
|
||||
<Label htmlFor="new-password">新密码</Label>
|
||||
<Input
|
||||
id="new-password"
|
||||
type="password"
|
||||
placeholder="如不修改请留空"
|
||||
value={password}
|
||||
onChange={(e) => {
|
||||
setPassword(e.target.value);
|
||||
setError('');
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirm-password">确认新密码</Label>
|
||||
<Input
|
||||
id="confirm-password"
|
||||
type="password"
|
||||
placeholder="如不修改请留空"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => {
|
||||
setConfirmPassword(e.target.value);
|
||||
setError('');
|
||||
}}
|
||||
/>
|
||||
{isPasswordMismatch && <p className="text-sm text-red-600">两次输入的密码不一致</p>}
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="bg-zinc-50/50 border-t border-zinc-100 pt-6">
|
||||
<Button onClick={handleSave} className="ml-auto bg-indigo-600 hover:bg-indigo-700 text-white" disabled={isSaving || isPasswordMismatch}>
|
||||
{isSaving ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <Save className="h-4 w-4 mr-2" />}
|
||||
保存设置
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -87,7 +87,7 @@ export function Users() {
|
||||
} else {
|
||||
// Create
|
||||
if (!formData.password) {
|
||||
setError("Password is required for new users");
|
||||
setError("新建用户必须填写密码");
|
||||
return;
|
||||
}
|
||||
await api.post("/api/v1/users", formData);
|
||||
@@ -95,12 +95,12 @@ export function Users() {
|
||||
setIsDialogOpen(false);
|
||||
fetchUsers();
|
||||
} catch (err: any) {
|
||||
setError(err.message || "An error occurred");
|
||||
setError(err.message || "发生错误");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (window.confirm("Are you sure you want to delete this user?")) {
|
||||
if (window.confirm("确认删除该用户吗?")) {
|
||||
try {
|
||||
await api.delete(`/api/v1/users/${id}`);
|
||||
fetchUsers();
|
||||
@@ -115,22 +115,22 @@ export function Users() {
|
||||
<div className="h-14 px-6 flex items-center justify-between border-b border-zinc-100 bg-white">
|
||||
<div className="flex items-center gap-2 text-zinc-700 font-medium">
|
||||
<UsersIcon className="h-5 w-5 text-indigo-500" />
|
||||
User Management
|
||||
用户管理
|
||||
</div>
|
||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<DialogTrigger className="inline-flex items-center justify-center gap-1.5 whitespace-nowrap text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 h-8 bg-indigo-600 hover:bg-indigo-700 text-white rounded-md px-3" onClick={() => handleOpenDialog()}>
|
||||
<Plus className="h-4 w-4" />
|
||||
Add User
|
||||
添加用户
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingUser ? "Edit User" : "Add New User"}</DialogTitle>
|
||||
<DialogTitle>{editingUser ? "编辑用户" : "添加新用户"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
{error && <div className="text-red-500 text-sm">{error}</div>}
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="username">Username</Label>
|
||||
<Label htmlFor="username">用户名</Label>
|
||||
<Input
|
||||
id="username"
|
||||
value={formData.username}
|
||||
@@ -139,7 +139,7 @@ export function Users() {
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Label htmlFor="email">邮箱</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
@@ -150,7 +150,7 @@ export function Users() {
|
||||
</div>
|
||||
{!editingUser && (
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Label htmlFor="password">密码</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
@@ -161,7 +161,7 @@ export function Users() {
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="is_active">Active</Label>
|
||||
<Label htmlFor="is_active">激活状态</Label>
|
||||
<Switch
|
||||
id="is_active"
|
||||
checked={formData.is_active}
|
||||
@@ -169,7 +169,7 @@ export function Users() {
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="is_admin">Admin</Label>
|
||||
<Label htmlFor="is_admin">管理员权限</Label>
|
||||
<Switch
|
||||
id="is_admin"
|
||||
checked={formData.is_admin}
|
||||
@@ -179,10 +179,10 @@ export function Users() {
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setIsDialogOpen(false)}>
|
||||
Cancel
|
||||
取消
|
||||
</Button>
|
||||
<Button type="submit" className="bg-indigo-600 hover:bg-indigo-700 text-white">
|
||||
Save
|
||||
保存
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
@@ -201,19 +201,19 @@ export function Users() {
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>ID</TableHead>
|
||||
<TableHead>Username</TableHead>
|
||||
<TableHead>Email</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Role</TableHead>
|
||||
<TableHead>Created At</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
<TableHead>用户名</TableHead>
|
||||
<TableHead>邮箱</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>角色</TableHead>
|
||||
<TableHead>创建时间</TableHead>
|
||||
<TableHead className="text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{users.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-center h-24 text-zinc-500">
|
||||
No users found.
|
||||
暂无用户数据
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
@@ -224,12 +224,12 @@ export function Users() {
|
||||
<TableCell>{user.email}</TableCell>
|
||||
<TableCell>
|
||||
<span className={`inline-flex px-2 py-1 rounded-full text-xs font-medium ${user.is_active ? 'bg-emerald-100 text-emerald-700' : 'bg-zinc-100 text-zinc-600'}`}>
|
||||
{user.is_active ? 'Active' : 'Inactive'}
|
||||
{user.is_active ? '正常' : '禁用'}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className={`inline-flex px-2 py-1 rounded-full text-xs font-medium ${user.is_admin ? 'bg-purple-100 text-purple-700' : 'bg-blue-100 text-blue-700'}`}>
|
||||
{user.is_admin ? 'Admin' : 'User'}
|
||||
{user.is_admin ? '管理员' : '普通用户'}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-zinc-500">
|
||||
|
||||
@@ -12,6 +12,7 @@ interface AuthState {
|
||||
token: string | null;
|
||||
isAuthenticated: boolean;
|
||||
login: (user: User, token: string) => void;
|
||||
updateUser: (user: Partial<User>) => void;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
@@ -24,6 +25,13 @@ export const useAuthStore = create<AuthState>((set) => ({
|
||||
localStorage.setItem('token', token);
|
||||
set({ user, token, isAuthenticated: true });
|
||||
},
|
||||
updateUser: (updatedUser) => set((state) => {
|
||||
const user = state.user ? { ...state.user, ...updatedUser } : null;
|
||||
if (user) {
|
||||
localStorage.setItem('user', JSON.stringify(user));
|
||||
}
|
||||
return { user };
|
||||
}),
|
||||
logout: () => {
|
||||
localStorage.removeItem('user');
|
||||
localStorage.removeItem('token');
|
||||
|
||||
Reference in New Issue
Block a user