c166d298b8
- 密码修改弹窗: 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>
2308 lines
140 KiB
TypeScript
2308 lines
140 KiB
TypeScript
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>
|
||
);
|
||
};
|