add model config and update settings
This commit is contained in:
+67
-9
@@ -1,17 +1,31 @@
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
from typing import List, Optional, Dict, Any
|
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 pydantic import BaseModel, Field
|
||||||
|
from app.core.security import SECRET_KEY, ALGORITHM
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
security = HTTPBearer()
|
||||||
|
|
||||||
DATA_FILE = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "data", "llm_config.json")
|
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):
|
class LLMConfig(BaseModel):
|
||||||
id: str = Field(..., description="Unique identifier for the LLM configuration")
|
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)")
|
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: 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_key: Optional[str] = Field(None, description="API Key for the provider")
|
||||||
api_base: Optional[str] = Field(None, description="Base URL for the API")
|
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")
|
extra_headers: Optional[Dict[str, str]] = Field(None, description="Extra headers for the request")
|
||||||
@@ -19,21 +33,52 @@ class LLMConfig(BaseModel):
|
|||||||
|
|
||||||
class LLMConfigCreate(BaseModel):
|
class LLMConfigCreate(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
|
name: Optional[str] = None
|
||||||
provider: str
|
provider: str
|
||||||
model: str
|
model: str
|
||||||
|
model_type: Optional[str] = None
|
||||||
|
base_model: Optional[str] = None
|
||||||
|
protocol_type: Optional[str] = None
|
||||||
api_key: Optional[str] = None
|
api_key: Optional[str] = None
|
||||||
api_base: Optional[str] = None
|
api_base: Optional[str] = None
|
||||||
extra_headers: Optional[Dict[str, str]] = None
|
extra_headers: Optional[Dict[str, str]] = None
|
||||||
is_active: bool = True
|
is_active: bool = True
|
||||||
|
|
||||||
class LLMConfigUpdate(BaseModel):
|
class LLMConfigUpdate(BaseModel):
|
||||||
|
name: Optional[str] = None
|
||||||
provider: Optional[str] = None
|
provider: Optional[str] = None
|
||||||
model: 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_key: Optional[str] = None
|
||||||
api_base: Optional[str] = None
|
api_base: Optional[str] = None
|
||||||
extra_headers: Optional[Dict[str, str]] = None
|
extra_headers: Optional[Dict[str, str]] = None
|
||||||
is_active: Optional[bool] = 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]]:
|
def _load_data() -> List[Dict[str, Any]]:
|
||||||
if not os.path.exists(DATA_FILE):
|
if not os.path.exists(DATA_FILE):
|
||||||
return []
|
return []
|
||||||
@@ -48,37 +93,50 @@ def _save_data(data: List[Dict[str, Any]]):
|
|||||||
with open(DATA_FILE, "w") as f:
|
with open(DATA_FILE, "w") as f:
|
||||||
json.dump(data, f, indent=2)
|
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])
|
@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()
|
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)
|
@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()
|
data = _load_data()
|
||||||
for item in data:
|
for item in data:
|
||||||
if item["id"] == config_id:
|
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")
|
raise HTTPException(status_code=404, detail="LLM configuration not found")
|
||||||
|
|
||||||
@router.post("/llm", response_model=LLMConfig)
|
@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()
|
data = _load_data()
|
||||||
if any(item["id"] == config.id for item in data):
|
if any(item["id"] == config.id for item in data):
|
||||||
raise HTTPException(status_code=400, detail="LLM configuration with this ID already exists")
|
raise HTTPException(status_code=400, detail="LLM configuration with this ID already exists")
|
||||||
|
|
||||||
new_config = config.dict()
|
new_config = config.dict()
|
||||||
|
if new_config.get("is_active"):
|
||||||
|
for item in data:
|
||||||
|
item["is_active"] = False
|
||||||
data.append(new_config)
|
data.append(new_config)
|
||||||
_save_data(data)
|
_save_data(data)
|
||||||
return LLMConfig(**new_config)
|
return LLMConfig(**new_config)
|
||||||
|
|
||||||
@router.put("/llm/{config_id}", response_model=LLMConfig)
|
@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()
|
data = _load_data()
|
||||||
for i, item in enumerate(data):
|
for i, item in enumerate(data):
|
||||||
if item["id"] == config_id:
|
if item["id"] == config_id:
|
||||||
updated_item = item.copy()
|
updated_item = item.copy()
|
||||||
update_data = config.dict(exclude_unset=True)
|
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)
|
updated_item.update(update_data)
|
||||||
data[i] = updated_item
|
data[i] = updated_item
|
||||||
_save_data(data)
|
_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")
|
raise HTTPException(status_code=404, detail="LLM configuration not found")
|
||||||
|
|
||||||
@router.delete("/llm/{config_id}")
|
@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()
|
data = _load_data()
|
||||||
initial_len = len(data)
|
initial_len = len(data)
|
||||||
data = [item for item in data if item["id"] != config_id]
|
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 { Settings } from "./pages/Settings";
|
||||||
import { Users } from "./pages/Users";
|
import { Users } from "./pages/Users";
|
||||||
import { Login } from "./pages/Login";
|
import { Login } from "./pages/Login";
|
||||||
|
import { ModelConfigs } from "./pages/ModelConfigs";
|
||||||
import { useAuthStore } from "./store/authStore";
|
import { useAuthStore } from "./store/authStore";
|
||||||
|
|
||||||
// Protected Route Component
|
// Protected Route Component
|
||||||
@@ -82,6 +83,14 @@ function App() {
|
|||||||
</MainLayout>
|
</MainLayout>
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
} />
|
} />
|
||||||
|
|
||||||
|
<Route path="/model-configs" element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<MainLayout>
|
||||||
|
<ModelConfigs />
|
||||||
|
</MainLayout>
|
||||||
|
</ProtectedRoute>
|
||||||
|
} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
|
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
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 { useState, useRef, useEffect } from "react";
|
||||||
import { Link, useNavigate } from "react-router-dom";
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
import { useAuthStore } from "@/store/authStore";
|
import { useAuthStore } from "@/store/authStore";
|
||||||
@@ -138,20 +138,33 @@ function SidebarBody() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Settings className="h-4 w-4 text-zinc-500" />
|
<Settings className="h-4 w-4 text-zinc-500" />
|
||||||
Settings
|
个人设置
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{user?.is_admin && (
|
{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"
|
<button
|
||||||
onClick={() => {
|
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-zinc-700 hover:bg-zinc-100 transition-colors"
|
||||||
navigate("/users");
|
onClick={() => {
|
||||||
setShowUserMenu(false);
|
navigate("/model-configs");
|
||||||
}}
|
setShowUserMenu(false);
|
||||||
>
|
}}
|
||||||
<User className="h-4 w-4" />
|
>
|
||||||
User Management
|
<Brain className="h-4 w-4 text-zinc-500" />
|
||||||
</button>
|
模型配置
|
||||||
|
</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" />
|
<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"
|
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}
|
onClick={handleLogout}
|
||||||
>
|
>
|
||||||
Log out
|
退出登录
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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 { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription, CardFooter } from "@/components/ui/card";
|
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 { Save, Loader2 } from "lucide-react";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
|
import { useAuthStore } from "@/store/authStore";
|
||||||
interface LLMConfig {
|
|
||||||
id: string;
|
|
||||||
provider: string;
|
|
||||||
model: string;
|
|
||||||
api_key?: string;
|
|
||||||
api_base?: string;
|
|
||||||
is_active: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Settings() {
|
export function Settings() {
|
||||||
const [configId, setConfigId] = useState<string | null>(null);
|
const { user, updateUser } = useAuthStore();
|
||||||
const [apiKey, setApiKey] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [provider, setProvider] = useState('openai');
|
const [password, setPassword] = useState('');
|
||||||
const [model, setModel] = useState('gpt-4-turbo');
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
const [baseUrl, setBaseUrl] = useState('https://api.openai.com/v1');
|
|
||||||
const [enableVoice, setEnableVoice] = useState(false);
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [success, setSuccess] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchConfig();
|
if (user) {
|
||||||
}, []);
|
setEmail(user.email || '');
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
};
|
}, [user]);
|
||||||
|
const isPasswordMismatch = password !== '' && confirmPassword !== '' && password !== confirmPassword;
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
|
setError('');
|
||||||
|
setSuccess('');
|
||||||
|
|
||||||
|
if (isPasswordMismatch) {
|
||||||
|
setError("两次输入的密码不一致");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
try {
|
try {
|
||||||
const configData = {
|
const updateData: any = {
|
||||||
provider,
|
email: email
|
||||||
model,
|
|
||||||
api_key: apiKey,
|
|
||||||
api_base: baseUrl,
|
|
||||||
is_active: true
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (configId) {
|
if (password) {
|
||||||
await api.put(`/api/v1/llm/${configId}`, configData);
|
updateData.password = password;
|
||||||
} else {
|
|
||||||
const newId = Date.now().toString();
|
|
||||||
await api.post('/api/v1/llm', { ...configData, id: newId });
|
|
||||||
setConfigId(newId);
|
|
||||||
}
|
}
|
||||||
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);
|
console.error("Failed to save settings", error);
|
||||||
alert("Failed to save settings");
|
setError(error.message || "保存设置失败");
|
||||||
} finally {
|
} finally {
|
||||||
setIsSaving(false);
|
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 (
|
return (
|
||||||
<div className="p-6 h-full overflow-y-auto">
|
<div className="flex-1 flex flex-col h-full bg-zinc-50/30 overflow-hidden">
|
||||||
<div className="mb-6">
|
<div className="h-14 px-6 flex items-center justify-between border-b border-zinc-100 bg-white">
|
||||||
<h1 className="text-2xl font-bold">Settings</h1>
|
<div className="flex items-center gap-2 text-zinc-700 font-medium">
|
||||||
<p className="text-muted-foreground">Configure AI model and application preferences</p>
|
<Save className="h-5 w-5 text-indigo-500" />
|
||||||
|
个人设置
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-6 max-w-2xl">
|
<div className="flex-1 p-6 overflow-auto">
|
||||||
<Card>
|
<div className="grid gap-6 max-w-2xl mx-auto">
|
||||||
<CardHeader>
|
{error && <div className="text-sm text-red-600 bg-red-50 border border-red-100 rounded-md p-3">{error}</div>}
|
||||||
<CardTitle>LLM Configuration</CardTitle>
|
{success && <div className="text-sm text-emerald-600 bg-emerald-50 border border-emerald-100 rounded-md p-3">{success}</div>}
|
||||||
<CardDescription>Manage your Large Language Model settings</CardDescription>
|
|
||||||
</CardHeader>
|
<Card className="border-zinc-200 shadow-sm">
|
||||||
<CardContent className="space-y-4">
|
<CardHeader>
|
||||||
<div className="space-y-2">
|
<CardTitle className="text-xl">账号信息</CardTitle>
|
||||||
<Label htmlFor="provider">Provider</Label>
|
<CardDescription>修改您的登录邮箱和密码</CardDescription>
|
||||||
<Select value={provider} onValueChange={(val) => val && setProvider(val)}>
|
</CardHeader>
|
||||||
<SelectTrigger>
|
<CardContent className="space-y-4">
|
||||||
<SelectValue placeholder="Select provider" />
|
<div className="space-y-2">
|
||||||
</SelectTrigger>
|
<Label htmlFor="username">用户名</Label>
|
||||||
<SelectContent>
|
<Input
|
||||||
<SelectItem value="openai">OpenAI</SelectItem>
|
id="username"
|
||||||
<SelectItem value="anthropic">Anthropic</SelectItem>
|
value={user?.username || ''}
|
||||||
<SelectItem value="azure">Azure OpenAI</SelectItem>
|
disabled
|
||||||
<SelectItem value="local">Local (Ollama/LM Studio)</SelectItem>
|
className="bg-zinc-50 text-zinc-500"
|
||||||
</SelectContent>
|
/>
|
||||||
</Select>
|
<p className="text-xs text-zinc-400">用户名不可修改</p>
|
||||||
</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>
|
</div>
|
||||||
<Switch
|
|
||||||
id="voice-mode"
|
<div className="space-y-2">
|
||||||
checked={enableVoice}
|
<Label htmlFor="email">邮箱地址</Label>
|
||||||
onCheckedChange={setEnableVoice}
|
<Input
|
||||||
/>
|
id="email"
|
||||||
</div>
|
type="email"
|
||||||
<div className="flex items-center justify-between">
|
value={email}
|
||||||
<div className="space-y-0.5">
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
<Label htmlFor="dark-mode">Dark Mode</Label>
|
/>
|
||||||
<p className="text-sm text-muted-foreground">Toggle dark/light theme</p>
|
|
||||||
</div>
|
</div>
|
||||||
<Switch id="dark-mode" defaultChecked />
|
|
||||||
</div>
|
<div className="space-y-2 pt-4 border-t border-zinc-100">
|
||||||
</CardContent>
|
<Label htmlFor="new-password">新密码</Label>
|
||||||
<CardFooter>
|
<Input
|
||||||
<Button onClick={handleSave} className="ml-auto" disabled={isSaving}>
|
id="new-password"
|
||||||
{isSaving ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <Save className="h-4 w-4 mr-2" />}
|
type="password"
|
||||||
Save Changes
|
placeholder="如不修改请留空"
|
||||||
</Button>
|
value={password}
|
||||||
</CardFooter>
|
onChange={(e) => {
|
||||||
</Card>
|
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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ export function Users() {
|
|||||||
} else {
|
} else {
|
||||||
// Create
|
// Create
|
||||||
if (!formData.password) {
|
if (!formData.password) {
|
||||||
setError("Password is required for new users");
|
setError("新建用户必须填写密码");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await api.post("/api/v1/users", formData);
|
await api.post("/api/v1/users", formData);
|
||||||
@@ -95,12 +95,12 @@ export function Users() {
|
|||||||
setIsDialogOpen(false);
|
setIsDialogOpen(false);
|
||||||
fetchUsers();
|
fetchUsers();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message || "An error occurred");
|
setError(err.message || "发生错误");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (id: number) => {
|
const handleDelete = async (id: number) => {
|
||||||
if (window.confirm("Are you sure you want to delete this user?")) {
|
if (window.confirm("确认删除该用户吗?")) {
|
||||||
try {
|
try {
|
||||||
await api.delete(`/api/v1/users/${id}`);
|
await api.delete(`/api/v1/users/${id}`);
|
||||||
fetchUsers();
|
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="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">
|
<div className="flex items-center gap-2 text-zinc-700 font-medium">
|
||||||
<UsersIcon className="h-5 w-5 text-indigo-500" />
|
<UsersIcon className="h-5 w-5 text-indigo-500" />
|
||||||
User Management
|
用户管理
|
||||||
</div>
|
</div>
|
||||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
<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()}>
|
<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" />
|
<Plus className="h-4 w-4" />
|
||||||
Add User
|
添加用户
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent className="sm:max-w-[425px]">
|
<DialogContent className="sm:max-w-[425px]">
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{editingUser ? "Edit User" : "Add New User"}</DialogTitle>
|
<DialogTitle>{editingUser ? "编辑用户" : "添加新用户"}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="grid gap-4 py-4">
|
<div className="grid gap-4 py-4">
|
||||||
{error && <div className="text-red-500 text-sm">{error}</div>}
|
{error && <div className="text-red-500 text-sm">{error}</div>}
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="username">Username</Label>
|
<Label htmlFor="username">用户名</Label>
|
||||||
<Input
|
<Input
|
||||||
id="username"
|
id="username"
|
||||||
value={formData.username}
|
value={formData.username}
|
||||||
@@ -139,7 +139,7 @@ export function Users() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="email">Email</Label>
|
<Label htmlFor="email">邮箱</Label>
|
||||||
<Input
|
<Input
|
||||||
id="email"
|
id="email"
|
||||||
type="email"
|
type="email"
|
||||||
@@ -150,7 +150,7 @@ export function Users() {
|
|||||||
</div>
|
</div>
|
||||||
{!editingUser && (
|
{!editingUser && (
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="password">Password</Label>
|
<Label htmlFor="password">密码</Label>
|
||||||
<Input
|
<Input
|
||||||
id="password"
|
id="password"
|
||||||
type="password"
|
type="password"
|
||||||
@@ -161,7 +161,7 @@ export function Users() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label htmlFor="is_active">Active</Label>
|
<Label htmlFor="is_active">激活状态</Label>
|
||||||
<Switch
|
<Switch
|
||||||
id="is_active"
|
id="is_active"
|
||||||
checked={formData.is_active}
|
checked={formData.is_active}
|
||||||
@@ -169,7 +169,7 @@ export function Users() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label htmlFor="is_admin">Admin</Label>
|
<Label htmlFor="is_admin">管理员权限</Label>
|
||||||
<Switch
|
<Switch
|
||||||
id="is_admin"
|
id="is_admin"
|
||||||
checked={formData.is_admin}
|
checked={formData.is_admin}
|
||||||
@@ -179,10 +179,10 @@ export function Users() {
|
|||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button type="button" variant="outline" onClick={() => setIsDialogOpen(false)}>
|
<Button type="button" variant="outline" onClick={() => setIsDialogOpen(false)}>
|
||||||
Cancel
|
取消
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" className="bg-indigo-600 hover:bg-indigo-700 text-white">
|
<Button type="submit" className="bg-indigo-600 hover:bg-indigo-700 text-white">
|
||||||
Save
|
保存
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</form>
|
</form>
|
||||||
@@ -201,19 +201,19 @@ export function Users() {
|
|||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>ID</TableHead>
|
<TableHead>ID</TableHead>
|
||||||
<TableHead>Username</TableHead>
|
<TableHead>用户名</TableHead>
|
||||||
<TableHead>Email</TableHead>
|
<TableHead>邮箱</TableHead>
|
||||||
<TableHead>Status</TableHead>
|
<TableHead>状态</TableHead>
|
||||||
<TableHead>Role</TableHead>
|
<TableHead>角色</TableHead>
|
||||||
<TableHead>Created At</TableHead>
|
<TableHead>创建时间</TableHead>
|
||||||
<TableHead className="text-right">Actions</TableHead>
|
<TableHead className="text-right">操作</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{users.length === 0 ? (
|
{users.length === 0 ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={7} className="text-center h-24 text-zinc-500">
|
<TableCell colSpan={7} className="text-center h-24 text-zinc-500">
|
||||||
No users found.
|
暂无用户数据
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : (
|
) : (
|
||||||
@@ -224,12 +224,12 @@ export function Users() {
|
|||||||
<TableCell>{user.email}</TableCell>
|
<TableCell>{user.email}</TableCell>
|
||||||
<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'}`}>
|
<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>
|
</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<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'}`}>
|
<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>
|
</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-zinc-500">
|
<TableCell className="text-zinc-500">
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ interface AuthState {
|
|||||||
token: string | null;
|
token: string | null;
|
||||||
isAuthenticated: boolean;
|
isAuthenticated: boolean;
|
||||||
login: (user: User, token: string) => void;
|
login: (user: User, token: string) => void;
|
||||||
|
updateUser: (user: Partial<User>) => void;
|
||||||
logout: () => void;
|
logout: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,6 +25,13 @@ export const useAuthStore = create<AuthState>((set) => ({
|
|||||||
localStorage.setItem('token', token);
|
localStorage.setItem('token', token);
|
||||||
set({ user, token, isAuthenticated: true });
|
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: () => {
|
logout: () => {
|
||||||
localStorage.removeItem('user');
|
localStorage.removeItem('user');
|
||||||
localStorage.removeItem('token');
|
localStorage.removeItem('token');
|
||||||
|
|||||||
Reference in New Issue
Block a user