Files
Developer c166d298b8 fix(ui): 批量修复字号/间距/弹窗/表格 UI 问题(接续)
- 密码修改弹窗: max-w-md→max-w-lg, py-3.5→py-3, text-[10px]→text-xs
- 角色选择按钮: flex-wrap + min-w-[100px] 防窄屏换行
- 用户表格: overflow-x-auto + min-w-[700px] 响应式
- 全部 42 处 text-[10px] → text-xs(标签/徽章/说明文字)
- PermissionSettingsView 剩余 2 处 text-[10px]→text-xs

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 10:39:48 +08:00

2308 lines
140 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { ModelConfig, ModelType, AppSettings, KnowledgeGroup, Tenant, TenantMember, DEFAULT_SETTINGS } from '../../types';
import { useLanguage } from '../../contexts/LanguageContext';
import {
ChevronLeft,
ChevronRight,
Plus,
Search,
KeyRound,
Trash2,
Edit,
UserPlus,
Globe,
PlusCircle,
Clock,
ExternalLink,
Download,
Upload,
Building,
Settings as SettingsIcon,
Shield,
User,
MoreVertical,
Check,
ChevronDown,
ChevronUp,
Filter,
RefreshCcw,
LayoutDashboard,
Users,
Database,
UserCircle,
HardDrive,
LayoutGrid,
X,
Key,
Loader2,
Edit2,
Save,
Cpu,
BookOpen,
Sparkles,
ToggleRight,
ToggleLeft,
FileText,
} from "lucide-react";
import { motion, AnimatePresence } from 'framer-motion';
import { userService } from '../../services/userService';
import { settingsService } from '../../services/settingsService';
import { userSettingService } from '../../services/userSettingService';
import { knowledgeGroupService } from '../../services/knowledgeGroupService';
import { apiClient } from '../../services/apiClient';
import { AssessmentTemplateManager } from './AssessmentTemplateManager';
import { PermissionSettingsView } from './PermissionSettingsView';
import { useConfirm } from '../../contexts/ConfirmContext';
import { useToast } from '../../contexts/ToastContext';
interface SettingsViewProps {
// Model Props
models: ModelConfig[];
authToken: string | null;
onUpdateModels: (action: 'create' | 'update' | 'delete', model: ModelConfig) => Promise<void>;
isAdmin?: boolean; // Added isAdmin prop
currentUser?: any; // Added current user prop
initialTab?: TabType;
}
type TabType = 'general' | 'user' | 'model' | 'tenants' | 'knowledge_base' | 'import_tasks' | 'assessment_templates' | 'permissions';
const buildTenantTree = (tenants: Tenant[]): Tenant[] => {
const map = new Map<string, Tenant>();
const roots: Tenant[] = [];
tenants.forEach(t => {
map.set(t.id, { ...t, children: [] });
});
tenants.forEach(t => {
const node = map.get(t.id)!;
if (t.parentId && map.has(t.parentId)) {
const parent = map.get(t.parentId)!;
parent.children = parent.children || [];
parent.children.push(node);
} else {
roots.push(node);
}
});
return roots;
};
// Moved outside to prevent re-mounting
const Pagination: React.FC<{
current: number;
total: number;
pageSize: number;
onChange: (page: number) => void;
}> = ({ current, total, pageSize, onChange }) => {
const totalPages = Math.ceil(total / pageSize);
if (totalPages <= 1) return null;
return (
<div className="flex items-center justify-center gap-2 mt-6">
<button
disabled={current === 1}
onClick={() => onChange(current - 1)}
className="p-2 rounded-xl bg-white border border-slate-200 text-slate-600 disabled:opacity-30 disabled:cursor-not-allowed hover:bg-slate-50 transition-colors"
>
<ChevronDown className="w-4 h-4 rotate-90" />
</button>
<div className="flex items-center gap-1">
{[...Array(totalPages)].map((_, i) => {
const p = i + 1;
if (totalPages > 7) {
if (p !== 1 && p !== totalPages && Math.abs(p - current) > 1) {
if (p === 2 || p === totalPages - 1) return <span key={p} className="px-1 font-bold text-slate-300">...</span>;
return null;
}
}
return (
<button
key={p}
onClick={() => onChange(p)}
className={`w-9 h-9 flex items-center justify-center rounded-xl text-xs font-black transition-all ${current === p ? 'bg-indigo-600 text-white shadow-lg shadow-indigo-100' : 'bg-white border border-slate-200 text-slate-500 hover:border-indigo-500 hover:text-indigo-600'}`}
>
{p}
</button>
);
})}
</div>
<button
disabled={current === totalPages}
onClick={() => onChange(current + 1)}
className="p-2 rounded-xl bg-white border border-slate-200 text-slate-600 disabled:opacity-30 disabled:cursor-not-allowed hover:bg-slate-50 transition-colors"
>
<ChevronDown className="w-4 h-4 -rotate-90" />
</button>
</div>
);
};
export const SettingsView: React.FC<SettingsViewProps> = ({
models,
authToken,
onUpdateModels,
isAdmin = false,
currentUser,
initialTab = 'general',
}) => {
const { t, language, setLanguage } = useLanguage();
const { confirm } = useConfirm();
const { showError, showSuccess } = useToast();
const [activeTab, setActiveTab] = useState<TabType>('general');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// --- Model Manager State ---
const [editingId, setEditingId] = useState<string | null>(null);
const [modelFormData, setModelFormData] = useState<Partial<ModelConfig>>({
type: ModelType.LLM,
baseUrl: 'http://localhost:11434/v1',
modelId: 'llama3',
name: '',
dimensions: 1536,
apiKey: '',
maxInputTokens: 8191,
maxBatchSize: 2048
});
const [users, setUsers] = useState<any[]>([]);
const [isUserLoading, setIsUserLoading] = useState(false);
const [userPage, setUserPage] = useState(1);
const USER_PAGE_SIZE = 20;
const [showAddUser, setShowAddUser] = useState(false);
const [newUser, setNewUser] = useState({ username: '', password: '', displayName: '' });
const [userSuccess, setUserSuccess] = useState('');
// --- Change Password State ---
const [passwordForm, setPasswordForm] = useState({ current: '', new: '', confirm: '' });
const [passwordSuccess, setPasswordSuccess] = useState('');
// --- App Settings State ---
const [appSettings, setAppSettings] = useState<AppSettings | null>(null);
const [knowledgeGroups, setKnowledgeGroups] = useState<KnowledgeGroup[]>([]);
const [isSettingsLoading, setIsSettingsLoading] = useState(false);
const [enabledModelIds, setEnabledModelIds] = useState<string[]>([]);
// --- Tenant Admin Binding Search State ---
const [bindingTenantId, setBindingTenantId] = useState<string | null>(null);
const [userSearchQuery, setUserSearchQuery] = useState('');
// --- Manage Members Modal State ---
const [managingMembersTenantId, setManagingMembersTenantId] = useState<string | null>(null);
const [tenantMembers, setTenantMembers] = useState<any[]>([]);
const [allMemberIds, setAllMemberIds] = useState<Set<string>>(new Set());
const [memberUserSearch, setMemberUserSearch] = useState('');
const [bindingRole, setBindingRole] = useState('USER');
const [currentMemberSearch, setCurrentMemberSearch] = useState('');
const [isMembersLoading, setIsMembersLoading] = useState(false);
const [activeTenantManagementId, setActiveTenantManagementId] = useState<string | null>(null);
const [memberPage, setMemberPage] = useState(1);
const [memberTotal, setMemberTotal] = useState(0);
const MEMBER_PAGE_SIZE = 20;
const [userTotal, setUserTotal] = useState(0);
// --- Tenant Tree & Global Management State ---
const [tenants, setTenants] = useState<Tenant[]>([]);
const [selectedTenantId, setSelectedTenantId] = useState<string | null>(null);
const [stats, setStats] = useState({ users: 0, tenants: 0 });
const [showCreateTenant, setShowCreateTenant] = useState(false);
const [editingTenant, setEditingTenant] = useState<Tenant | null>(null);
const [newTenant, setNewTenant] = useState<{ name: string; domain: string; parentId: string | null }>({
name: '',
domain: '',
parentId: null
});
useEffect(() => {
if (initialTab) {
setActiveTab(initialTab);
}
}, [initialTab]);
useEffect(() => {
if (activeTab === 'user' || activeTab === 'tenants') {
fetchUsers(userPage);
}
}, [userPage]);
useEffect(() => {
if (selectedTenantId) {
fetchTenantMembers(selectedTenantId, memberPage);
fetchAllMemberIds(selectedTenantId);
} else {
setAllMemberIds(new Set());
}
}, [selectedTenantId, memberPage]);
// Data fetching on tab change
useEffect(() => {
// Reset pages when switching tabs to avoid bleed-over
if (activeTab === 'user' || activeTab === 'tenants') {
setUserPage(1);
}
if (activeTab === 'user') {
fetchUsers(1);
} else if (activeTab === 'general') {
fetchSettingsAndGroups();
} else if (activeTab === 'tenants' && currentUser?.role === 'SUPER_ADMIN') {
fetchTenantsData();
fetchUsers(1); // Ensure users are loaded for admin binding
}
// Independent check for KB/Model settings to avoid being blocked by the branches above
if ((activeTab === 'knowledge_base' || activeTab === 'model') &&
(currentUser?.role === 'TENANT_ADMIN' || currentUser?.role === 'SUPER_ADMIN' || isAdmin)) {
fetchKnowledgeBaseSettings();
}
}, [activeTab, currentUser, authToken, isAdmin]);
const [kbSettings, setKbSettings] = useState<any>(null);
const [localKbSettings, setLocalKbSettings] = useState<any>(null);
const [isSavingKbSettings, setIsSavingKbSettings] = useState(false);
const fetchKnowledgeBaseSettings = async () => {
if (!authToken) return;
setIsLoading(true);
try {
const data = await userSettingService.get(authToken);
// If data is null, undefined, or empty object, use DEFAULT_SETTINGS
const finalSettings = (data && Object.keys(data).length > 0) ? { ...DEFAULT_SETTINGS, ...data } : DEFAULT_SETTINGS;
setKbSettings(finalSettings);
setLocalKbSettings(finalSettings);
} catch (error) {
console.error(error);
// Fallback to defaults on error to prevent blank page
setKbSettings(DEFAULT_SETTINGS);
setLocalKbSettings(DEFAULT_SETTINGS);
} finally {
setIsLoading(false);
}
};
const handleUpdateKbSettings = (key: string, value: any) => {
setLocalKbSettings((prev: any) => ({ ...prev, [key]: value }));
};
const handleSaveKbSettings = async () => {
if (!authToken || !localKbSettings) return;
setIsSavingKbSettings(true);
try {
await userSettingService.update(authToken, localKbSettings);
setKbSettings(localKbSettings);
showSuccess(t('kbSettingsSaved'));
} catch (error) {
console.error(error);
showError(t('actionFailed'));
} finally {
setIsSavingKbSettings(false);
}
};
const handleCancelKbSettings = () => {
setLocalKbSettings(kbSettings);
};
const fetchSettingsAndGroups = async () => {
if (!authToken) return;
setIsSettingsLoading(true);
try {
const [settings, groups, personal] = await Promise.all([
userSettingService.get(authToken),
knowledgeGroupService.getGroups(),
userSettingService.getPersonal(authToken)
]);
setAppSettings(settings);
setKnowledgeGroups(groups);
// Sync local language with user settings if they differ
if (personal?.language && personal.language !== language) {
setLanguage(personal.language as any);
}
// Also update KB settings with the same data if not already set
if (settings && Object.keys(settings).length > 0) {
setKbSettings(settings);
setLocalKbSettings(settings);
}
} catch (error) {
console.error('Failed to fetch settings or groups:', error);
} finally {
setIsSettingsLoading(false);
}
};
// --- General tab handlers ---
const handleChangePassword = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setPasswordSuccess('');
if (passwordForm.new !== passwordForm.confirm) {
setError(t('passwordMismatch'));
return;
}
if (passwordForm.new.length < 6) {
setError(t('newPasswordMinLength'));
return;
}
setIsLoading(true);
try {
await userService.changePassword(passwordForm.current, passwordForm.new);
setPasswordSuccess(t('passwordChangeSuccess'));
setPasswordForm({ current: '', new: '', confirm: '' });
} catch (err: any) {
setError(err.message || t('passwordChangeFailed'));
} finally {
setIsLoading(false);
}
};
// --- ユーザータブのハンドラー ---
const fetchUsers = async (page?: number) => {
setIsUserLoading(true);
const p = page || userPage;
try {
const result = await userService.getUsers(p, USER_PAGE_SIZE);
if (result && result.data) {
setUsers(result.data);
setUserTotal(result.total);
} else if (Array.isArray(result)) {
setUsers(result);
setUserTotal(result.length);
}
} catch (error: any) {
setError(error.message || t('getUserListFailed'));
} finally {
setIsUserLoading(false);
}
};
const handleCreateUser = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setUserSuccess('');
if (newUser.username && newUser.password && newUser.displayName) {
setIsUserLoading(true);
try {
await userService.createUser(
newUser.username,
newUser.password,
false,
undefined,
newUser.displayName
);
showSuccess(t('userCreatedSuccess'));
setNewUser({ username: '', password: '', displayName: '' });
setShowAddUser(false);
fetchUsers();
} catch (error: any) {
setError(error.message || t('createUserFailed'));
} finally {
setIsUserLoading(false);
}
}
};
const [passwordChangeUserData, setPasswordChangeUserData] = useState<{ userId: string, newPassword: string } | null>(null);
// --- Edit User State ---
const [editUserData, setEditUserData] = useState<{
userId: string;
username: string;
displayName: string;
memberId?: string;
tenantId?: string;
role?: string;
isAdmin?: boolean;
} | null>(null);
const handleToggleUserAdmin = async (userId: string, newAdminStatus: boolean) => {
try {
await userService.updateUser(userId, newAdminStatus);
// Re-fetch user list
fetchUsers();
setUserSuccess(newAdminStatus ? t('userPromotedToAdmin') : t('userDemotedFromAdmin'));
} catch (error: any) {
setError(error.message || t('updateUserFailed'));
}
};
const handleUserPasswordChange = async () => {
if (!passwordChangeUserData || !passwordChangeUserData.newPassword) return;
try {
// Update user password
await userService.updateUserInfo(passwordChangeUserData.userId, { password: passwordChangeUserData.newPassword });
setUserSuccess(t('passwordChangeSuccess'));
setPasswordChangeUserData(null);
fetchUsers(); // Refresh the user list
} catch (error: any) {
setError(error.message || t('passwordChangeFailed'));
}
};
const fetchAllMemberIds = async (tenantId: string) => {
try {
const { data } = await apiClient.get<string[]>(`/v1/tenants/${tenantId}/members/ids`);
if (Array.isArray(data)) {
setAllMemberIds(new Set(data));
}
} catch (e) {
console.error('Failed to fetch all member IDs:', e);
}
};
const fetchTenantMembers = async (tenantId: string, page?: number) => {
setIsMembersLoading(true);
const p = page || memberPage;
try {
const { data } = await apiClient.get(`/v1/tenants/${tenantId}/members?page=${p}&limit=${MEMBER_PAGE_SIZE}`);
if (data && data.data) {
setTenantMembers(data.data);
setMemberTotal(data.total);
} else if (Array.isArray(data)) {
setTenantMembers(data);
setMemberTotal(data.length);
}
} catch (e) {
console.error(e);
} finally {
setIsMembersLoading(false);
}
};
const handleAddMember = async (tenantId: string, userId: string, role: string = 'USER') => {
try {
await apiClient.post(`/v1/tenants/${tenantId}/members`, { userId, role });
setAllMemberIds(prev => {
const next = new Set(prev);
next.add(userId);
return next;
});
showSuccess(t('confirm'));
fetchTenantMembers(tenantId);
fetchTenantsData();
} catch (e: any) {
showError(e.message || 'Error adding member');
}
};
const handleRemoveMember = async (tenantId: string, userId: string) => {
try {
await apiClient.delete(`/v1/tenants/${tenantId}/members/${userId}`);
setAllMemberIds(prev => {
const next = new Set(prev);
next.delete(userId);
return next;
});
showSuccess('User removed from organization');
fetchTenantMembers(tenantId);
fetchTenantsData();
} catch (e: any) {
showError(e.message || 'Error removing member');
}
};
const handleUpdateMemberRole = async (tenantId: string, userId: string, role: string) => {
try {
await apiClient.patch(`/v1/tenants/${tenantId}/members/${userId}`, { role });
showSuccess(t('featureUpdated'));
fetchTenantMembers(tenantId);
} catch (e: any) {
showError(e.message || 'Error updating role');
}
};
const fetchTenantsData = async () => {
if (!authToken) return;
setIsLoading(true);
try {
const [tenRes, admRes] = await Promise.all([
apiClient.get('/v1/tenants'),
apiClient.get('/users?page=1&limit=1')
]);
const data: Tenant[] = tenRes.data;
const filteredData = data.filter(t => t.name !== 'Default');
setTenants(filteredData);
setStats(s => ({ ...s, tenants: filteredData.length }));
const result = admRes.data;
setStats(s => ({ ...s, users: result.total ?? result.length ?? 0 }));
} catch (e) {
console.error(e);
} finally {
setIsLoading(false);
}
};
const handleCreateTenant = async (e: React.FormEvent) => {
e.preventDefault();
try {
const path = editingTenant ? `/v1/tenants/${editingTenant.id}` : '/v1/tenants';
const body = {
name: newTenant.name,
domain: newTenant.domain,
parentId: newTenant.parentId
};
if (editingTenant) {
await apiClient.put(path, body);
} else {
await apiClient.post(path, body);
}
setShowCreateTenant(false);
setEditingTenant(null);
setNewTenant({ name: '', domain: '', parentId: null });
fetchTenantsData();
showSuccess(editingTenant ? 'Tenant updated' : 'Tenant created');
} catch (e: any) {
showError(e.message || 'Action failed');
}
};
const handleRemoveTenant = async (tenantId: string) => {
if (!(await confirm(t('confirmDeleteTenant')))) return;
try {
await apiClient.delete(`/v1/tenants/${tenantId}`);
setSelectedTenantId(null);
fetchTenantsData();
showSuccess('Tenant deleted');
} catch (e: any) {
showError(e.message || 'Delete failed');
}
};
const handleUpdateUser = async () => {
if (!editUserData) return;
try {
// 更新基本信息
await userService.updateUserInfo(editUserData.userId, {
username: editUserData.username,
displayName: editUserData.displayName,
isAdmin: editUserData.role === 'SUPER_ADMIN',
});
// 更新角色(通过 tenant member API
if (editUserData.memberId && editUserData.tenantId) {
const res = await fetch(`/api/v1/tenants/${editUserData.tenantId}/members/${editUserData.userId}`, {
method: 'PATCH',
headers: {
'x-api-key': authToken,
'Content-Type': 'application/json',
},
body: JSON.stringify({ role: editUserData.role }),
});
if (!res.ok) console.warn('Role update returned', res.status);
}
showSuccess(t('featureUpdated'));
setEditUserData(null);
fetchUsers();
} catch (error: any) {
showError('Failed to update user');
}
};
const handleDeleteUser = async (userId: string) => {
if (!confirm(t('confirmDeleteUser') || "Are you sure?")) return;
try {
await userService.deleteUser(userId);
showSuccess(t('userDeletedSuccessfully'));
fetchUsers();
} catch (error: any) {
showError(error.message || t('deleteUserFailed'));
}
};
const handleExportUsers = async () => {
try {
const blob = await userService.exportUsers();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `users_${new Date().toISOString().split('T')[0]}.xlsx`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
} catch (error) {
console.error('Export users failed', error);
showError(t('exportFailed'));
}
};
const handleImportUsers = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
try {
const result = await userService.importUsers(file);
showSuccess(t('importSuccess').replace('$1', (result.success || 0).toString()).replace('$2', (result.failed || 0).toString()));
fetchUsers();
if (result.errors.length > 0) {
console.warn('Import had errors:', result.errors);
}
} catch (error: any) {
console.error('Import users failed', error);
showError(t('importFailed') + (error.response?.data?.message ? `: ${error.response.data.message}` : ''));
} finally {
// Reset input
e.target.value = '';
}
};
const handleSaveModel = async () => {
if (!authToken) return;
setIsLoading(true);
try {
await onUpdateModels(editingId === 'new' ? 'create' : 'update', modelFormData as ModelConfig);
setEditingId(null);
} catch (err) {
setError('Update failed');
} finally {
setIsLoading(false);
}
};
const handleToggleModel = async (model: ModelConfig) => {
if (currentUser?.role === 'TENANT_ADMIN') {
const newEnabledIds = enabledModelIds.includes(model.id)
? enabledModelIds.filter(id => id !== model.id)
: [...enabledModelIds, model.id];
try {
await apiClient.put('/v1/admin/settings', { enabledModelIds: newEnabledIds });
setEnabledModelIds(newEnabledIds);
setLocalKbSettings((prev: any) => ({ ...prev, enabledModelIds: newEnabledIds }));
showSuccess('Updated');
} catch (e: any) {
showError(e.message || 'Update failed');
}
return;
}
await onUpdateModels('update', { ...model, isEnabled: !model.isEnabled });
};
const handleDeleteModel = async (id: string) => {
if (await confirm(t('confirmDeleteModel'))) {
await onUpdateModels('delete', { id } as ModelConfig);
}
};
const TenantTreeNode: React.FC<{
tenant: Tenant;
selectedTenantId: string | null;
onSelect: (id: string) => void;
onCreateSubtenant: (parentId: string) => void;
depth?: number;
}> = ({ tenant, selectedTenantId, onSelect, onCreateSubtenant, depth = 0 }) => {
const [collapsed, setCollapsed] = useState(false);
const hasChildren = tenant.children && tenant.children.length > 0;
const isSelected = selectedTenantId === tenant.id;
return (
<div className="select-none">
<div
className={`group flex items-center gap-2 px-3 py-2 rounded-xl cursor-pointer transition-all ${isSelected ? 'bg-indigo-600 text-white shadow-lg shadow-indigo-100' : 'hover:bg-slate-50 text-slate-600 hover:text-slate-900'}`}
style={{ paddingLeft: `${depth * 1.5 + 0.75}rem` }}
onClick={() => onSelect(tenant.id)}
>
<div className="flex items-center gap-2 flex-1 min-w-0">
{hasChildren ? (
<button
onClick={(e) => { e.stopPropagation(); setCollapsed(!collapsed); }}
className={`p-0.5 rounded-md hover:bg-black/10 transition-colors ${isSelected ? 'text-white/70' : 'text-slate-400'}`}
>
{collapsed ? <ChevronRight size={14} /> : <ChevronDown size={14} />}
</button>
) : (
<div className="w-5" />
)}
<Building size={16} className={isSelected ? 'text-white' : 'text-slate-400'} />
<span className="text-sm font-bold truncate">{tenant.name}</span>
</div>
<button
onClick={(e) => {
e.stopPropagation();
onCreateSubtenant(tenant.id);
}}
className={`p-1.5 rounded-lg opacity-0 group-hover:opacity-100 transition-all ${isSelected ? 'hover:bg-white/20 text-white' : 'hover:bg-slate-200 text-slate-400 hover:text-indigo-600'}`}
title={t('createSubOrg')}
>
<Plus size={14} />
</button>
</div>
{hasChildren && !collapsed && (
<div className="mt-1">
{tenant.children?.map(child => (
<TenantTreeNode
key={child.id}
tenant={child}
selectedTenantId={selectedTenantId}
onSelect={onSelect}
onCreateSubtenant={onCreateSubtenant}
depth={depth + 1}
/>
))}
</div>
)}
</div>
);
};
const getTypeLabel = (type: ModelType) => {
switch (type) {
case ModelType.LLM: return t('typeLLM');
case ModelType.EMBEDDING: return t('typeEmbedding');
case ModelType.RERANK: return t('typeRerank');
case ModelType.VISION: return t('typeVision');
default: return type;
}
};
// --- Rendering functions ---
const renderGeneralTab = () => (
<div className="space-y-8 animate-in slide-in-from-right duration-300 max-w-2xl">
{/* Change password section */}
<section className="bg-white p-6 rounded-lg border border-slate-200 shadow-sm">
<h3 className="text-sm font-medium text-slate-800 mb-4 flex items-center gap-2">
<Key className="w-4 h-4 text-blue-500" />
{t('changePassword')}
</h3>
<form onSubmit={handleChangePassword} className="space-y-4 max-w-sm">
<input type="hidden" name="username" value="current-user" autoComplete="username" />
<div>
<input
type="password"
name="currentPassword"
placeholder={t('currentPassword')}
value={passwordForm.current}
onChange={e => setPasswordForm({ ...passwordForm, current: e.target.value })}
className="w-full px-3 py-2 text-sm border border-slate-300 rounded-md focus:ring-2 focus:ring-blue-500 outline-none"
required
autoComplete="current-password"
/>
</div>
<div>
<input
type="password"
name="newPassword"
placeholder={t('newPassword')}
value={passwordForm.new}
onChange={e => setPasswordForm({ ...passwordForm, new: e.target.value })}
className="w-full px-3 py-2 text-sm border border-slate-300 rounded-md focus:ring-2 focus:ring-blue-500 outline-none"
required
autoComplete="new-password"
/>
</div>
<div>
<input
type="password"
name="confirmPassword"
placeholder={t('confirmPassword')}
value={passwordForm.confirm}
onChange={e => setPasswordForm({ ...passwordForm, confirm: e.target.value })}
className="w-full px-3 py-2 text-sm border border-slate-300 rounded-md focus:ring-2 focus:ring-blue-500 outline-none"
required
autoComplete="new-password"
/>
</div>
{passwordSuccess && <p className="text-xs text-green-600">{passwordSuccess}</p>}
<button
type="submit"
disabled={isLoading}
className="w-full py-2 bg-slate-800 text-white rounded-md text-sm hover:bg-slate-900 transition-colors disabled:opacity-50"
>
{isLoading ? <Loader2 className="w-4 h-4 animate-spin mx-auto" /> : t('confirmChange')}
</button>
</form>
</section>
{/* 语言设置セクション */}
<section className="bg-white p-6 rounded-lg border border-slate-200 shadow-sm">
<h3 className="text-sm font-medium text-slate-800 mb-4 flex items-center gap-2">
<Globe className="w-4 h-4 text-blue-500" />
{t('languageSettings')}
</h3>
<div className="space-y-4 max-w-sm">
<div className="space-y-2">
<label className="text-xs font-black text-slate-400 uppercase tracking-widest px-1">
{t('switchLanguage')}
</label>
<select
value={language}
onChange={async (e) => {
const newLang = e.target.value as any;
setLanguage(newLang);
try {
await settingsService.updateLanguage(newLang);
showSuccess(t('confirm'));
} catch (err) {
console.error('Failed to update backend language preference:', err);
}
}}
className="w-full px-3 py-2 text-sm border border-slate-300 rounded-md focus:ring-2 focus:ring-blue-500 outline-none bg-white"
>
<option value="en">English</option>
<option value="zh"> (Chinese)</option>
<option value="ja"> (Japanese)</option>
</select>
</div>
</div>
</section>
</div >
);
const renderUserTab = () => (
<div className="space-y-6 w-full">
<div className="flex justify-between items-center mb-6">
<div>
<p className="text-xs text-slate-400 font-medium">{''}</p>
</div>
{currentUser?.role === 'SUPER_ADMIN' && (
<div className="flex gap-2">
<button
onClick={handleExportUsers}
className="flex items-center gap-2 px-4 py-2.5 bg-white border border-slate-200 text-slate-600 text-sm font-bold rounded-2xl hover:bg-slate-50 shadow-sm transition-all active:scale-95"
title={t('exportUsers')}
>
<Download className="w-4 h-4" />
<span className="hidden sm:inline">{t('exportUsers')}</span>
</button>
<div className="relative">
<input
type="file"
accept=".xlsx,.xls,.csv"
onChange={handleImportUsers}
className="absolute inset-0 opacity-0 cursor-pointer"
title={t('importUsers')}
/>
<button
className="flex items-center gap-2 px-4 py-2.5 bg-white border border-slate-200 text-slate-600 text-sm font-bold rounded-2xl hover:bg-slate-50 shadow-sm transition-all active:scale-95"
>
<Upload className="w-4 h-4" />
<span className="hidden sm:inline">{t('importUsers')}</span>
</button>
</div>
<button
onClick={() => setShowAddUser(!showAddUser)}
className="flex items-center gap-2 px-6 py-2.5 bg-indigo-600 text-white text-sm font-bold rounded-2xl hover:bg-indigo-700 shadow-lg shadow-indigo-100 transition-all active:scale-95"
>
<Plus className="w-4 h-4" />
{t('addUser')}
</button>
</div>
)}
</div>
{showAddUser && (
<motion.form
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
onSubmit={handleCreateUser}
className="bg-white/80 backdrop-blur-md p-8 rounded-3xl border border-slate-200/50 shadow-xl mb-8 space-y-5"
>
<div className="grid grid-cols-1 md:grid-cols-3 gap-5">
<input
type="text"
placeholder={t('usernamePlaceholder')}
value={newUser.username}
onChange={e => setNewUser({ ...newUser, username: e.target.value })}
className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-2xl text-sm font-medium focus:ring-4 focus:ring-indigo-500/10 focus:border-indigo-500/50 outline-none transition-all"
required
autoComplete="username"
/>
<input
type="text"
placeholder={t('displayNamePlaceholder') || t('name')}
value={newUser.displayName}
onChange={e => setNewUser({ ...newUser, displayName: e.target.value })}
className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-2xl text-sm font-medium focus:ring-4 focus:ring-indigo-500/10 focus:border-indigo-500/50 outline-none transition-all"
required
autoComplete="name"
/>
<input
type="password"
placeholder={t('passwordPlaceholder')}
value={newUser.password}
onChange={e => setNewUser({ ...newUser, password: e.target.value })}
className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-2xl text-sm font-medium focus:ring-4 focus:ring-indigo-500/10 focus:border-indigo-500/50 outline-none transition-all"
required
autoComplete="new-password"
/>
</div>
<div className="flex items-center justify-between">
<div></div>
<div className="flex gap-3">
<button type="button" onClick={() => setShowAddUser(false)} className="px-5 py-2 text-xs font-bold text-slate-500 hover:text-slate-700">{t('cancel')}</button>
<button type="submit" className="px-8 py-2 bg-slate-900 text-white rounded-xl text-xs font-black uppercase tracking-widest hover:bg-indigo-600 transition-all shadow-lg shadow-slate-100">{t('create')}</button>
</div>
</div>
</motion.form>
)}
{createPortal(
<AnimatePresence>
{passwordChangeUserData && (
<div key="password-change-modal" className="fixed inset-0 bg-slate-900/40 backdrop-blur-sm flex items-center justify-center z-[1000] p-6">
<motion.div
initial={{ scale: 0.95, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.95, opacity: 0 }}
className="bg-white rounded-3xl p-10 w-full max-w-lg shadow-2xl border border-white/20"
>
<div className="flex items-center justify-between mb-8">
<h3 className="text-xl font-black text-slate-900 tracking-tight">{t('changeUserPassword')}</h3>
<button onClick={() => setPasswordChangeUserData(null)} className="p-2 hover:bg-slate-100 rounded-xl transition-all">
<X size={20} className="text-slate-400" />
</button>
</div>
<form onSubmit={(e) => { e.preventDefault(); handleUserPasswordChange(); }} className="space-y-6">
<div>
<label className="block text-xs font-black text-slate-400 uppercase tracking-wider mb-2 px-1">
{t('newPassword')}
</label>
<input
type="password"
value={passwordChangeUserData.newPassword}
onChange={(e) => setPasswordChangeUserData({ ...passwordChangeUserData, newPassword: e.target.value })}
className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-2xl text-sm font-medium transition-all focus:outline-none focus:ring-4 focus:ring-indigo-500/10 focus:border-indigo-500/50"
placeholder={t('enterNewPassword')}
required
/>
</div>
<div className="flex gap-4 pt-4">
<button type="button" onClick={() => setPasswordChangeUserData(null)} className="flex-1 py-3 text-slate-500 font-bold text-sm">{t('cancel')}</button>
<button type="submit" className="flex-1 py-3 bg-slate-900 text-white rounded-2xl font-black uppercase tracking-widest text-xs hover:bg-indigo-600 shadow-xl shadow-slate-100 transition-all">{t('confirmChange')}</button>
</div>
</form>
</motion.div>
</div>
)}
</AnimatePresence>,
document.body
)}
{/* Edit User Modal */}
{createPortal(
<AnimatePresence>
{editUserData && (
<div key="edit-user-modal" className="fixed inset-0 z-[1000] flex items-center justify-center bg-slate-900/40 backdrop-blur-sm p-4">
<motion.div
initial={{ scale: 0.95, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.95, opacity: 0 }}
className="bg-white rounded-3xl p-10 w-full max-w-lg shadow-2xl border border-white/20"
>
<div className="flex items-center justify-between mb-8">
<h3 className="text-xl font-black text-slate-900 tracking-tight">{t('editUser')}</h3>
<button onClick={() => setEditUserData(null)} className="p-2 hover:bg-slate-100 rounded-xl transition-all">
<X size={20} className="text-slate-400" />
</button>
</div>
<form onSubmit={(e) => { e.preventDefault(); handleUpdateUser(); }} className="space-y-6">
<div>
<label className="block text-xs font-black text-slate-400 uppercase tracking-wider mb-2 px-1">
{t('username')}
</label>
<input
type="text"
value={editUserData.username}
onChange={(e) => setEditUserData({ ...editUserData, username: e.target.value })}
className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-2xl text-sm font-medium transition-all focus:outline-none focus:ring-4 focus:ring-indigo-500/10 focus:border-indigo-500/50"
placeholder={t('usernamePlaceholder')}
required
/>
</div>
<div>
<label className="block text-xs font-black text-slate-400 uppercase tracking-wider mb-2 px-1">
{t('displayName') || t('name')}
</label>
<input
type="text"
value={editUserData.displayName}
onChange={(e) => setEditUserData({ ...editUserData, displayName: e.target.value })}
className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-2xl text-sm font-medium transition-all focus:outline-none focus:ring-4 focus:ring-indigo-500/10 focus:border-indigo-500/50"
placeholder={t('displayNamePlaceholder') || t('namePlaceholder')}
required
/>
</div>
{/* 角色选择 */}
<div>
<label className="block text-xs font-black text-slate-400 uppercase tracking-wider mb-2 px-1">
</label>
<div className="flex flex-wrap gap-2">
{['USER', 'TENANT_ADMIN', 'SUPER_ADMIN'].map(r => (
<button
key={r}
type="button"
onClick={() => setEditUserData({ ...editUserData, role: r })}
disabled={r === 'SUPER_ADMIN' && currentUser?.role !== 'SUPER_ADMIN'}
className={`flex-1 min-w-[100px] px-4 py-2.5 rounded-xl text-xs font-black uppercase tracking-wider transition-all border-2 ${
editUserData.role === r
? r === 'SUPER_ADMIN'
? 'border-red-500 bg-red-50 text-red-700'
: r === 'TENANT_ADMIN'
? 'border-indigo-500 bg-indigo-50 text-indigo-700'
: 'border-slate-300 bg-slate-50 text-slate-600'
: 'border-transparent text-slate-400 hover:bg-slate-50'
} ${
r === 'SUPER_ADMIN' && currentUser?.role !== 'SUPER_ADMIN'
? 'opacity-30 cursor-not-allowed'
: 'cursor-pointer'
}`}
>
{r === 'SUPER_ADMIN' ? '超级管理员' : r === 'TENANT_ADMIN' ? '管理员' : '用户'}
</button>
))}
</div>
{editUserData.role === 'SUPER_ADMIN' && currentUser?.role !== 'SUPER_ADMIN' && (
<p className="text-xs text-amber-600 mt-1"></p>
)}
</div>
{/* 权限预览 */}
<div className="py-3 px-4 bg-slate-50 rounded-2xl border border-slate-100">
<p className="text-xs font-black text-slate-400 uppercase tracking-wider mb-2">
({editUserData.role === 'SUPER_ADMIN' ? '26项' : editUserData.role === 'TENANT_ADMIN' ? '21项' : '5项'})
</p>
<div className="flex flex-wrap gap-1">
{editUserData.role === 'SUPER_ADMIN' && (
['全部权限:用户管理、租户管理、知识库、考核评估、模型配置、插件管理、系统设置'].map(p => (
<span key={p} className="px-2 py-0.5 bg-indigo-50 text-indigo-600 text-xs font-bold rounded-md">{p}</span>
))
)}
{editUserData.role === 'TENANT_ADMIN' && (
['查看用户','创建用户','编辑用户','重置密码','管理知识库','管理考核','管理模型','管理插件'].map(p => (
<span key={p} className="px-2 py-0.5 bg-indigo-50 text-indigo-600 text-xs font-bold rounded-md">{p}</span>
))
)}
{editUserData.role === 'USER' && (
['使用知识库','参与考核'].map(p => (
<span key={p} className="px-2 py-0.5 bg-slate-100 text-slate-500 text-xs font-bold rounded-md">{p}</span>
))
)}
</div>
</div>
<div className="flex gap-4 pt-4">
<button type="button" onClick={() => setEditUserData(null)} className="flex-1 py-3.5 text-slate-500 font-bold text-sm">{t('cancel')}</button>
<button type="submit" className="flex-1 py-3.5 bg-slate-900 text-white rounded-2xl font-black uppercase tracking-widest text-xs hover:bg-indigo-600 shadow-xl shadow-slate-100 transition-all">{t('saveChanges')}</button>
</div>
</form>
</motion.div>
</div>
)}
</AnimatePresence>,
document.body
)}
<div className="w-full bg-white/70 backdrop-blur-md border border-slate-200/50 rounded-2xl overflow-x-auto shadow-sm">
<table className="w-full border-collapse text-left min-w-[700px]">
<thead>
<tr className="bg-slate-50/50 border-b border-slate-200/50">
<th className="px-6 py-3 text-xs font-black text-slate-400 uppercase tracking-wider">{t('username')}</th>
<th className="px-6 py-3 text-xs font-black text-slate-400 uppercase tracking-wider">{t('displayName') || t('name')}</th>
<th className="px-6 py-3 text-xs font-black text-slate-400 uppercase tracking-wider">{t('organizations')}</th>
<th className="px-6 py-3 text-xs font-black text-slate-400 uppercase tracking-wider"></th>
<th className="px-6 py-3 text-xs font-black text-slate-400 uppercase tracking-wider">{t('createdAt')}</th>
<th className="px-6 py-3 text-xs font-black text-slate-400 uppercase tracking-wider text-right">{t('actions')}</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
<AnimatePresence>
{users.filter((u: any) => u.username !== 'admin').map((user: any, index: number) => {
let IconComponent = User;
let iconColors = 'bg-slate-50 text-slate-400';
if (user.isAdmin) {
IconComponent = Shield;
iconColors = 'bg-red-50 text-red-600';
}
return (
<motion.tr
key={user.id}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.03 }}
className="group hover:bg-slate-50/50 transition-all"
>
<td className="px-6 py-4">
<div className="flex items-center gap-3">
<div className={`w-10 h-10 rounded-xl flex items-center justify-center flex-shrink-0 ${iconColors}`}>
<IconComponent size={18} />
</div>
<div className="min-w-0">
<p className="font-bold text-slate-900 truncate">{user.username}</p>
</div>
</div>
</td>
<td className="px-6 py-4">
<p className="text-sm text-slate-600 truncate">{user.displayName || '-'}</p>
</td>
<td className="px-6 py-4">
{user.tenantMembers && user.tenantMembers.length > 0 ? (
<div className="flex flex-wrap gap-1">
{user.tenantMembers
.filter((m: any) => m.tenant?.name !== 'Default')
.map((m: any) => (
<span
key={m.tenantId}
className="inline-flex items-center gap-1 px-2 py-0.5 bg-emerald-50 text-emerald-700 text-xs font-black rounded-md uppercase tracking-wider border border-emerald-100"
>
<Building size={8} />
{m.tenant?.name || m.tenantId}
</span>
))}
</div>
) : (
<span className="text-xs text-slate-400 italic">{t('noOrganization')}</span>
)}
</td>
<td className="px-6 py-4">
{(() => {
const tm = user.tenantMembers?.filter((m: any) => m.tenant?.name !== 'Default')?.[0];
const role = user.isAdmin ? 'SUPER_ADMIN' : tm?.role || 'USER';
const colors: Record<string, string> = {
SUPER_ADMIN: 'bg-red-50 text-red-700 border-red-100',
TENANT_ADMIN: 'bg-indigo-50 text-indigo-700 border-indigo-100',
USER: 'bg-slate-50 text-slate-500 border-slate-100',
};
return (
<span className={`inline-flex items-center gap-1 px-2 py-0.5 text-xs font-black rounded-md uppercase tracking-wider border ${colors[role] || colors.USER}`}>
{role === 'SUPER_ADMIN' ? '超级管理员' : role === 'TENANT_ADMIN' ? '管理员' : '用户'}
</span>
);
})()}
</td>
<td className="px-6 py-4">
<p className="text-[11px] font-medium text-slate-600">
{new Date(user.createdAt).toLocaleDateString()}
</p>
</td>
<td className="px-6 py-4 text-right">
<div className="flex items-center justify-end gap-1 opacity-60 group-hover:opacity-100 transition-all">
{user.username !== 'admin' && (
<>
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
const tm = user.tenantMembers?.filter((m: any) => m.tenant?.name !== 'Default')?.[0];
setEditUserData({
userId: user.id,
username: user.username,
displayName: user.displayName || '',
memberId: tm?.id,
tenantId: tm?.tenantId,
role: user.isAdmin ? 'SUPER_ADMIN' : tm?.role || 'USER',
isAdmin: !!user.isAdmin,
});
}}
className="p-2 rounded-lg text-slate-400 hover:text-indigo-600 hover:bg-white shadow-sm transition-all"
title={t('edit')}
>
<Edit2 className="w-4 h-4" />
</button>
<button
onClick={() => setPasswordChangeUserData({ userId: user.id, newPassword: '' })}
className="p-2 rounded-lg text-slate-400 hover:text-indigo-600 hover:bg-white shadow-sm transition-all"
title={t('changeUserPassword')}
>
<Key className="w-4 h-4" />
</button>
{user.id !== currentUser?.id && (
<button
onClick={() => handleDeleteUser(user.id)}
className="p-2 rounded-lg text-slate-400 hover:text-red-500 hover:bg-white shadow-sm transition-all"
title={t('deleteUser')}
>
<Trash2 className="w-4 h-4" />
</button>
)}
</>
)}
</div>
</td>
</motion.tr>
);
})}
</AnimatePresence>
</tbody>
</table>
</div>
<Pagination
current={userPage}
total={userTotal}
pageSize={USER_PAGE_SIZE}
onChange={setUserPage}
/>
</div>
);
const renderTenantsTab = () => {
const tenantTree = buildTenantTree(tenants);
const activeTenant = selectedTenantId ? tenants.find(t => t.id === selectedTenantId) : null;
return (
<div className="flex flex-col lg:flex-row gap-6 h-[calc(100vh-12rem)] animate-in fade-in duration-500">
{/* Left: Organization Tree */}
<div className="w-full lg:w-80 flex flex-col bg-white border border-slate-200 rounded-3xl shadow-sm overflow-hidden min-h-[400px] lg:min-h-0">
<div className="p-6 border-b border-slate-100 flex items-center justify-between shrink-0">
<div>
<h3 className="font-black text-slate-900 text-lg tracking-tight">{t('orgManagement')}</h3>
<p className="text-xs font-bold text-slate-400 uppercase tracking-widest">{t('globalTenantControl')}</p>
</div>
<button
onClick={() => {
setNewTenant({ name: '', domain: '', parentId: null });
setEditingTenant(null);
setShowCreateTenant(true);
}}
className="p-2 bg-slate-900 text-white rounded-xl hover:bg-slate-700 transition-all"
>
<Plus size={18} />
</button>
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-1 custom-scrollbar">
{tenantTree.length > 0 ? (
tenantTree.map(t => (
<TenantTreeNode
key={t.id}
tenant={t}
selectedTenantId={selectedTenantId}
onSelect={(id) => {
if (id !== selectedTenantId) {
setSelectedTenantId(id);
setMemberPage(1);
setUserPage(1);
}
}}
onCreateSubtenant={(parentId) => {
setNewTenant({ name: '', domain: '', parentId });
setEditingTenant(null);
setShowCreateTenant(true);
}}
/>
))
) : (
<div className="py-20 text-center">
<Building size={32} className="mx-auto text-slate-200 mb-3" />
<p className="text-xs font-black text-slate-300 uppercase tracking-widest">{t('noOrganizations')}</p>
</div>
)}
</div>
<div className="p-4 bg-slate-50 border-t border-slate-100 shrink-0">
<div className="flex items-center gap-3 p-3 bg-white border border-slate-200 rounded-2xl shadow-sm">
<div className="w-10 h-10 rounded-xl bg-blue-50 flex items-center justify-center text-blue-600">
<Building size={20} />
</div>
<div>
<p className="text-xs font-black text-slate-400 uppercase tracking-widest">{t('totalTenants')}</p>
<p className="text-xl font-black text-slate-900">{stats.tenants}</p>
</div>
</div>
</div>
</div>
{/* Right: User List & Management */}
<div className="flex-1 flex flex-col bg-white border border-slate-200 rounded-3xl shadow-xl overflow-hidden min-h-[600px] lg:min-h-0">
{activeTenant ? (
<div className="flex flex-col h-full">
{/* Organization Header */}
<div className="p-8 border-b border-slate-100 bg-slate-50/50 flex items-center justify-between shrink-0">
<div className="flex items-center gap-4">
<div className="w-14 h-14 rounded-2xl bg-white border border-slate-200 shadow-sm flex items-center justify-center text-indigo-600">
<Building size={28} />
</div>
<div>
<div className="flex items-center gap-2">
<h2 className="text-2xl font-black text-slate-900 tracking-tight">{activeTenant.name}</h2>
<span className="px-2 py-0.5 bg-indigo-50 text-indigo-600 rounded text-[9px] font-black uppercase tracking-widest border border-indigo-100">{t('activeOrg')}</span>
</div>
<p className="text-xs font-medium text-slate-400">{activeTenant.domain || t('noCustomDomain')}</p>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => {
setEditingTenant(activeTenant);
setNewTenant({
name: activeTenant.name,
domain: activeTenant.domain || '',
parentId: activeTenant.parentId || null
});
setShowCreateTenant(true);
}}
className="p-2.5 bg-white border border-slate-200 text-slate-600 rounded-xl hover:border-indigo-500 hover:text-indigo-600 transition-all shadow-sm"
title={t('orgSettings')}
>
<SettingsIcon size={18} />
</button>
<button
onClick={() => handleRemoveTenant(activeTenant.id)}
className="p-2.5 bg-white border border-slate-200 text-slate-400 hover:text-red-500 hover:border-red-500 transition-all shadow-sm"
title={t('deleteOrg')}
>
<Trash2 size={18} />
</button>
</div>
</div>
{/* Main Content Split: Members vs All Users */}
<div className="flex-1 flex overflow-hidden">
{/* Current Members */}
<div className="flex-1 flex flex-col border-r border-slate-100 overflow-hidden">
<div className="p-6 border-b border-slate-50 flex items-center justify-between shrink-0">
<h4 className="text-xs font-black text-slate-400 uppercase tracking-widest">{t('orgMembers')}</h4>
<span className="text-xs font-black px-2 py-0.5 bg-slate-100 text-slate-500 rounded-full">
{t('membersCount').replace('$1', (memberTotal || 0).toString())}
</span>
</div>
<div className="flex-1 overflow-y-auto p-6 scrollbar-hide">
<div className="grid grid-cols-1 gap-3">
{tenantMembers?.map((m: any) => (
<div key={m.id} className="p-4 bg-slate-50/50 border border-slate-100 rounded-2xl flex items-center justify-between group hover:bg-white hover:shadow-sm transition-all hover:border-slate-200">
<div className="flex items-center gap-3 min-w-0">
<div className="w-10 h-10 rounded-xl bg-white border border-slate-200 flex items-center justify-center text-slate-400 shadow-sm">
<User size={18} />
</div>
<div className="min-w-0">
<p className="text-sm font-black text-slate-900 truncate">
{m.user?.displayName || m.user?.username || m.userId}
</p>
<select
value={m.role}
onChange={(e) => handleUpdateMemberRole(activeTenant.id, m.userId, e.target.value)}
className={`text-[9px] font-black uppercase tracking-widest bg-transparent border-none outline-none cursor-pointer hover:bg-slate-100 rounded px-1 transition-colors ${m.role === 'TENANT_ADMIN' ? 'text-indigo-500' : 'text-slate-400'}`}
>
<option value="USER">{t('roleRegularUser')}</option>
<option value="TENANT_ADMIN">{t('roleTenantAdmin')}</option>
</select>
</div>
</div>
<button
onClick={() => handleRemoveMember(activeTenant.id, m.userId)}
className="p-2 text-slate-300 hover:text-red-500 opacity-0 group-hover:opacity-100 transition-all"
>
<Trash2 size={14} />
</button>
</div>
))}
{(!tenantMembers || tenantMembers.length === 0) && (
<div className="py-20 text-center">
<Users size={24} className="mx-auto text-slate-200 mb-2" />
<p className="text-xs font-bold text-slate-300 uppercase tracking-wider">{t('noMembersAssigned')}</p>
</div>
)}
</div>
<Pagination
current={memberPage}
total={memberTotal}
pageSize={MEMBER_PAGE_SIZE}
onChange={setMemberPage}
/>
</div>
</div>
{/* Add New Users (Right side of specific tenant view) */}
<div className="w-72 flex flex-col bg-slate-50/30 overflow-hidden shrink-0">
<div className="p-6 border-b border-slate-100 shrink-0">
<h4 className="text-xs font-black text-slate-900 uppercase tracking-widest mb-3">{t('addMembers')}</h4>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={14} />
<input
className="w-full pl-9 pr-3 py-2 bg-white border border-slate-200 rounded-xl text-xs outline-none focus:ring-4 focus:ring-indigo-500/10 transition-all"
placeholder={t('searchSystemUsers')}
value={userSearchQuery}
onChange={e => setUserSearchQuery(e.target.value)}
/>
</div>
<div className="mt-3 flex gap-1 p-1 bg-white border border-slate-200 rounded-xl">
<button
onClick={() => setBindingRole('USER')}
className={`flex-1 py-1.5 text-xs font-black uppercase tracking-widest rounded-lg transition-all ${bindingRole === 'USER' ? 'bg-indigo-600 text-white shadow-sm' : 'text-slate-400 hover:text-slate-600'}`}
>
{t('roleRegularUser')}
</button>
<button
onClick={() => setBindingRole('TENANT_ADMIN')}
className={`flex-1 py-1.5 text-xs font-black uppercase tracking-widest rounded-lg transition-all ${bindingRole === 'TENANT_ADMIN' ? 'bg-indigo-600 text-white shadow-sm' : 'text-slate-400 hover:text-slate-600'}`}
>
{t('roleTenantAdmin')}
</button>
</div>
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-2">
{users
.filter(u =>
u.username !== 'admin' &&
u.username.toLowerCase().includes(userSearchQuery.toLowerCase())
)
.map(u => {
const isAlreadyMember = allMemberIds.has(u.id);
return (
<button
key={u.id}
onClick={() => !isAlreadyMember && handleAddMember(activeTenant.id, u.id, bindingRole)}
disabled={isAlreadyMember}
className={`w-full p-3 border rounded-xl flex items-center justify-between group transition-all ${isAlreadyMember
? 'bg-slate-50 border-slate-100 cursor-not-allowed opacity-60'
: 'bg-white border-slate-100 hover:border-indigo-500 hover:shadow-sm'
}`}
>
<div className="flex items-center gap-2 min-w-0">
<div className={`w-8 h-8 rounded-lg flex items-center justify-center transition-colors ${isAlreadyMember
? 'bg-slate-100 text-slate-300'
: 'bg-slate-50 text-slate-400 group-hover:bg-indigo-50 group-hover:text-indigo-600'
}`}>
<User size={14} />
</div>
<span className={`text-[13px] font-bold truncate ${isAlreadyMember ? 'text-slate-400' : 'text-slate-700'}`}>
{u.displayName || u.username}
</span>
</div>
{isAlreadyMember ? (
<Check size={14} className="text-emerald-500" />
) : (
<Plus size={14} className="text-slate-300 group-hover:text-indigo-600" />
)}
</button>
);
})
}
<Pagination
current={userPage}
total={userTotal}
pageSize={USER_PAGE_SIZE}
onChange={setUserPage}
/>
</div>
</div>
</div>
{/* Create Tenant Modal (Nested in Tab Content for scope) */}
{showCreateTenant && (
<div className="fixed inset-0 z-[120] bg-slate-900/40 backdrop-blur-sm flex items-center justify-center p-4 text-left">
<motion.div initial={{ scale: 0.95, opacity: 0 }} animate={{ scale: 1, opacity: 1 }} className="bg-white rounded-3xl p-8 w-full max-w-md shadow-2xl">
<h3 className="text-xl font-black text-slate-900 mb-6">{editingTenant ? t('editOrg') : t('newTenant')}</h3>
<form onSubmit={handleCreateTenant} className="space-y-5">
<div>
<label className="text-xs font-black text-slate-400 uppercase tracking-widest px-1">{t('tenantName')}</label>
<input className="w-full mt-1 px-4 py-3 bg-slate-50 border border-slate-200 rounded-2xl text-sm" placeholder={t('tenantName')} value={newTenant.name} onChange={e => setNewTenant({ ...newTenant, name: e.target.value })} required />
</div>
<div>
<label className="text-xs font-black text-slate-400 uppercase tracking-widest px-1">{t('domainOptional')}</label>
<input className="w-full mt-1 px-4 py-3 bg-slate-50 border border-slate-200 rounded-2xl text-sm" placeholder={t('domainOptional')} value={newTenant.domain} onChange={e => setNewTenant({ ...newTenant, domain: e.target.value })} />
</div>
<div>
<label className="text-xs font-black text-slate-400 uppercase tracking-widest px-1">{t('parentOrg')}</label>
{!editingTenant ? (
<div className="w-full mt-1 px-4 py-3 bg-slate-100 border border-slate-200 rounded-2xl text-sm text-slate-500 font-bold">
{newTenant.parentId ? tenants.find(t => t.id === newTenant.parentId)?.name : t('noneRoot')}
</div>
) : (
<select
className="w-full mt-1 px-4 py-3 bg-slate-50 border border-slate-200 rounded-2xl text-sm outline-none focus:ring-2 focus:ring-indigo-500/20"
value={newTenant.parentId || ''}
onChange={e => setNewTenant({ ...newTenant, parentId: e.target.value || null })}
>
<option value="">{t('noneRoot')}</option>
{tenants.filter(t => t.id !== editingTenant?.id).map(t => (
<option key={t.id} value={t.id}>{t.name}</option>
))}
</select>
)}
</div>
<div className="flex gap-4 pt-2">
<button type="button" onClick={() => { setShowCreateTenant(false); setEditingTenant(null); }} className="flex-1 py-3 text-slate-500 font-bold text-sm">{t('cancel')}</button>
<button type="submit" className="flex-1 py-3 bg-slate-900 text-white rounded-2xl font-black uppercase tracking-widest text-xs hover:bg-indigo-600">{editingTenant ? t('update') : t('create')}</button>
</div>
</form>
</motion.div>
</div>
)}
</div>
) : (
<div className="flex-1 flex flex-col items-center justify-center p-12 text-center bg-slate-50/30">
<div className="w-24 h-24 rounded-[2rem] bg-indigo-50 flex items-center justify-center text-indigo-400 mb-6 shadow-xl shadow-indigo-100/50">
<Building size={48} />
</div>
<h2 className="text-2xl font-black text-slate-900 tracking-tight mb-2">{t('selectOrg')}</h2>
<p className="text-sm text-slate-400 max-w-xs font-medium leading-relaxed">{t('selectOrgDesc')}</p>
<div className="mt-12 grid grid-cols-2 gap-4 w-full max-w-lg">
<div className="p-6 bg-white border border-slate-200 rounded-3xl text-left shadow-sm">
<div className="w-10 h-10 rounded-xl bg-orange-50 flex items-center justify-center text-orange-600 mb-4">
<Users size={20} />
</div>
<p className="text-xl font-black text-slate-900">{stats.users}</p>
<p className="text-xs font-bold text-slate-400 uppercase tracking-widest mt-1">{t('totalSystemUsers')}</p>
</div>
<div className="p-6 bg-white border border-slate-200 rounded-3xl text-left shadow-sm">
<div className="w-10 h-10 rounded-xl bg-emerald-50 flex items-center justify-center text-emerald-600 mb-4">
<Shield size={20} />
</div>
<p className="text-xl font-black text-slate-900">{tenants.filter(t => t.parentId === null).length}</p>
<p className="text-xs font-bold text-slate-400 uppercase tracking-widest mt-1">{t('rootOrgs')}</p>
</div>
</div>
{/* Scope Modal for Create even when no selection */}
{showCreateTenant && (
<div className="fixed inset-0 z-[120] bg-slate-900/40 backdrop-blur-sm flex items-center justify-center p-4 text-left">
<motion.div initial={{ scale: 0.95, opacity: 0 }} animate={{ scale: 1, opacity: 1 }} className="bg-white rounded-3xl p-8 w-full max-w-md shadow-2xl">
<h3 className="text-xl font-black text-slate-900 mb-6">{t('newTenant')}</h3>
<form onSubmit={handleCreateTenant} className="space-y-5">
<div>
<label className="text-xs font-black text-slate-400 uppercase tracking-widest px-1">{t('tenantName')}</label>
<input className="w-full mt-1 px-4 py-3 bg-slate-50 border border-slate-200 rounded-2xl text-sm" placeholder={t('tenantName')} value={newTenant.name} onChange={e => setNewTenant({ ...newTenant, name: e.target.value })} required />
</div>
<div>
<label className="text-xs font-black text-slate-400 uppercase tracking-widest px-1">{t('domainOptional')}</label>
<input className="w-full mt-1 px-4 py-3 bg-slate-50 border border-slate-200 rounded-2xl text-sm" placeholder={t('domainOptional')} value={newTenant.domain} onChange={e => setNewTenant({ ...newTenant, domain: e.target.value })} />
</div>
<div>
<label className="text-xs font-black text-slate-400 uppercase tracking-widest px-1">{t('parentOrg')}</label>
<select
className="w-full mt-1 px-4 py-3 bg-slate-50 border border-slate-200 rounded-2xl text-sm outline-none focus:ring-2 focus:ring-indigo-500/20"
value={newTenant.parentId || ''}
onChange={e => setNewTenant({ ...newTenant, parentId: e.target.value || null })}
>
<option value="">{t('noneRoot')}</option>
{tenants.map(t => (
<option key={t.id} value={t.id}>{t.name}</option>
))}
</select>
</div>
<div className="flex gap-4 pt-2">
<button
type="button"
onClick={() => {
setShowCreateTenant(false);
setNewTenant({ name: '', domain: '', parentId: null });
}}
className="flex-1 py-3 text-slate-500 font-bold text-sm"
>
{t('cancel')}
</button>
<button type="submit" className="flex-1 py-3 bg-slate-900 text-white rounded-2xl font-black uppercase tracking-widest text-xs hover:bg-indigo-600">{t('create')}</button>
</div>
</form>
</motion.div>
</div>
)}
</div>
)}
</div>
</div>
);
};
const renderKnowledgeBaseTab = () => (
<div className="space-y-8 animate-in slide-in-from-right duration-300 w-full max-w-5xl pb-10">
{localKbSettings ? (
<>
{/* Save/Cancel Bar */}
<div className="flex justify-end gap-3 sticky top-0 z-20 py-4 bg-white/50 backdrop-blur-sm border-b border-slate-100 mb-6">
<button
onClick={handleCancelKbSettings}
disabled={isSavingKbSettings || JSON.stringify(localKbSettings) === JSON.stringify(kbSettings)}
className="px-6 py-2 text-sm font-bold text-slate-500 hover:text-slate-700 disabled:opacity-30"
>
{t('cancel')}
</button>
<button
onClick={handleSaveKbSettings}
disabled={isSavingKbSettings || JSON.stringify(localKbSettings) === JSON.stringify(kbSettings)}
className="flex items-center gap-2 px-8 py-2 bg-indigo-600 text-white text-sm font-black uppercase tracking-widest rounded-2xl hover:bg-indigo-700 shadow-lg shadow-indigo-100 transition-all active:scale-95 disabled:opacity-50"
>
{isSavingKbSettings ? <Loader2 size={16} className="animate-spin" /> : <Save size={16} />}
{t('saveChanges')}
</button>
</div>
{/* Model Configuration */}
<section className="bg-white/80 backdrop-blur-md p-8 rounded-3xl border border-slate-200/50 shadow-sm space-y-6">
<div className="flex items-center gap-3 text-slate-900 font-black uppercase tracking-widest text-[11px] border-b border-slate-100 pb-4">
<div className="w-8 h-8 rounded-xl bg-indigo-50 flex items-center justify-center text-indigo-600">
<Cpu size={16} />
</div>
{t('modelConfiguration')}
</div>
<div className="grid grid-cols-1 gap-6">
<div>
<label className="block text-xs font-black text-slate-400 uppercase tracking-widest mb-2 px-1">{t('defaultLLMModel')}</label>
<select
value={localKbSettings.selectedLLMId || ''}
onChange={(e) => handleUpdateKbSettings('selectedLLMId', e.target.value)}
className="w-full px-4 py-3.5 bg-slate-50 border border-slate-200 rounded-2xl text-sm font-medium outline-none focus:ring-4 focus:ring-indigo-500/10 transition-all cursor-pointer appearance-none"
>
<option value="">--- {t('selectLLMModel')} ---</option>
{models.filter(m => m.type === ModelType.LLM).map(m => (
<option key={m.id} value={m.id}>{m.name} ({m.modelId})</option>
))}
</select>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className="block text-xs font-black text-slate-400 uppercase tracking-widest mb-2 px-1">{t('embeddingModel')}</label>
<select
value={localKbSettings.selectedEmbeddingId || ''}
onChange={(e) => handleUpdateKbSettings('selectedEmbeddingId', e.target.value)}
className="w-full px-4 py-3.5 bg-slate-50 border border-slate-200 rounded-2xl text-sm font-medium outline-none focus:ring-4 focus:ring-indigo-500/10 transition-all"
>
<option value="">--- {t('selectEmbeddingModel')} ---</option>
{models.filter(m => m.type === ModelType.EMBEDDING).map(m => (
<option key={m.id} value={m.id}>{m.name}</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-black text-slate-400 uppercase tracking-widest mb-2 px-1">{t('rerankModel')}</label>
<select
value={localKbSettings.selectedRerankId || ''}
onChange={(e) => handleUpdateKbSettings('selectedRerankId', e.target.value)}
className="w-full px-4 py-3.5 bg-slate-50 border border-slate-200 rounded-2xl text-sm font-medium outline-none focus:ring-4 focus:ring-indigo-500/10 transition-all"
>
<option value="">--- {t('selectRerankModel')} ---</option>
{models.filter(m => m.type === ModelType.RERANK).map(m => (
<option key={m.id} value={m.id}>{m.name}</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-black text-slate-400 uppercase tracking-widest mb-2 px-1">
{t('defaultVisionModel')}
<span className="ml-1 text-[8px] opacity-60">({t('typeVision')})</span>
</label>
<select
value={localKbSettings.selectedVisionId || ''}
onChange={(e) => handleUpdateKbSettings('selectedVisionId', e.target.value)}
className="w-full px-4 py-3.5 bg-slate-50 border border-slate-200 rounded-2xl text-sm font-medium outline-none focus:ring-4 focus:ring-indigo-500/10 transition-all"
>
<option value="">--- {t('selectVisionModel')} ---</option>
{models.filter(m => m.type === ModelType.VISION || m.supportsVision).map(m => (
<option key={m.id} value={m.id}>{m.name}</option>
))}
</select>
</div>
</div>
</div>
</section>
{/* Indexing & Chunking Configuration */}
<section className="bg-white/80 backdrop-blur-md p-8 rounded-3xl border border-slate-200/50 shadow-sm space-y-6">
<div className="flex items-center gap-3 text-slate-900 font-black uppercase tracking-widest text-[11px] border-b border-slate-100 pb-4">
<div className="w-8 h-8 rounded-xl bg-orange-50 flex items-center justify-center text-orange-600">
<BookOpen size={16} />
</div>
{t('indexingChunkingConfig')}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<div>
<div className="flex justify-between mb-3 px-1">
<label className="text-xs font-black text-slate-400 uppercase tracking-widest">{t('chunkSize')}</label>
<span className="text-sm font-black text-indigo-600 bg-indigo-50 px-2 py-0.5 rounded-lg">{localKbSettings.chunkSize || 1000}</span>
</div>
<input
type="range"
min="100"
max="8192"
step="100"
value={localKbSettings.chunkSize || 1000}
onChange={(e) => handleUpdateKbSettings('chunkSize', parseInt(e.target.value))}
className="w-full h-2 bg-slate-100 rounded-lg appearance-none cursor-pointer accent-indigo-600"
/>
</div>
<div>
<div className="flex justify-between mb-3 px-1">
<label className="text-xs font-black text-slate-400 uppercase tracking-widest">{t('chunkOverlap')}</label>
<span className="text-sm font-black text-indigo-600 bg-indigo-50 px-2 py-0.5 rounded-lg">{localKbSettings.chunkOverlap || 100}</span>
</div>
<input
type="range"
min="0"
max="2048"
step="10"
value={localKbSettings.chunkOverlap || 100}
onChange={(e) => handleUpdateKbSettings('chunkOverlap', parseInt(e.target.value))}
className="w-full h-2 bg-slate-100 rounded-lg appearance-none cursor-pointer accent-indigo-600"
/>
</div>
</div>
</section>
{/* Chat Hyperparameters */}
<section className="bg-white/80 backdrop-blur-md p-8 rounded-3xl border border-slate-200/50 shadow-sm space-y-6">
<div className="flex items-center gap-3 text-slate-900 font-black uppercase tracking-widest text-[11px] border-b border-slate-100 pb-4">
<div className="w-8 h-8 rounded-xl bg-pink-50 flex items-center justify-center text-pink-600">
<Sparkles size={16} />
</div>
{t('chatHyperparameters')}
</div>
<div className="space-y-8">
<div>
<div className="flex justify-between mb-3 px-1">
<label className="text-xs font-black text-slate-400 uppercase tracking-widest">{t('temperature')}</label>
<span className="text-sm font-black text-indigo-600 bg-indigo-50 px-2 py-0.5 rounded-lg">{localKbSettings.temperature}</span>
</div>
<input
type="range"
min="0"
max="1"
step="0.1"
value={localKbSettings.temperature || 0.7}
onChange={(e) => handleUpdateKbSettings('temperature', parseFloat(e.target.value))}
className="w-full h-2 bg-slate-100 rounded-lg appearance-none cursor-pointer accent-indigo-600"
/>
<div className="flex justify-between mt-2 px-1 text-[9px] font-bold text-slate-300 uppercase tracking-tighter">
<span>{t('precise')}</span>
<span>{t('creative')}</span>
</div>
</div>
<div>
<label className="block text-xs font-black text-slate-400 uppercase tracking-widest mb-2 px-1">{t('maxResponseTokens')}</label>
<input
type="number"
value={localKbSettings.maxTokens || 2000}
onChange={(e) => handleUpdateKbSettings('maxTokens', parseInt(e.target.value))}
className="w-full px-4 py-3.5 bg-slate-50 border border-slate-200 rounded-2xl text-sm font-medium outline-none focus:ring-4 focus:ring-indigo-500/10 transition-all"
/>
</div>
</div>
</section>
{/* Retrieval & Search Settings */}
<section className="bg-white/80 backdrop-blur-md p-8 rounded-3xl border border-slate-200/50 shadow-sm space-y-6">
<div className="flex items-center gap-3 text-slate-900 font-black uppercase tracking-widest text-[11px] border-b border-slate-100 pb-4">
<div className="w-8 h-8 rounded-xl bg-emerald-50 flex items-center justify-center text-emerald-600">
<Database size={16} />
</div>
{t('retrievalSearchSettings')}
</div>
<div className="space-y-8">
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<div>
<div className="flex justify-between mb-3 px-1">
<label className="text-xs font-black text-slate-400 uppercase tracking-widest">{t('topK')}</label>
<span className="text-sm font-black text-indigo-600 bg-indigo-50 px-2 py-0.5 rounded-lg">{localKbSettings.topK}</span>
</div>
<input
type="range"
min="1"
max="50"
step="1"
value={localKbSettings.topK || 10}
onChange={(e) => handleUpdateKbSettings('topK', parseInt(e.target.value))}
className="w-full h-2 bg-slate-100 rounded-lg appearance-none cursor-pointer accent-indigo-600"
/>
</div>
<div>
<div className="flex justify-between mb-3 px-1">
<label className="text-xs font-black text-slate-400 uppercase tracking-widest">{t('similarityThreshold')}</label>
<span className="text-sm font-black text-indigo-600 bg-indigo-50 px-2 py-0.5 rounded-lg">{localKbSettings.similarityThreshold}</span>
</div>
<input
type="range"
min="0"
max="1"
step="0.05"
value={localKbSettings.similarityThreshold || 0.5}
onChange={(e) => handleUpdateKbSettings('similarityThreshold', parseFloat(e.target.value))}
className="w-full h-2 bg-slate-100 rounded-lg appearance-none cursor-pointer accent-indigo-600"
/>
</div>
</div>
<div className="space-y-4 pt-4 border-t border-slate-100">
<div className="flex items-center justify-between p-5 bg-slate-50/50 rounded-2xl border border-slate-200/30 transition-all hover:bg-white hover:border-indigo-100">
<div>
<div className="text-sm font-bold text-slate-800">{t('enableHybridSearch')}</div>
<div className="text-xs text-slate-400 font-medium">{t('hybridSearchDesc')}</div>
</div>
<button
onClick={() => handleUpdateKbSettings('enableFullTextSearch', !localKbSettings.enableFullTextSearch)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-all duration-300 ${localKbSettings.enableFullTextSearch ? 'bg-indigo-600 shadow-md shadow-indigo-100' : 'bg-slate-300'}`}
>
<span className={`${localKbSettings.enableFullTextSearch ? 'translate-x-6' : 'translate-x-1'} inline-block h-4 w-4 transform rounded-full bg-white transition-transform`} />
</button>
</div>
{localKbSettings.enableFullTextSearch && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="p-5 bg-indigo-50/30 rounded-2xl border border-indigo-100/50 space-y-4"
>
<div className="flex justify-between mb-2 px-1">
<label className="text-xs font-black text-indigo-400 uppercase tracking-widest">{t('hybridWeight')}</label>
<span className="text-sm font-black text-indigo-600">{localKbSettings.hybridVectorWeight || 0.5}</span>
</div>
<input
type="range"
min="0"
max="1"
step="0.05"
value={localKbSettings.hybridVectorWeight || 0.5}
onChange={(e) => handleUpdateKbSettings('hybridVectorWeight', parseFloat(e.target.value))}
className="w-full h-2 bg-indigo-100 rounded-lg appearance-none cursor-pointer accent-indigo-600"
/>
<div className="flex justify-between mt-1 px-1 text-[9px] font-bold text-indigo-300 uppercase">
<span>{t('pureText')}</span>
<span>{t('pureVector')}</span>
</div>
</motion.div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="flex items-center justify-between p-5 bg-slate-50/50 rounded-2xl border border-slate-200/30 transition-all hover:bg-white hover:border-indigo-100">
<div>
<div className="text-sm font-bold text-slate-800">{t('enableQueryExpansion')}</div>
<div className="text-xs text-slate-400 font-medium">{t('queryExpansionDesc')}</div>
</div>
<button
onClick={() => handleUpdateKbSettings('enableQueryExpansion', !localKbSettings.enableQueryExpansion)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-all duration-300 ${localKbSettings.enableQueryExpansion ? 'bg-indigo-600 shadow-md shadow-indigo-100' : 'bg-slate-300'}`}
>
<span className={`${localKbSettings.enableQueryExpansion ? 'translate-x-6' : 'translate-x-1'} inline-block h-4 w-4 transform rounded-full bg-white transition-transform`} />
</button>
</div>
<div className="flex items-center justify-between p-5 bg-slate-50/50 rounded-2xl border border-slate-200/30 transition-all hover:bg-white hover:border-indigo-100">
<div>
<div className="text-sm font-bold text-slate-800">{t('enableHyDE')}</div>
<div className="text-xs text-slate-400 font-medium">{t('hydeDesc')}</div>
</div>
<button
onClick={() => handleUpdateKbSettings('enableHyDE', !localKbSettings.enableHyDE)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-all duration-300 ${localKbSettings.enableHyDE ? 'bg-indigo-600 shadow-md shadow-indigo-100' : 'bg-slate-300'}`}
>
<span className={`${localKbSettings.enableHyDE ? 'translate-x-6' : 'translate-x-1'} inline-block h-4 w-4 transform rounded-full bg-white transition-transform`} />
</button>
</div>
</div>
<div className="flex items-center justify-between p-5 bg-slate-50/50 rounded-2xl border border-slate-200/30 transition-all hover:bg-white hover:border-indigo-100">
<div>
<div className="text-sm font-bold text-slate-800">{t('enableReranking')}</div>
<div className="text-xs text-slate-400 font-medium">{t('rerankingDesc')}</div>
</div>
<button
onClick={() => handleUpdateKbSettings('enableRerank', !localKbSettings.enableRerank)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-all duration-300 ${localKbSettings.enableRerank ? 'bg-indigo-600 shadow-md shadow-indigo-100' : 'bg-slate-300'}`}
>
<span className={`${localKbSettings.enableRerank ? 'translate-x-6' : 'translate-x-1'} inline-block h-4 w-4 transform rounded-full bg-white transition-transform`} />
</button>
</div>
{localKbSettings.enableRerank && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="p-5 bg-indigo-50/30 rounded-2xl border border-indigo-100/50 space-y-4"
>
<div className="flex justify-between mb-2 px-1">
<label className="text-xs font-black text-indigo-400 uppercase tracking-widest">{t('rerankSimilarityThreshold')}</label>
<span className="text-sm font-black text-indigo-600">{localKbSettings.rerankSimilarityThreshold || 0.5}</span>
</div>
<input
type="range"
min="0"
max="1"
step="0.05"
value={localKbSettings.rerankSimilarityThreshold || 0.5}
onChange={(e) => handleUpdateKbSettings('rerankSimilarityThreshold', parseFloat(e.target.value))}
className="w-full h-2 bg-indigo-100 rounded-lg appearance-none cursor-pointer accent-indigo-600"
/>
<div className="flex justify-between mt-1 px-1 text-[9px] font-bold text-indigo-300 uppercase">
<span>{t('broad')}</span>
<span>{t('strict')}</span>
</div>
</motion.div>
)}
</div>
</div>
</section>
</>
) : (
<div className="flex flex-col items-center justify-center py-20 space-y-4">
<Loader2 size={40} className="animate-spin text-indigo-600 opacity-20" />
<p className="text-sm font-medium text-slate-400 animate-pulse">{t('loading')}</p>
</div>
)}
</div>
);
const renderModelTab = () => (
<div className="w-full space-y-6">
<div className="flex justify-between items-center mb-6">
<div>
</div>
{!editingId && currentUser?.role === 'SUPER_ADMIN' && (
<button
onClick={() => { setEditingId('new'); setModelFormData({ type: ModelType.LLM, baseUrl: 'http://localhost:11434/v1', modelId: 'llama3', name: '', dimensions: 1536 }); }}
className="flex items-center gap-2 px-6 py-2.5 bg-indigo-600 text-white text-sm font-bold rounded-2xl hover:bg-indigo-700 shadow-lg shadow-indigo-100 transition-all active:scale-95"
>
<Plus className="w-4 h-4" />
{t('mmAddBtn')}
</button>
)}
</div>
{editingId ? (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="bg-white/90 backdrop-blur-md p-10 rounded-3xl border border-slate-200/50 shadow-xl space-y-8"
>
<div className="flex items-center gap-4 mb-2">
<div className="w-12 h-12 rounded-2xl bg-indigo-50 flex items-center justify-center text-indigo-600">
<Cpu className="w-6 h-6" />
</div>
<h3 className="text-xl font-black text-slate-900 tracking-tight">{editingId === 'new' ? t('mmAddBtn') : t('mmEdit')}</h3>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<label className="text-xs font-black text-slate-400 uppercase tracking-widest px-1">{t('mmFormName')} *</label>
<input className="w-full px-4 py-3.5 bg-slate-50 border border-slate-200 rounded-2xl text-sm font-medium focus:ring-4 focus:ring-indigo-500/10 focus:border-indigo-500/50 outline-none transition-all" value={modelFormData.name || ''} onChange={e => setModelFormData({ ...modelFormData, name: e.target.value })} disabled={isLoading} />
</div>
<div className="space-y-2">
<label className="text-xs font-black text-slate-400 uppercase tracking-widest px-1">{t('mmFormModelId')} *</label>
<input className="w-full px-4 py-3.5 bg-slate-50 border border-slate-200 rounded-2xl text-sm font-mono focus:ring-4 focus:ring-indigo-500/10 focus:border-indigo-500/50 outline-none transition-all" value={modelFormData.modelId || ''} onChange={e => setModelFormData({ ...modelFormData, modelId: e.target.value })} disabled={isLoading} />
</div>
</div>
<div className="space-y-2">
<label className="text-xs font-black text-slate-400 uppercase tracking-widest px-1">{t('mmFormType')} *</label>
<select className="w-full px-4 py-3.5 bg-slate-50 border border-slate-200 rounded-2xl text-sm font-medium focus:ring-4 focus:ring-indigo-500/10 focus:border-indigo-500/50 outline-none transition-all appearance-none" value={modelFormData.type} onChange={e => setModelFormData({ ...modelFormData, type: e.target.value as ModelType })} disabled={isLoading}>
<option value={ModelType.LLM}>{t('typeLLM')}</option>
<option value={ModelType.EMBEDDING}>{t('typeEmbedding')}</option>
<option value={ModelType.RERANK}>{t('typeRerank')}</option>
<option value={ModelType.VISION}>{t('typeVision')}</option>
</select>
</div>
<div className="space-y-2">
<label className="text-xs font-black text-slate-400 uppercase tracking-widest px-1">{t('mmFormBaseUrl')} *</label>
<input className="w-full px-4 py-3.5 bg-slate-50 border border-slate-200 rounded-2xl text-sm font-mono focus:ring-4 focus:ring-indigo-500/10 focus:border-indigo-500/50 outline-none transition-all" value={modelFormData.baseUrl || ''} onChange={e => setModelFormData({ ...modelFormData, baseUrl: e.target.value })} disabled={isLoading} />
</div>
<div className="space-y-2">
<label className="text-xs font-black text-slate-400 uppercase tracking-widest px-1">{t('mmFormApiKey')}</label>
<input
type="password"
className="w-full px-4 py-3.5 bg-slate-50 border border-slate-200 rounded-2xl text-sm font-mono focus:ring-4 focus:ring-indigo-500/10 focus:border-indigo-500/50 outline-none transition-all"
value={modelFormData.apiKey || ''}
onChange={e => setModelFormData({ ...modelFormData, apiKey: e.target.value })}
disabled={isLoading}
placeholder={t('mmFormApiKeyPlaceholder')}
/>
</div>
{modelFormData.type === ModelType.EMBEDDING && (
<div className="grid grid-cols-2 gap-6 p-6 bg-slate-50 rounded-3xl border border-slate-200/50">
<div className="space-y-2">
<label className="text-xs font-black text-slate-400 uppercase tracking-widest px-1">{t('maxInput')}</label>
<input type="number" className="w-full px-4 py-3 bg-white border border-slate-200 rounded-xl text-sm font-bold" value={modelFormData.maxInputTokens || 8191} onChange={e => setModelFormData({ ...modelFormData, maxInputTokens: parseInt(e.target.value) })} />
</div>
<div className="space-y-2">
<label className="text-xs font-black text-slate-400 uppercase tracking-widest px-1">{t('dimensions')}</label>
<input type="number" className="w-full px-4 py-3 bg-white border border-slate-200 rounded-xl text-sm font-bold" value={modelFormData.dimensions || 1536} onChange={e => setModelFormData({ ...modelFormData, dimensions: parseInt(e.target.value) })} />
</div>
</div>
)}
<div className="flex justify-end gap-3 pt-4">
<button onClick={() => { setEditingId(null); setError(null); }} className="px-6 py-3 text-sm font-bold text-slate-500 hover:text-slate-700">{t('mmCancel')}</button>
<button onClick={handleSaveModel} className="px-10 py-3 bg-indigo-600 text-white rounded-2xl font-black uppercase tracking-widest text-xs shadow-xl shadow-indigo-100 transition-all active:scale-95 flex items-center gap-2" disabled={isLoading}>
{isLoading && <Loader2 className="w-4 h-4 animate-spin" />}
{t('mmSave')}
</button>
</div>
</motion.div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-2 xl:grid-cols-3 gap-5 overflow-y-auto pr-2 pb-6 scrollbar-hide">
<AnimatePresence>
{models.map((model, index) => (
<motion.div
key={model.id}
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: index * 0.05 }}
className="bg-white/70 backdrop-blur-md border border-slate-200/50 rounded-[2rem] p-6 flex flex-col justify-between group hover:shadow-xl hover:shadow-indigo-500/5 hover:border-indigo-500/30 transition-all duration-300 relative overflow-hidden"
>
{/* Subtle background pattern/glow */}
<div className="absolute -top-12 -right-12 w-32 h-32 bg-indigo-500/5 rounded-full blur-3xl group-hover:bg-indigo-500/10 transition-all duration-500" />
<div className="relative z-10">
<div className="flex items-start justify-between mb-5">
<div className={`w-14 h-14 rounded-2xl flex items-center justify-center transition-all duration-500 ${model.isEnabled !== false ? 'bg-indigo-600 text-white shadow-lg shadow-indigo-100 rotate-0 group-hover:rotate-6' : 'bg-slate-100 text-slate-400 opacity-60'}`}>
<Cpu size={26} strokeWidth={2.5} />
</div>
<div className="flex gap-1 items-center bg-slate-50 p-1 rounded-xl border border-slate-100/50">
<button
onClick={() => handleToggleModel(model)}
className={`p-1.5 rounded-lg transition-all ${((currentUser?.role === 'SUPER_ADMIN' ? !!model.isEnabled : enabledModelIds.includes(model.id))) ? 'text-indigo-600 bg-white shadow-sm' : 'text-slate-400 hover:text-slate-600'}`}
title={((currentUser?.role === 'SUPER_ADMIN' ? !!model.isEnabled : enabledModelIds.includes(model.id))) ? t('modelEnabled') : t('modelDisabled')}
>
{((currentUser?.role === 'SUPER_ADMIN' ? !!model.isEnabled : enabledModelIds.includes(model.id))) ? <ToggleRight size={24} /> : <ToggleLeft size={24} />}
</button>
</div>
</div>
<div className="space-y-1 mb-6">
<div className="flex items-center gap-2.5">
<h4 className="font-black text-slate-900 text-lg tracking-tight truncate">{model.name}</h4>
</div>
<div className="flex items-center gap-2">
<span className="text-[9px] font-black bg-indigo-50 text-indigo-600 px-2 py-0.5 rounded-lg uppercase tracking-wider border border-indigo-100/50">
{getTypeLabel(model.type)}
</span>
{model.isDefault && (
<span className="text-[9px] font-black bg-amber-50 text-amber-600 px-2 py-0.5 rounded-lg uppercase tracking-wider border border-amber-100/50 flex items-center gap-1">
<Sparkles size={8} /> {t('defaultBadge')}
</span>
)}
</div>
<p className="text-[11px] font-mono text-slate-400 mt-2 truncate bg-slate-50 px-2 py-1 rounded-lg border border-slate-100/50 inline-block max-w-full">
{model.modelId}
</p>
</div>
{/* Additional info grid */}
<div className="grid grid-cols-2 gap-3 mb-6">
{model.type === ModelType.EMBEDDING && (
<>
<div className="bg-slate-50/50 p-2.5 rounded-2xl border border-slate-100/50">
<span className="block text-[8px] font-black text-slate-400 uppercase tracking-widest mb-0.5">{t('dims')}</span>
<span className="text-xs font-bold text-slate-700">{model.dimensions || '-'}</span>
</div>
<div className="bg-slate-50/50 p-2.5 rounded-2xl border border-slate-100/50">
<span className="block text-[8px] font-black text-slate-400 uppercase tracking-widest mb-0.5">{t('ctx')}</span>
<span className="text-xs font-bold text-slate-700">{model.maxInputTokens || '-'}</span>
</div>
</>
)}
{model.type === ModelType.LLM && (
<div className="col-span-2 bg-slate-50/50 p-2.5 rounded-2xl border border-slate-100/50 flex items-center justify-between">
<span className="text-[8px] font-black text-slate-400 uppercase tracking-widest">{t('baseApi')}</span>
<span className="text-[9px] font-mono font-medium text-slate-600 max-w-[140px] truncate">{model.baseUrl}</span>
</div>
)}
</div>
</div>
<div className="flex items-center justify-between pt-4 border-t border-slate-100/50 relative z-10">
<div className="flex items-center gap-1 text-xs font-bold text-slate-400">
<SettingsIcon size={12} />
{t('configured')}
</div>
<div className="flex gap-2">
{currentUser?.role === 'SUPER_ADMIN' && (
<>
<button
onClick={() => { setEditingId(model.id); setModelFormData({ ...model }); }}
className="w-9 h-9 flex items-center justify-center rounded-xl bg-slate-50 text-slate-400 hover:text-indigo-600 hover:bg-indigo-50 hover:shadow-sm transition-all"
>
<Edit2 size={15} />
</button>
<button
onClick={() => handleDeleteModel(model.id)}
className="w-9 h-9 flex items-center justify-center rounded-xl bg-slate-50 text-slate-400 hover:text-red-500 hover:bg-red-50 hover:shadow-sm transition-all"
>
<Trash2 size={15} />
</button>
</>
)}
</div>
</div>
</motion.div>
))}
</AnimatePresence>
{models.length === 0 && (
<div className="bg-white/50 border-2 border-dashed border-slate-200 rounded-3xl p-16 text-center">
<Cpu className="w-12 h-12 text-slate-200 mx-auto mb-4" />
<p className="text-slate-400 font-bold uppercase tracking-widest text-xs">{t('mmEmpty')}</p>
</div>
)}
</div>
)}
</div>
);
return (
<div className="flex h-full bg-[#FCFDFF] overflow-hidden relative">
{/* Settings Sidebar */}
<div className="w-64 bg-slate-50/50 border-r border-slate-200/60 flex flex-col shrink-0 z-20">
<div className="p-6 pb-2">
<h2 className="text-lg font-bold text-slate-900 tracking-tight">{t('tabSettings')}</h2>
</div>
<div className="flex-1 overflow-y-auto p-3 space-y-1">
<button
onClick={() => setActiveTab('general')}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-all ${activeTab === 'general' ? 'bg-white text-indigo-600 shadow-sm border border-slate-200/60' : 'text-slate-600 hover:bg-slate-100'
}`}
>
<SettingsIcon size={18} />
{t('generalSettings')}
</button>
{currentUser?.role === 'SUPER_ADMIN' && (
<button
onClick={() => setActiveTab('user')}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-all ${activeTab === 'user' ? 'bg-white text-indigo-600 shadow-sm border border-slate-200/60' : 'text-slate-600 hover:bg-slate-100'
}`}
>
<UserCircle size={18} />
{t('userManagement')}
</button>
)}
{isAdmin && (
<>
<button
onClick={() => setActiveTab('model')}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-all ${activeTab === 'model' ? 'bg-white text-indigo-600 shadow-sm border border-slate-200/60' : 'text-slate-600 hover:bg-slate-100'
}`}
>
<HardDrive size={18} />
{t('modelManagement')}
</button>
<button
onClick={() => setActiveTab('knowledge_base')}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-all ${activeTab === 'knowledge_base' ? 'bg-white text-indigo-600 shadow-sm border border-slate-200/60' : 'text-slate-600 hover:bg-slate-100'
}`}
>
<Database size={18} />
{t('sidebarTitle')}
</button>
</>
)}
{currentUser?.role === 'SUPER_ADMIN' && (
<button
onClick={() => setActiveTab('tenants')}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-all ${activeTab === 'tenants' ? 'bg-white text-indigo-600 shadow-sm border border-slate-200/60' : 'text-slate-600 hover:bg-slate-100'
}`}
>
<LayoutGrid size={18} />
{t('navTenants')}
</button>
)}
{isAdmin && (
<button
onClick={() => setActiveTab('assessment_templates')}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-all ${activeTab === 'assessment_templates' ? 'bg-white text-indigo-600 shadow-sm border border-slate-200/60' : 'text-slate-600 hover:bg-slate-100'
}`}
>
<FileText size={18} />
{t('assessmentTemplates')}
</button>
)}
{isAdmin && (
<button
onClick={() => setActiveTab('permissions')}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-all ${activeTab === 'permissions' ? 'bg-white text-indigo-600 shadow-sm border border-slate-200/60' : 'text-slate-600 hover:bg-slate-100'
}`}
>
<Shield size={18} />
</button>
)}
</div>
</div>
{/* Content Area */}
<div className="flex-1 flex flex-col min-w-0 h-full overflow-hidden relative bg-white z-10">
<div className="px-8 pt-8 pb-6 flex items-start justify-between shrink-0">
<div>
<h1 className="text-2xl font-bold text-slate-900 leading-tight">
{activeTab === 'general' ? t('generalSettings') : activeTab === 'user' ? t('userManagement') : activeTab === 'model' ? t('modelManagement') : activeTab === 'knowledge_base' ? t('sidebarTitle') : activeTab === 'tenants' ? t('navTenants') : activeTab === 'permissions' ? '权限管理' : t('assessmentTemplates')}
</h1>
<p className="text-[15px] text-slate-500 mt-1">
{activeTab === 'general' ? t('generalSettingsSubtitle') : activeTab === 'user' ? t('userManagementSubtitle') : activeTab === 'model' ? t('modelManagementSubtitle') : activeTab === 'knowledge_base' ? t('kbSettingsSubtitle') : activeTab === 'tenants' ? t('tenantsSubtitle') : activeTab === 'permissions' ? '管理角色和细粒度权限' : t('assessmentTemplatesSubtitle')}
</p>
</div>
</div>
<div className="flex-1 overflow-y-auto px-10 pb-10 scrollbar-hide">
<div className="w-full">
{error && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="mb-8 p-4 bg-red-50/80 backdrop-blur-md border border-red-200/50 text-red-700 rounded-2xl flex gap-3 items-center text-sm shadow-sm"
>
<div className="w-8 h-8 rounded-full bg-red-100 flex items-center justify-center shrink-0">
<X className="w-4 h-4 text-red-600" />
</div>
<div>
<span className="font-black uppercase tracking-widest text-xs block mb-0.5">{t('errorLabel')}</span>
{error}
</div>
</motion.div>
)}
<AnimatePresence mode="wait">
<motion.div
key={activeTab}
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
transition={{ duration: 0.3 }}
>
{activeTab === 'general' && renderGeneralTab()}
{activeTab === 'user' && currentUser?.role === 'SUPER_ADMIN' && renderUserTab()}
{activeTab === 'model' && isAdmin && renderModelTab()}
{activeTab === 'knowledge_base' && isAdmin && renderKnowledgeBaseTab()}
{activeTab === 'tenants' && currentUser?.role === 'SUPER_ADMIN' && renderTenantsTab()}
{activeTab === 'assessment_templates' && isAdmin && (
<div className="bg-white rounded-3xl border border-slate-200/60 p-8 shadow-sm">
<AssessmentTemplateManager />
</div>
)}
{activeTab === 'permissions' && isAdmin && (
<div className="flex-1 overflow-y-auto custom-scrollbar" style={{ height: 'calc(100vh - 220px)' }}>
<PermissionSettingsView />
</div>
)}
</motion.div>
</AnimatePresence>
</div>
</div>
</div>
</div>
);
};