SettingsView.tsx 137 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314
  1. import React, { useState, useEffect } from 'react';
  2. import { ModelConfig, ModelType, AppSettings, KnowledgeGroup, Tenant, TenantMember, DEFAULT_SETTINGS } from '../../types';
  3. import { useLanguage } from '../../contexts/LanguageContext';
  4. import {
  5. ChevronLeft,
  6. ChevronRight,
  7. Plus,
  8. Search,
  9. KeyRound,
  10. Trash2,
  11. Edit,
  12. UserPlus,
  13. Globe,
  14. PlusCircle,
  15. Clock,
  16. ExternalLink,
  17. Download,
  18. Upload,
  19. Building,
  20. Settings as SettingsIcon,
  21. Shield,
  22. User,
  23. MoreVertical,
  24. Check,
  25. ChevronDown,
  26. ChevronUp,
  27. Filter,
  28. RefreshCcw,
  29. LayoutDashboard,
  30. Users,
  31. Database,
  32. UserCircle,
  33. HardDrive,
  34. LayoutGrid,
  35. X,
  36. Key,
  37. Loader2,
  38. Edit2,
  39. Save,
  40. Cpu,
  41. BookOpen,
  42. Sparkles,
  43. ToggleRight,
  44. ToggleLeft,
  45. } from "lucide-react";
  46. import { motion, AnimatePresence } from 'framer-motion';
  47. import { userService } from '../../services/userService';
  48. import { settingsService } from '../../services/settingsService';
  49. import { userSettingService } from '../../services/userSettingService';
  50. import { knowledgeGroupService } from '../../services/knowledgeGroupService';
  51. import { apiClient } from '../../services/apiClient';
  52. import { apiKeyService } from '../../services/apiKeyService';
  53. import { useConfirm } from '../../contexts/ConfirmContext';
  54. import { useToast } from '../../contexts/ToastContext';
  55. interface SettingsViewProps {
  56. // Model Props
  57. models: ModelConfig[];
  58. authToken: string | null;
  59. onUpdateModels: (action: 'create' | 'update' | 'delete', model: ModelConfig) => Promise<void>;
  60. isAdmin?: boolean; // Added isAdmin prop
  61. currentUser?: any; // Added current user prop
  62. initialTab?: TabType;
  63. }
  64. type TabType = 'general' | 'user' | 'model' | 'tenants' | 'knowledge_base' | 'import_tasks' | 'api_key';
  65. const buildTenantTree = (tenants: Tenant[]): Tenant[] => {
  66. const map = new Map<string, Tenant>();
  67. const roots: Tenant[] = [];
  68. tenants.forEach(t => {
  69. map.set(t.id, { ...t, children: [] });
  70. });
  71. tenants.forEach(t => {
  72. const node = map.get(t.id)!;
  73. if (t.parentId && map.has(t.parentId)) {
  74. const parent = map.get(t.parentId)!;
  75. parent.children = parent.children || [];
  76. parent.children.push(node);
  77. } else {
  78. roots.push(node);
  79. }
  80. });
  81. return roots;
  82. };
  83. // Moved outside to prevent re-mounting
  84. const Pagination: React.FC<{
  85. current: number;
  86. total: number;
  87. pageSize: number;
  88. onChange: (page: number) => void;
  89. }> = ({ current, total, pageSize, onChange }) => {
  90. const totalPages = Math.ceil(total / pageSize);
  91. if (totalPages <= 1) return null;
  92. return (
  93. <div className="flex items-center justify-center gap-2 mt-6">
  94. <button
  95. disabled={current === 1}
  96. onClick={() => onChange(current - 1)}
  97. 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"
  98. >
  99. <ChevronDown className="w-4 h-4 rotate-90" />
  100. </button>
  101. <div className="flex items-center gap-1">
  102. {[...Array(totalPages)].map((_, i) => {
  103. const p = i + 1;
  104. if (totalPages > 7) {
  105. if (p !== 1 && p !== totalPages && Math.abs(p - current) > 1) {
  106. if (p === 2 || p === totalPages - 1) return <span key={p} className="px-1 font-bold text-slate-300">...</span>;
  107. return null;
  108. }
  109. }
  110. return (
  111. <button
  112. key={p}
  113. onClick={() => onChange(p)}
  114. 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'}`}
  115. >
  116. {p}
  117. </button>
  118. );
  119. })}
  120. </div>
  121. <button
  122. disabled={current === totalPages}
  123. onClick={() => onChange(current + 1)}
  124. 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"
  125. >
  126. <ChevronDown className="w-4 h-4 -rotate-90" />
  127. </button>
  128. </div>
  129. );
  130. };
  131. export const SettingsView: React.FC<SettingsViewProps> = ({
  132. models,
  133. authToken,
  134. onUpdateModels,
  135. isAdmin = false,
  136. currentUser,
  137. initialTab = 'general',
  138. }) => {
  139. const { t, language, setLanguage } = useLanguage();
  140. const { confirm } = useConfirm();
  141. const { showError, showSuccess } = useToast();
  142. const [activeTab, setActiveTab] = useState<TabType>('general');
  143. const [isLoading, setIsLoading] = useState(false);
  144. const [error, setError] = useState<string | null>(null);
  145. // --- Model Manager State ---
  146. const [editingId, setEditingId] = useState<string | null>(null);
  147. const [modelFormData, setModelFormData] = useState<Partial<ModelConfig>>({
  148. type: ModelType.LLM,
  149. baseUrl: 'http://localhost:11434/v1',
  150. modelId: 'llama3',
  151. name: '',
  152. dimensions: 1536,
  153. apiKey: '',
  154. maxInputTokens: 8191,
  155. maxBatchSize: 2048
  156. });
  157. const [users, setUsers] = useState<any[]>([]);
  158. const [isUserLoading, setIsUserLoading] = useState(false);
  159. const [userPage, setUserPage] = useState(1);
  160. const USER_PAGE_SIZE = 20;
  161. const [showAddUser, setShowAddUser] = useState(false);
  162. const [newUser, setNewUser] = useState({ username: '', password: '', displayName: '' });
  163. const [userSuccess, setUserSuccess] = useState('');
  164. // --- Change Password State ---
  165. const [passwordForm, setPasswordForm] = useState({ current: '', new: '', confirm: '' });
  166. const [passwordSuccess, setPasswordSuccess] = useState('');
  167. // --- API Key State ---
  168. const [apiKey, setApiKey] = useState<string>('');
  169. const [isLoadingKey, setIsLoadingKey] = useState(false);
  170. const [isRegenerating, setIsRegenerating] = useState(false);
  171. const [apiKeyCopied, setApiKeyCopied] = useState(false);
  172. // --- App Settings State ---
  173. const [appSettings, setAppSettings] = useState<AppSettings | null>(null);
  174. const [knowledgeGroups, setKnowledgeGroups] = useState<KnowledgeGroup[]>([]);
  175. const [isSettingsLoading, setIsSettingsLoading] = useState(false);
  176. const [enabledModelIds, setEnabledModelIds] = useState<string[]>([]);
  177. // --- Tenant Admin Binding Search State ---
  178. const [bindingTenantId, setBindingTenantId] = useState<string | null>(null);
  179. const [userSearchQuery, setUserSearchQuery] = useState('');
  180. // --- Manage Members Modal State ---
  181. const [managingMembersTenantId, setManagingMembersTenantId] = useState<string | null>(null);
  182. const [tenantMembers, setTenantMembers] = useState<any[]>([]);
  183. const [allMemberIds, setAllMemberIds] = useState<Set<string>>(new Set());
  184. const [memberUserSearch, setMemberUserSearch] = useState('');
  185. const [bindingRole, setBindingRole] = useState('USER');
  186. const [currentMemberSearch, setCurrentMemberSearch] = useState('');
  187. const [isMembersLoading, setIsMembersLoading] = useState(false);
  188. const [activeTenantManagementId, setActiveTenantManagementId] = useState<string | null>(null);
  189. const [memberPage, setMemberPage] = useState(1);
  190. const [memberTotal, setMemberTotal] = useState(0);
  191. const MEMBER_PAGE_SIZE = 20;
  192. const [userTotal, setUserTotal] = useState(0);
  193. // --- Tenant Tree & Global Management State ---
  194. const [tenants, setTenants] = useState<Tenant[]>([]);
  195. const [selectedTenantId, setSelectedTenantId] = useState<string | null>(null);
  196. const [stats, setStats] = useState({ users: 0, tenants: 0 });
  197. const [showCreateTenant, setShowCreateTenant] = useState(false);
  198. const [editingTenant, setEditingTenant] = useState<Tenant | null>(null);
  199. const [newTenant, setNewTenant] = useState<{ name: string; domain: string; parentId: string | null }>({
  200. name: '',
  201. domain: '',
  202. parentId: null
  203. });
  204. useEffect(() => {
  205. if (initialTab) {
  206. setActiveTab(initialTab);
  207. }
  208. }, [initialTab]);
  209. useEffect(() => {
  210. if (activeTab === 'user' || activeTab === 'tenants') {
  211. fetchUsers(userPage);
  212. }
  213. }, [userPage]);
  214. useEffect(() => {
  215. if (selectedTenantId) {
  216. fetchTenantMembers(selectedTenantId, memberPage);
  217. fetchAllMemberIds(selectedTenantId);
  218. } else {
  219. setAllMemberIds(new Set());
  220. }
  221. }, [selectedTenantId, memberPage]);
  222. // Data fetching on tab change
  223. useEffect(() => {
  224. // Reset pages when switching tabs to avoid bleed-over
  225. if (activeTab === 'user' || activeTab === 'tenants') {
  226. setUserPage(1);
  227. }
  228. if (activeTab === 'user') {
  229. fetchUsers(1);
  230. } else if (activeTab === 'general') {
  231. fetchSettingsAndGroups();
  232. } else if (activeTab === 'api_key') {
  233. fetchApiKey();
  234. } else if (activeTab === 'tenants' && currentUser?.role === 'SUPER_ADMIN') {
  235. fetchTenantsData();
  236. fetchUsers(1); // Ensure users are loaded for admin binding
  237. }
  238. // Independent check for KB/Model settings to avoid being blocked by the branches above
  239. if ((activeTab === 'knowledge_base' || activeTab === 'model') &&
  240. (currentUser?.role === 'TENANT_ADMIN' || currentUser?.role === 'SUPER_ADMIN' || isAdmin)) {
  241. fetchKnowledgeBaseSettings();
  242. }
  243. }, [activeTab, currentUser, authToken, isAdmin]);
  244. const [kbSettings, setKbSettings] = useState<any>(null);
  245. const [localKbSettings, setLocalKbSettings] = useState<any>(null);
  246. const [isSavingKbSettings, setIsSavingKbSettings] = useState(false);
  247. const fetchKnowledgeBaseSettings = async () => {
  248. if (!authToken) return;
  249. setIsLoading(true);
  250. try {
  251. const data = await userSettingService.get(authToken);
  252. // If data is null, undefined, or empty object, use DEFAULT_SETTINGS
  253. const finalSettings = (data && Object.keys(data).length > 0) ? { ...DEFAULT_SETTINGS, ...data } : DEFAULT_SETTINGS;
  254. setKbSettings(finalSettings);
  255. setLocalKbSettings(finalSettings);
  256. } catch (error) {
  257. console.error(error);
  258. // Fallback to defaults on error to prevent blank page
  259. setKbSettings(DEFAULT_SETTINGS);
  260. setLocalKbSettings(DEFAULT_SETTINGS);
  261. } finally {
  262. setIsLoading(false);
  263. }
  264. };
  265. const handleUpdateKbSettings = (key: string, value: any) => {
  266. setLocalKbSettings((prev: any) => ({ ...prev, [key]: value }));
  267. };
  268. const handleSaveKbSettings = async () => {
  269. if (!authToken || !localKbSettings) return;
  270. setIsSavingKbSettings(true);
  271. try {
  272. await userSettingService.update(authToken, localKbSettings);
  273. setKbSettings(localKbSettings);
  274. showSuccess(t('kbSettingsSaved'));
  275. } catch (error) {
  276. console.error(error);
  277. showError(t('actionFailed'));
  278. } finally {
  279. setIsSavingKbSettings(false);
  280. }
  281. };
  282. const handleCancelKbSettings = () => {
  283. setLocalKbSettings(kbSettings);
  284. };
  285. const fetchSettingsAndGroups = async () => {
  286. if (!authToken) return;
  287. setIsSettingsLoading(true);
  288. try {
  289. const [settings, groups, personal] = await Promise.all([
  290. userSettingService.get(authToken),
  291. knowledgeGroupService.getGroups(),
  292. userSettingService.getPersonal(authToken)
  293. ]);
  294. setAppSettings(settings);
  295. setKnowledgeGroups(groups);
  296. // Sync local language with user settings if they differ
  297. if (personal?.language && personal.language !== language) {
  298. setLanguage(personal.language as any);
  299. }
  300. // Also update KB settings with the same data if not already set
  301. if (settings && Object.keys(settings).length > 0) {
  302. setKbSettings(settings);
  303. setLocalKbSettings(settings);
  304. }
  305. } catch (error) {
  306. console.error('Failed to fetch settings or groups:', error);
  307. } finally {
  308. setIsSettingsLoading(false);
  309. }
  310. };
  311. const fetchApiKey = async () => {
  312. setIsLoadingKey(true);
  313. try {
  314. const data = await apiKeyService.getApiKey();
  315. setApiKey(data.apiKey);
  316. } catch (error) {
  317. console.error('Failed to fetch API key:', error);
  318. showError('获取 API Key 失败');
  319. } finally {
  320. setIsLoadingKey(false);
  321. }
  322. };
  323. const handleRegenerateApiKey = async () => {
  324. if (!confirm('确定要重新生成 API Key 吗?旧的 API Key 将立即失效。')) {
  325. return;
  326. }
  327. setIsRegenerating(true);
  328. try {
  329. const data = await apiKeyService.regenerateApiKey();
  330. setApiKey(data.apiKey);
  331. showSuccess('API Key 已重新生成');
  332. } catch (error) {
  333. console.error('Failed to regenerate API key:', error);
  334. showError('重新生成 API Key 失败');
  335. } finally {
  336. setIsRegenerating(false);
  337. }
  338. };
  339. const handleCopyApiKey = async () => {
  340. try {
  341. await navigator.clipboard.writeText(apiKey);
  342. setApiKeyCopied(true);
  343. showSuccess('已复制到剪贴板');
  344. setTimeout(() => setApiKeyCopied(false), 2000);
  345. } catch (error) {
  346. console.error('Failed to copy:', error);
  347. showError('复制失败');
  348. }
  349. };
  350. // --- 一般タブのハンドラー ---
  351. const handleChangePassword = async (e: React.FormEvent) => {
  352. e.preventDefault();
  353. setError(null);
  354. setPasswordSuccess('');
  355. if (passwordForm.new !== passwordForm.confirm) {
  356. setError(t('passwordMismatch'));
  357. return;
  358. }
  359. if (passwordForm.new.length < 6) {
  360. setError(t('newPasswordMinLength'));
  361. return;
  362. }
  363. setIsLoading(true);
  364. try {
  365. await userService.changePassword(passwordForm.current, passwordForm.new);
  366. setPasswordSuccess(t('passwordChangeSuccess'));
  367. setPasswordForm({ current: '', new: '', confirm: '' });
  368. } catch (err: any) {
  369. setError(err.message || t('passwordChangeFailed'));
  370. } finally {
  371. setIsLoading(false);
  372. }
  373. };
  374. // --- ユーザータブのハンドラー ---
  375. const fetchUsers = async (page?: number) => {
  376. setIsUserLoading(true);
  377. const p = page || userPage;
  378. try {
  379. const result = await userService.getUsers(p, USER_PAGE_SIZE);
  380. if (result && result.data) {
  381. setUsers(result.data);
  382. setUserTotal(result.total);
  383. } else if (Array.isArray(result)) {
  384. setUsers(result);
  385. setUserTotal(result.length);
  386. }
  387. } catch (error: any) {
  388. setError(error.message || t('getUserListFailed'));
  389. } finally {
  390. setIsUserLoading(false);
  391. }
  392. };
  393. const handleCreateUser = async (e: React.FormEvent) => {
  394. e.preventDefault();
  395. setError('');
  396. setUserSuccess('');
  397. if (newUser.username && newUser.password && newUser.displayName) {
  398. setIsUserLoading(true);
  399. try {
  400. await userService.createUser(
  401. newUser.username,
  402. newUser.password,
  403. false,
  404. undefined,
  405. newUser.displayName
  406. );
  407. showSuccess(t('userCreatedSuccess'));
  408. setNewUser({ username: '', password: '', displayName: '' });
  409. setShowAddUser(false);
  410. fetchUsers();
  411. } catch (error: any) {
  412. setError(error.message || t('createUserFailed'));
  413. } finally {
  414. setIsUserLoading(false);
  415. }
  416. }
  417. };
  418. const [passwordChangeUserData, setPasswordChangeUserData] = useState<{ userId: string, newPassword: string } | null>(null);
  419. // --- Edit User State ---
  420. const [editUserData, setEditUserData] = useState<{ userId: string, username: string, displayName: string } | null>(null);
  421. const handleToggleUserAdmin = async (userId: string, newAdminStatus: boolean) => {
  422. try {
  423. await userService.updateUser(userId, newAdminStatus);
  424. // ユーザーリストを再取得
  425. fetchUsers();
  426. setUserSuccess(newAdminStatus ? t('userPromotedToAdmin') : t('userDemotedFromAdmin'));
  427. } catch (error: any) {
  428. setError(error.message || t('updateUserFailed'));
  429. }
  430. };
  431. const handleUserPasswordChange = async () => {
  432. if (!passwordChangeUserData || !passwordChangeUserData.newPassword) return;
  433. try {
  434. // Update user password
  435. await userService.updateUserInfo(passwordChangeUserData.userId, { password: passwordChangeUserData.newPassword });
  436. setUserSuccess(t('passwordChangeSuccess'));
  437. setPasswordChangeUserData(null);
  438. fetchUsers(); // Refresh the user list
  439. } catch (error: any) {
  440. setError(error.message || t('passwordChangeFailed'));
  441. }
  442. };
  443. const fetchAllMemberIds = async (tenantId: string) => {
  444. try {
  445. const { data } = await apiClient.get<string[]>(`/v1/tenants/${tenantId}/members/ids`);
  446. if (Array.isArray(data)) {
  447. setAllMemberIds(new Set(data));
  448. }
  449. } catch (e) {
  450. console.error('Failed to fetch all member IDs:', e);
  451. }
  452. };
  453. const fetchTenantMembers = async (tenantId: string, page?: number) => {
  454. setIsMembersLoading(true);
  455. const p = page || memberPage;
  456. try {
  457. const { data } = await apiClient.get(`/v1/tenants/${tenantId}/members?page=${p}&limit=${MEMBER_PAGE_SIZE}`);
  458. if (data && data.data) {
  459. setTenantMembers(data.data);
  460. setMemberTotal(data.total);
  461. } else if (Array.isArray(data)) {
  462. setTenantMembers(data);
  463. setMemberTotal(data.length);
  464. }
  465. } catch (e) {
  466. console.error(e);
  467. } finally {
  468. setIsMembersLoading(false);
  469. }
  470. };
  471. const handleAddMember = async (tenantId: string, userId: string, role: string = 'USER') => {
  472. try {
  473. await apiClient.post(`/v1/tenants/${tenantId}/members`, { userId, role });
  474. setAllMemberIds(prev => {
  475. const next = new Set(prev);
  476. next.add(userId);
  477. return next;
  478. });
  479. showSuccess(t('confirm'));
  480. fetchTenantMembers(tenantId);
  481. fetchTenantsData();
  482. } catch (e: any) {
  483. showError(e.message || 'Error adding member');
  484. }
  485. };
  486. const handleRemoveMember = async (tenantId: string, userId: string) => {
  487. try {
  488. await apiClient.delete(`/v1/tenants/${tenantId}/members/${userId}`);
  489. setAllMemberIds(prev => {
  490. const next = new Set(prev);
  491. next.delete(userId);
  492. return next;
  493. });
  494. showSuccess('User removed from organization');
  495. fetchTenantMembers(tenantId);
  496. fetchTenantsData();
  497. } catch (e: any) {
  498. showError(e.message || 'Error removing member');
  499. }
  500. };
  501. const handleUpdateMemberRole = async (tenantId: string, userId: string, role: string) => {
  502. try {
  503. await apiClient.patch(`/v1/tenants/${tenantId}/members/${userId}`, { role });
  504. showSuccess(t('featureUpdated'));
  505. fetchTenantMembers(tenantId);
  506. } catch (e: any) {
  507. showError(e.message || 'Error updating role');
  508. }
  509. };
  510. const fetchTenantsData = async () => {
  511. if (!authToken) return;
  512. setIsLoading(true);
  513. try {
  514. const [tenRes, admRes] = await Promise.all([
  515. apiClient.get('/v1/tenants'),
  516. apiClient.get('/users?page=1&limit=1')
  517. ]);
  518. const data: Tenant[] = tenRes.data;
  519. const filteredData = data.filter(t => t.name !== 'Default');
  520. setTenants(filteredData);
  521. setStats(s => ({ ...s, tenants: filteredData.length }));
  522. const result = admRes.data;
  523. setStats(s => ({ ...s, users: result.total ?? result.length ?? 0 }));
  524. } catch (e) {
  525. console.error(e);
  526. } finally {
  527. setIsLoading(false);
  528. }
  529. };
  530. const handleCreateTenant = async (e: React.FormEvent) => {
  531. e.preventDefault();
  532. try {
  533. const path = editingTenant ? `/v1/tenants/${editingTenant.id}` : '/v1/tenants';
  534. const body = {
  535. name: newTenant.name,
  536. domain: newTenant.domain,
  537. parentId: newTenant.parentId
  538. };
  539. if (editingTenant) {
  540. await apiClient.put(path, body);
  541. } else {
  542. await apiClient.post(path, body);
  543. }
  544. setShowCreateTenant(false);
  545. setEditingTenant(null);
  546. setNewTenant({ name: '', domain: '', parentId: null });
  547. fetchTenantsData();
  548. showSuccess(editingTenant ? 'Tenant updated' : 'Tenant created');
  549. } catch (e: any) {
  550. showError(e.message || 'Action failed');
  551. }
  552. };
  553. const handleRemoveTenant = async (tenantId: string) => {
  554. if (!(await confirm(t('confirmDeleteTenant')))) return;
  555. try {
  556. await apiClient.delete(`/v1/tenants/${tenantId}`);
  557. setSelectedTenantId(null);
  558. fetchTenantsData();
  559. showSuccess('Tenant deleted');
  560. } catch (e: any) {
  561. showError(e.message || 'Delete failed');
  562. }
  563. };
  564. const handleUpdateUser = async () => {
  565. if (!editUserData) return;
  566. try {
  567. await userService.updateUserInfo(editUserData.userId, {
  568. username: editUserData.username,
  569. displayName: editUserData.displayName
  570. });
  571. showSuccess(t('featureUpdated'));
  572. setEditUserData(null);
  573. fetchUsers();
  574. } catch (error: any) {
  575. showError('Failed to update user');
  576. }
  577. };
  578. const handleDeleteUser = async (userId: string) => {
  579. if (!confirm(t('confirmDeleteUser') || "Are you sure?")) return;
  580. try {
  581. await userService.deleteUser(userId);
  582. showSuccess(t('userDeletedSuccessfully'));
  583. fetchUsers();
  584. } catch (error: any) {
  585. showError(error.message || t('deleteUserFailed'));
  586. }
  587. };
  588. const handleExportUsers = async () => {
  589. try {
  590. const blob = await userService.exportUsers();
  591. const url = window.URL.createObjectURL(blob);
  592. const a = document.createElement('a');
  593. a.href = url;
  594. a.download = `users_${new Date().toISOString().split('T')[0]}.xlsx`;
  595. document.body.appendChild(a);
  596. a.click();
  597. window.URL.revokeObjectURL(url);
  598. document.body.removeChild(a);
  599. } catch (error) {
  600. console.error('Export users failed', error);
  601. showError(t('exportFailed'));
  602. }
  603. };
  604. const handleImportUsers = async (e: React.ChangeEvent<HTMLInputElement>) => {
  605. const file = e.target.files?.[0];
  606. if (!file) return;
  607. try {
  608. const result = await userService.importUsers(file);
  609. showSuccess(t('importSuccess').replace('$1', (result.success || 0).toString()).replace('$2', (result.failed || 0).toString()));
  610. fetchUsers();
  611. if (result.errors.length > 0) {
  612. console.warn('Import had errors:', result.errors);
  613. }
  614. } catch (error: any) {
  615. console.error('Import users failed', error);
  616. showError(t('importFailed') + (error.response?.data?.message ? `: ${error.response.data.message}` : ''));
  617. } finally {
  618. // Reset input
  619. e.target.value = '';
  620. }
  621. };
  622. const handleSaveModel = async () => {
  623. if (!authToken) return;
  624. setIsLoading(true);
  625. try {
  626. await onUpdateModels(editingId === 'new' ? 'create' : 'update', modelFormData as ModelConfig);
  627. setEditingId(null);
  628. } catch (err) {
  629. setError('Update failed');
  630. } finally {
  631. setIsLoading(false);
  632. }
  633. };
  634. const handleToggleModel = async (model: ModelConfig) => {
  635. if (currentUser?.role === 'TENANT_ADMIN') {
  636. const newEnabledIds = enabledModelIds.includes(model.id)
  637. ? enabledModelIds.filter(id => id !== model.id)
  638. : [...enabledModelIds, model.id];
  639. try {
  640. await apiClient.put('/v1/admin/settings', { enabledModelIds: newEnabledIds });
  641. setEnabledModelIds(newEnabledIds);
  642. setLocalKbSettings((prev: any) => ({ ...prev, enabledModelIds: newEnabledIds }));
  643. showSuccess('Updated');
  644. } catch (e: any) {
  645. showError(e.message || 'Update failed');
  646. }
  647. return;
  648. }
  649. await onUpdateModels('update', { ...model, isEnabled: !model.isEnabled });
  650. };
  651. const handleDeleteModel = async (id: string) => {
  652. if (await confirm(t('confirmDeleteModel'))) {
  653. await onUpdateModels('delete', { id } as ModelConfig);
  654. }
  655. };
  656. const TenantTreeNode: React.FC<{
  657. tenant: Tenant;
  658. selectedTenantId: string | null;
  659. onSelect: (id: string) => void;
  660. onCreateSubtenant: (parentId: string) => void;
  661. depth?: number;
  662. }> = ({ tenant, selectedTenantId, onSelect, onCreateSubtenant, depth = 0 }) => {
  663. const [collapsed, setCollapsed] = useState(false);
  664. const hasChildren = tenant.children && tenant.children.length > 0;
  665. const isSelected = selectedTenantId === tenant.id;
  666. return (
  667. <div className="select-none">
  668. <div
  669. 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'}`}
  670. style={{ paddingLeft: `${depth * 1.5 + 0.75}rem` }}
  671. onClick={() => onSelect(tenant.id)}
  672. >
  673. <div className="flex items-center gap-2 flex-1 min-w-0">
  674. {hasChildren ? (
  675. <button
  676. onClick={(e) => { e.stopPropagation(); setCollapsed(!collapsed); }}
  677. className={`p-0.5 rounded-md hover:bg-black/10 transition-colors ${isSelected ? 'text-white/70' : 'text-slate-400'}`}
  678. >
  679. {collapsed ? <ChevronRight size={14} /> : <ChevronDown size={14} />}
  680. </button>
  681. ) : (
  682. <div className="w-5" />
  683. )}
  684. <Building size={16} className={isSelected ? 'text-white' : 'text-slate-400'} />
  685. <span className="text-sm font-bold truncate">{tenant.name}</span>
  686. </div>
  687. <button
  688. onClick={(e) => {
  689. e.stopPropagation();
  690. onCreateSubtenant(tenant.id);
  691. }}
  692. 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'}`}
  693. title={t('createSubOrg')}
  694. >
  695. <Plus size={14} />
  696. </button>
  697. </div>
  698. {hasChildren && !collapsed && (
  699. <div className="mt-1">
  700. {tenant.children?.map(child => (
  701. <TenantTreeNode
  702. key={child.id}
  703. tenant={child}
  704. selectedTenantId={selectedTenantId}
  705. onSelect={onSelect}
  706. onCreateSubtenant={onCreateSubtenant}
  707. depth={depth + 1}
  708. />
  709. ))}
  710. </div>
  711. )}
  712. </div>
  713. );
  714. };
  715. const getTypeLabel = (type: ModelType) => {
  716. switch (type) {
  717. case ModelType.LLM: return t('typeLLM');
  718. case ModelType.EMBEDDING: return t('typeEmbedding');
  719. case ModelType.RERANK: return t('typeRerank');
  720. case ModelType.VISION: return t('typeVision');
  721. default: return type;
  722. }
  723. };
  724. // --- レンダリング関数 ---
  725. const renderGeneralTab = () => (
  726. <div className="space-y-8 animate-in slide-in-from-right duration-300 max-w-2xl">
  727. {/* パスワード変更セクション */}
  728. <section className="bg-white p-6 rounded-lg border border-slate-200 shadow-sm">
  729. <h3 className="text-sm font-medium text-slate-800 mb-4 flex items-center gap-2">
  730. <Key className="w-4 h-4 text-blue-500" />
  731. {t('changePassword')}
  732. </h3>
  733. <form onSubmit={handleChangePassword} className="space-y-4 max-w-sm">
  734. <input type="hidden" name="username" value="current-user" autoComplete="username" />
  735. <div>
  736. <input
  737. type="password"
  738. name="currentPassword"
  739. placeholder={t('currentPassword')}
  740. value={passwordForm.current}
  741. onChange={e => setPasswordForm({ ...passwordForm, current: e.target.value })}
  742. className="w-full px-3 py-2 text-sm border border-slate-300 rounded-md focus:ring-2 focus:ring-blue-500 outline-none"
  743. required
  744. autoComplete="current-password"
  745. />
  746. </div>
  747. <div>
  748. <input
  749. type="password"
  750. name="newPassword"
  751. placeholder={t('newPassword')}
  752. value={passwordForm.new}
  753. onChange={e => setPasswordForm({ ...passwordForm, new: e.target.value })}
  754. className="w-full px-3 py-2 text-sm border border-slate-300 rounded-md focus:ring-2 focus:ring-blue-500 outline-none"
  755. required
  756. autoComplete="new-password"
  757. />
  758. </div>
  759. <div>
  760. <input
  761. type="password"
  762. name="confirmPassword"
  763. placeholder={t('confirmPassword')}
  764. value={passwordForm.confirm}
  765. onChange={e => setPasswordForm({ ...passwordForm, confirm: e.target.value })}
  766. className="w-full px-3 py-2 text-sm border border-slate-300 rounded-md focus:ring-2 focus:ring-blue-500 outline-none"
  767. required
  768. autoComplete="new-password"
  769. />
  770. </div>
  771. {passwordSuccess && <p className="text-xs text-green-600">{passwordSuccess}</p>}
  772. <button
  773. type="submit"
  774. disabled={isLoading}
  775. className="w-full py-2 bg-slate-800 text-white rounded-md text-sm hover:bg-slate-900 transition-colors disabled:opacity-50"
  776. >
  777. {isLoading ? <Loader2 className="w-4 h-4 animate-spin mx-auto" /> : t('confirmChange')}
  778. </button>
  779. </form>
  780. </section>
  781. {/* 语言设置セクション */}
  782. <section className="bg-white p-6 rounded-lg border border-slate-200 shadow-sm">
  783. <h3 className="text-sm font-medium text-slate-800 mb-4 flex items-center gap-2">
  784. <Globe className="w-4 h-4 text-blue-500" />
  785. {t('languageSettings')}
  786. </h3>
  787. <div className="space-y-4 max-w-sm">
  788. <div className="space-y-2">
  789. <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1">
  790. {t('switchLanguage')}
  791. </label>
  792. <select
  793. value={language}
  794. onChange={async (e) => {
  795. const newLang = e.target.value as any;
  796. setLanguage(newLang);
  797. try {
  798. await settingsService.updateLanguage(newLang);
  799. showSuccess(t('confirm'));
  800. } catch (err) {
  801. console.error('Failed to update backend language preference:', err);
  802. }
  803. }}
  804. 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"
  805. >
  806. <option value="en">English</option>
  807. <option value="zh">中文 (Chinese)</option>
  808. <option value="ja">日本語 (Japanese)</option>
  809. </select>
  810. </div>
  811. </div>
  812. </section>
  813. </div >
  814. );
  815. const renderApiKeyTab = () => (
  816. <div className="space-y-8 animate-in slide-in-from-right duration-300 max-w-2xl">
  817. <section className="bg-white p-6 rounded-lg border border-slate-200 shadow-sm">
  818. <h3 className="text-sm font-medium text-slate-800 mb-4 flex items-center gap-2">
  819. <Key className="w-4 h-4 text-blue-500" />
  820. API Key 管理
  821. </h3>
  822. <p className="text-sm text-slate-500 mb-4">
  823. 使用 API Key 可以通过开放 API 访问您的知识库。请妥善保管您的 API Key,不要泄露给他人。
  824. </p>
  825. {isLoadingKey ? (
  826. <div className="flex items-center gap-2 text-slate-500">
  827. <Loader2 className="w-4 h-4 animate-spin" />
  828. <span className="text-sm">加载中...</span>
  829. </div>
  830. ) : (
  831. <div className="space-y-4">
  832. <div>
  833. <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1 block mb-2">
  834. 您的 API Key
  835. </label>
  836. <div className="flex gap-2">
  837. <input
  838. type="text"
  839. value={apiKey}
  840. readOnly
  841. className="flex-1 px-3 py-2 text-sm font-mono bg-slate-50 border border-slate-300 rounded-md text-slate-700"
  842. />
  843. <button
  844. onClick={handleCopyApiKey}
  845. className="px-4 py-2 bg-slate-100 text-slate-700 rounded-md text-sm hover:bg-slate-200 transition-colors flex items-center gap-2"
  846. >
  847. {apiKeyCopied ? <Check className="w-4 h-4" /> : <Download className="w-4 h-4" />}
  848. {apiKeyCopied ? '已复制' : '复制'}
  849. </button>
  850. </div>
  851. </div>
  852. <div className="pt-4 border-t border-slate-200">
  853. <button
  854. onClick={handleRegenerateApiKey}
  855. disabled={isRegenerating}
  856. className="px-4 py-2 bg-red-50 text-red-600 rounded-md text-sm hover:bg-red-100 transition-colors disabled:opacity-50 flex items-center gap-2"
  857. >
  858. {isRegenerating ? (
  859. <Loader2 className="w-4 h-4 animate-spin" />
  860. ) : (
  861. <RefreshCcw className="w-4 h-4" />
  862. )}
  863. 重新生成 API Key
  864. </button>
  865. <p className="text-xs text-slate-400 mt-2">
  866. 注意:重新生成后,旧的 API Key 将立即失效,所有使用旧 Key 的应用将无法访问。
  867. </p>
  868. </div>
  869. </div>
  870. )}
  871. </section>
  872. <section className="bg-white p-6 rounded-lg border border-slate-200 shadow-sm">
  873. <h3 className="text-sm font-medium text-slate-800 mb-4 flex items-center gap-2">
  874. <ExternalLink className="w-4 h-4 text-blue-500" />
  875. API 使用示例
  876. </h3>
  877. <div className="space-y-4">
  878. <div>
  879. <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1 block mb-2">
  880. 使用 x-api-key 头
  881. </label>
  882. <pre className="bg-slate-900 text-slate-100 p-4 rounded-lg text-xs overflow-x-auto">
  883. {`curl -X POST http://localhost:3001/api/v1/chat \\
  884. -H "Content-Type: application/json" \\
  885. -H "x-api-key: ${apiKey || 'YOUR_API_KEY'}" \\
  886. -d '{"message": "你好", "stream": false}'`}
  887. </pre>
  888. </div>
  889. <div>
  890. <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1 block mb-2">
  891. 使用 Authorization 头
  892. </label>
  893. <pre className="bg-slate-900 text-slate-100 p-4 rounded-lg text-xs overflow-x-auto">
  894. {`curl -X POST http://localhost:3001/api/v1/chat \\
  895. -H "Content-Type: application/json" \\
  896. -H "Authorization: Bearer ${apiKey || 'YOUR_API_KEY'}" \\
  897. -d '{"message": "你好", "stream": true}'`}
  898. </pre>
  899. </div>
  900. </div>
  901. </section>
  902. </div>
  903. );
  904. const renderUserTab = () => (
  905. <div className="space-y-6 w-full">
  906. <div className="flex justify-between items-center mb-6">
  907. <div>
  908. <p className="text-xs text-slate-400 font-medium">{''}</p>
  909. </div>
  910. {currentUser?.role === 'SUPER_ADMIN' && (
  911. <div className="flex gap-2">
  912. <button
  913. onClick={handleExportUsers}
  914. 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"
  915. title={t('exportUsers')}
  916. >
  917. <Download className="w-4 h-4" />
  918. <span className="hidden sm:inline">{t('exportUsers')}</span>
  919. </button>
  920. <div className="relative">
  921. <input
  922. type="file"
  923. accept=".xlsx,.xls,.csv"
  924. onChange={handleImportUsers}
  925. className="absolute inset-0 opacity-0 cursor-pointer"
  926. title={t('importUsers')}
  927. />
  928. <button
  929. 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"
  930. >
  931. <Upload className="w-4 h-4" />
  932. <span className="hidden sm:inline">{t('importUsers')}</span>
  933. </button>
  934. </div>
  935. <button
  936. onClick={() => setShowAddUser(!showAddUser)}
  937. 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"
  938. >
  939. <Plus className="w-4 h-4" />
  940. {t('addUser')}
  941. </button>
  942. </div>
  943. )}
  944. </div>
  945. {showAddUser && (
  946. <motion.form
  947. initial={{ opacity: 0, y: -10 }}
  948. animate={{ opacity: 1, y: 0 }}
  949. onSubmit={handleCreateUser}
  950. className="bg-white/80 backdrop-blur-md p-8 rounded-3xl border border-slate-200/50 shadow-xl mb-8 space-y-5"
  951. >
  952. <div className="grid grid-cols-1 md:grid-cols-3 gap-5">
  953. <input
  954. type="text"
  955. placeholder={t('usernamePlaceholder')}
  956. value={newUser.username}
  957. onChange={e => setNewUser({ ...newUser, username: e.target.value })}
  958. 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"
  959. required
  960. autoComplete="username"
  961. />
  962. <input
  963. type="text"
  964. placeholder={t('displayNamePlaceholder') || t('name')}
  965. value={newUser.displayName}
  966. onChange={e => setNewUser({ ...newUser, displayName: e.target.value })}
  967. 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"
  968. required
  969. autoComplete="name"
  970. />
  971. <input
  972. type="password"
  973. placeholder={t('passwordPlaceholder')}
  974. value={newUser.password}
  975. onChange={e => setNewUser({ ...newUser, password: e.target.value })}
  976. 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"
  977. required
  978. autoComplete="new-password"
  979. />
  980. </div>
  981. <div className="flex items-center justify-between">
  982. <div></div>
  983. <div className="flex gap-3">
  984. <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>
  985. <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>
  986. </div>
  987. </div>
  988. </motion.form>
  989. )}
  990. {passwordChangeUserData && (
  991. <div className="fixed inset-0 bg-slate-900/40 backdrop-blur-sm flex items-center justify-center z-[100] p-6">
  992. <motion.div
  993. initial={{ scale: 0.95, opacity: 0 }}
  994. animate={{ scale: 1, opacity: 1 }}
  995. className="bg-white rounded-3xl p-10 w-full max-w-md shadow-2xl border border-white/20"
  996. >
  997. <div className="flex items-center justify-between mb-8">
  998. <h3 className="text-xl font-black text-slate-900 tracking-tight">{t('changeUserPassword')}</h3>
  999. <button onClick={() => setPasswordChangeUserData(null)} className="p-2 hover:bg-slate-100 rounded-xl transition-all">
  1000. <X size={20} className="text-slate-400" />
  1001. </button>
  1002. </div>
  1003. <form onSubmit={(e) => { e.preventDefault(); handleUserPasswordChange(); }} className="space-y-6">
  1004. <div>
  1005. <label className="block text-[10px] font-black text-slate-400 uppercase tracking-widest mb-2 px-1">
  1006. {t('newPassword')}
  1007. </label>
  1008. <input
  1009. type="password"
  1010. value={passwordChangeUserData.newPassword}
  1011. onChange={(e) => setPasswordChangeUserData({ ...passwordChangeUserData, newPassword: e.target.value })}
  1012. className="w-full px-4 py-3.5 bg-slate-50 border border-slate-200 rounded-2xl text-[14px] font-medium transition-all focus:outline-none focus:ring-4 focus:ring-indigo-500/10 focus:border-indigo-500/50"
  1013. placeholder={t('enterNewPassword')}
  1014. required
  1015. />
  1016. </div>
  1017. <div className="flex gap-4 pt-4">
  1018. <button type="button" onClick={() => setPasswordChangeUserData(null)} className="flex-1 py-3.5 text-slate-500 font-bold text-sm">{t('cancel')}</button>
  1019. <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('confirmChange')}</button>
  1020. </div>
  1021. </form>
  1022. </motion.div>
  1023. </div>
  1024. )}
  1025. {/* Edit User Modal */}
  1026. {editUserData && (
  1027. <div className="fixed inset-0 z-[110] flex items-center justify-center bg-slate-900/40 backdrop-blur-sm p-4">
  1028. <motion.div
  1029. initial={{ scale: 0.95, opacity: 0 }}
  1030. animate={{ scale: 1, opacity: 1 }}
  1031. className="bg-white rounded-3xl p-10 w-full max-w-md shadow-2xl border border-white/20"
  1032. >
  1033. <div className="flex items-center justify-between mb-8">
  1034. <h3 className="text-xl font-black text-slate-900 tracking-tight">{t('editUser')}</h3>
  1035. <button onClick={() => setEditUserData(null)} className="p-2 hover:bg-slate-100 rounded-xl transition-all">
  1036. <X size={20} className="text-slate-400" />
  1037. </button>
  1038. </div>
  1039. <form onSubmit={(e) => { e.preventDefault(); handleUpdateUser(); }} className="space-y-6">
  1040. <div>
  1041. <label className="block text-[10px] font-black text-slate-400 uppercase tracking-widest mb-2 px-1">
  1042. {t('username')}
  1043. </label>
  1044. <input
  1045. type="text"
  1046. value={editUserData.username}
  1047. onChange={(e) => setEditUserData({ ...editUserData, username: e.target.value })}
  1048. className="w-full px-4 py-3.5 bg-slate-50 border border-slate-200 rounded-2xl text-[14px] font-medium transition-all focus:outline-none focus:ring-4 focus:ring-indigo-500/10 focus:border-indigo-500/50"
  1049. placeholder={t('usernamePlaceholder')}
  1050. required
  1051. />
  1052. </div>
  1053. <div>
  1054. <label className="block text-[10px] font-black text-slate-400 uppercase tracking-widest mb-2 px-1">
  1055. {t('displayName') || t('name')}
  1056. </label>
  1057. <input
  1058. type="text"
  1059. value={editUserData.displayName}
  1060. onChange={(e) => setEditUserData({ ...editUserData, displayName: e.target.value })}
  1061. className="w-full px-4 py-3.5 bg-slate-50 border border-slate-200 rounded-2xl text-[14px] font-medium transition-all focus:outline-none focus:ring-4 focus:ring-indigo-500/10 focus:border-indigo-500/50"
  1062. placeholder={t('displayNamePlaceholder') || t('namePlaceholder')}
  1063. required
  1064. />
  1065. </div>
  1066. <div className="py-2.5 px-4 bg-indigo-50 rounded-2xl border border-indigo-100/50">
  1067. <p className="text-[10px] font-black text-indigo-500 uppercase tracking-widest mb-1">{t('globalUserNote') || "Note"}</p>
  1068. <p className="text-[11px] text-indigo-700/70 leading-relaxed font-medium">
  1069. {t('roleManagedInOrg') || "Roles are managed within organizations."}
  1070. </p>
  1071. </div>
  1072. <div className="flex gap-4 pt-4">
  1073. <button type="button" onClick={() => setEditUserData(null)} className="flex-1 py-3.5 text-slate-500 font-bold text-sm">{t('cancel')}</button>
  1074. <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>
  1075. </div>
  1076. </form>
  1077. </motion.div>
  1078. </div>
  1079. )}
  1080. <div className="w-full bg-white/70 backdrop-blur-md border border-slate-200/50 rounded-2xl overflow-hidden shadow-sm">
  1081. <table className="w-full border-collapse text-left">
  1082. <thead>
  1083. <tr className="bg-slate-50/50 border-b border-slate-200/50">
  1084. <th className="px-6 py-4 text-[10px] font-black text-slate-400 uppercase tracking-widest">{t('username')}</th>
  1085. <th className="px-6 py-4 text-[10px] font-black text-slate-400 uppercase tracking-widest">{t('displayName') || t('name')}</th>
  1086. <th className="px-6 py-4 text-[10px] font-black text-slate-400 uppercase tracking-widest">{t('organizations')}</th>
  1087. <th className="px-6 py-4 text-[10px] font-black text-slate-400 uppercase tracking-widest">{t('createdAt')}</th>
  1088. <th className="px-6 py-4 text-[10px] font-black text-slate-400 uppercase tracking-widest text-right">{t('actions')}</th>
  1089. </tr>
  1090. </thead>
  1091. <tbody className="divide-y divide-slate-100">
  1092. <AnimatePresence>
  1093. {users.filter((u: any) => u.username !== 'admin').map((user: any, index: number) => {
  1094. let IconComponent = User;
  1095. let iconColors = 'bg-slate-50 text-slate-400';
  1096. if (user.isAdmin) {
  1097. IconComponent = Shield;
  1098. iconColors = 'bg-red-50 text-red-600';
  1099. }
  1100. return (
  1101. <motion.tr
  1102. key={user.id}
  1103. initial={{ opacity: 0, y: 10 }}
  1104. animate={{ opacity: 1, y: 0 }}
  1105. transition={{ delay: index * 0.03 }}
  1106. className="group hover:bg-slate-50/50 transition-all"
  1107. >
  1108. <td className="px-6 py-4">
  1109. <div className="flex items-center gap-3">
  1110. <div className={`w-10 h-10 rounded-xl flex items-center justify-center flex-shrink-0 ${iconColors}`}>
  1111. <IconComponent size={18} />
  1112. </div>
  1113. <div className="min-w-0">
  1114. <p className="font-bold text-slate-900 truncate">{user.username}</p>
  1115. </div>
  1116. </div>
  1117. </td>
  1118. <td className="px-6 py-4">
  1119. <p className="text-sm text-slate-600 truncate">{user.displayName || '-'}</p>
  1120. </td>
  1121. <td className="px-6 py-4">
  1122. {user.tenantMembers && user.tenantMembers.length > 0 ? (
  1123. <div className="flex flex-wrap gap-1">
  1124. {user.tenantMembers
  1125. .filter((m: any) => m.tenant?.name !== 'Default')
  1126. .map((m: any) => (
  1127. <span
  1128. key={m.tenantId}
  1129. className="inline-flex items-center gap-1 px-2 py-0.5 bg-emerald-50 text-emerald-700 text-[9px] font-black rounded-md uppercase tracking-wider border border-emerald-100"
  1130. >
  1131. <Building size={8} />
  1132. {m.tenant?.name || m.tenantId}
  1133. </span>
  1134. ))}
  1135. </div>
  1136. ) : (
  1137. <span className="text-[10px] text-slate-400 italic">{t('noOrganization')}</span>
  1138. )}
  1139. </td>
  1140. <td className="px-6 py-4">
  1141. <p className="text-[11px] font-medium text-slate-600">
  1142. {new Date(user.createdAt).toLocaleDateString()}
  1143. </p>
  1144. </td>
  1145. <td className="px-6 py-4 text-right">
  1146. <div className="flex items-center justify-end gap-1 opacity-0 group-hover:opacity-100 transition-all">
  1147. {user.username !== 'admin' && (
  1148. <>
  1149. <button
  1150. onClick={(e) => {
  1151. e.preventDefault();
  1152. e.stopPropagation();
  1153. setEditUserData({
  1154. userId: user.id,
  1155. username: user.username,
  1156. displayName: user.displayName || ''
  1157. });
  1158. }}
  1159. className="p-2 rounded-lg text-slate-400 hover:text-indigo-600 hover:bg-white shadow-sm transition-all"
  1160. title={t('edit')}
  1161. >
  1162. <Edit2 className="w-4 h-4" />
  1163. </button>
  1164. <button
  1165. onClick={() => setPasswordChangeUserData({ userId: user.id, newPassword: '' })}
  1166. className="p-2 rounded-lg text-slate-400 hover:text-indigo-600 hover:bg-white shadow-sm transition-all"
  1167. title={t('changeUserPassword')}
  1168. >
  1169. <Key className="w-4 h-4" />
  1170. </button>
  1171. {user.id !== currentUser?.id && (
  1172. <button
  1173. onClick={() => handleDeleteUser(user.id)}
  1174. className="p-2 rounded-lg text-slate-400 hover:text-red-500 hover:bg-white shadow-sm transition-all"
  1175. title={t('deleteUser')}
  1176. >
  1177. <Trash2 className="w-4 h-4" />
  1178. </button>
  1179. )}
  1180. </>
  1181. )}
  1182. </div>
  1183. </td>
  1184. </motion.tr>
  1185. );
  1186. })}
  1187. </AnimatePresence>
  1188. </tbody>
  1189. </table>
  1190. </div>
  1191. <Pagination
  1192. current={userPage}
  1193. total={userTotal}
  1194. pageSize={USER_PAGE_SIZE}
  1195. onChange={setUserPage}
  1196. />
  1197. </div>
  1198. );
  1199. const renderTenantsTab = () => {
  1200. const tenantTree = buildTenantTree(tenants);
  1201. const activeTenant = selectedTenantId ? tenants.find(t => t.id === selectedTenantId) : null;
  1202. return (
  1203. <div className="flex flex-col lg:flex-row gap-6 h-[calc(100vh-12rem)] animate-in fade-in duration-500">
  1204. {/* Left: Organization Tree */}
  1205. <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">
  1206. <div className="p-6 border-b border-slate-100 flex items-center justify-between shrink-0">
  1207. <div>
  1208. <h3 className="font-black text-slate-900 text-lg tracking-tight">{t('orgManagement')}</h3>
  1209. <p className="text-[10px] font-bold text-slate-400 uppercase tracking-widest">{t('globalTenantControl')}</p>
  1210. </div>
  1211. <button
  1212. onClick={() => {
  1213. setNewTenant({ name: '', domain: '', parentId: null });
  1214. setEditingTenant(null);
  1215. setShowCreateTenant(true);
  1216. }}
  1217. className="p-2 bg-slate-900 text-white rounded-xl hover:bg-slate-700 transition-all"
  1218. >
  1219. <Plus size={18} />
  1220. </button>
  1221. </div>
  1222. <div className="flex-1 overflow-y-auto p-4 space-y-1 custom-scrollbar">
  1223. {tenantTree.length > 0 ? (
  1224. tenantTree.map(t => (
  1225. <TenantTreeNode
  1226. key={t.id}
  1227. tenant={t}
  1228. selectedTenantId={selectedTenantId}
  1229. onSelect={(id) => {
  1230. if (id !== selectedTenantId) {
  1231. setSelectedTenantId(id);
  1232. setMemberPage(1);
  1233. setUserPage(1);
  1234. }
  1235. }}
  1236. onCreateSubtenant={(parentId) => {
  1237. setNewTenant({ name: '', domain: '', parentId });
  1238. setEditingTenant(null);
  1239. setShowCreateTenant(true);
  1240. }}
  1241. />
  1242. ))
  1243. ) : (
  1244. <div className="py-20 text-center">
  1245. <Building size={32} className="mx-auto text-slate-200 mb-3" />
  1246. <p className="text-xs font-black text-slate-300 uppercase tracking-widest">{t('noOrganizations')}</p>
  1247. </div>
  1248. )}
  1249. </div>
  1250. <div className="p-4 bg-slate-50 border-t border-slate-100 shrink-0">
  1251. <div className="flex items-center gap-3 p-3 bg-white border border-slate-200 rounded-2xl shadow-sm">
  1252. <div className="w-10 h-10 rounded-xl bg-blue-50 flex items-center justify-center text-blue-600">
  1253. <Building size={20} />
  1254. </div>
  1255. <div>
  1256. <p className="text-[10px] font-black text-slate-400 uppercase tracking-widest">{t('totalTenants')}</p>
  1257. <p className="text-xl font-black text-slate-900">{stats.tenants}</p>
  1258. </div>
  1259. </div>
  1260. </div>
  1261. </div>
  1262. {/* Right: User List & Management */}
  1263. <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">
  1264. {activeTenant ? (
  1265. <div className="flex flex-col h-full">
  1266. {/* Organization Header */}
  1267. <div className="p-8 border-b border-slate-100 bg-slate-50/50 flex items-center justify-between shrink-0">
  1268. <div className="flex items-center gap-4">
  1269. <div className="w-14 h-14 rounded-2xl bg-white border border-slate-200 shadow-sm flex items-center justify-center text-indigo-600">
  1270. <Building size={28} />
  1271. </div>
  1272. <div>
  1273. <div className="flex items-center gap-2">
  1274. <h2 className="text-2xl font-black text-slate-900 tracking-tight">{activeTenant.name}</h2>
  1275. <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>
  1276. </div>
  1277. <p className="text-xs font-medium text-slate-400">{activeTenant.domain || t('noCustomDomain')}</p>
  1278. </div>
  1279. </div>
  1280. <div className="flex items-center gap-2">
  1281. <button
  1282. onClick={() => {
  1283. setEditingTenant(activeTenant);
  1284. setNewTenant({
  1285. name: activeTenant.name,
  1286. domain: activeTenant.domain || '',
  1287. parentId: activeTenant.parentId || null
  1288. });
  1289. setShowCreateTenant(true);
  1290. }}
  1291. 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"
  1292. title={t('orgSettings')}
  1293. >
  1294. <SettingsIcon size={18} />
  1295. </button>
  1296. <button
  1297. onClick={() => handleRemoveTenant(activeTenant.id)}
  1298. 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"
  1299. title={t('deleteOrg')}
  1300. >
  1301. <Trash2 size={18} />
  1302. </button>
  1303. </div>
  1304. </div>
  1305. {/* Main Content Split: Members vs All Users */}
  1306. <div className="flex-1 flex overflow-hidden">
  1307. {/* Current Members */}
  1308. <div className="flex-1 flex flex-col border-r border-slate-100 overflow-hidden">
  1309. <div className="p-6 border-b border-slate-50 flex items-center justify-between shrink-0">
  1310. <h4 className="text-xs font-black text-slate-400 uppercase tracking-widest">{t('orgMembers')}</h4>
  1311. <span className="text-[10px] font-black px-2 py-0.5 bg-slate-100 text-slate-500 rounded-full">
  1312. {t('membersCount').replace('$1', (memberTotal || 0).toString())}
  1313. </span>
  1314. </div>
  1315. <div className="flex-1 overflow-y-auto p-6 scrollbar-hide">
  1316. <div className="grid grid-cols-1 gap-3">
  1317. {tenantMembers?.map((m: any) => (
  1318. <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">
  1319. <div className="flex items-center gap-3 min-w-0">
  1320. <div className="w-10 h-10 rounded-xl bg-white border border-slate-200 flex items-center justify-center text-slate-400 shadow-sm">
  1321. <User size={18} />
  1322. </div>
  1323. <div className="min-w-0">
  1324. <p className="text-sm font-black text-slate-900 truncate">{m.user?.username || m.userId}</p>
  1325. <select
  1326. value={m.role}
  1327. onChange={(e) => handleUpdateMemberRole(activeTenant.id, m.userId, e.target.value)}
  1328. 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'}`}
  1329. >
  1330. <option value="USER">{t('roleRegularUser')}</option>
  1331. <option value="TENANT_ADMIN">{t('roleTenantAdmin')}</option>
  1332. </select>
  1333. </div>
  1334. </div>
  1335. <button
  1336. onClick={() => handleRemoveMember(activeTenant.id, m.userId)}
  1337. className="p-2 text-slate-300 hover:text-red-500 opacity-0 group-hover:opacity-100 transition-all"
  1338. >
  1339. <Trash2 size={14} />
  1340. </button>
  1341. </div>
  1342. ))}
  1343. {(!tenantMembers || tenantMembers.length === 0) && (
  1344. <div className="py-20 text-center">
  1345. <Users size={24} className="mx-auto text-slate-200 mb-2" />
  1346. <p className="text-[10px] font-bold text-slate-300 uppercase tracking-wider">{t('noMembersAssigned')}</p>
  1347. </div>
  1348. )}
  1349. </div>
  1350. <Pagination
  1351. current={memberPage}
  1352. total={memberTotal}
  1353. pageSize={MEMBER_PAGE_SIZE}
  1354. onChange={setMemberPage}
  1355. />
  1356. </div>
  1357. </div>
  1358. {/* Add New Users (Right side of specific tenant view) */}
  1359. <div className="w-72 flex flex-col bg-slate-50/30 overflow-hidden shrink-0">
  1360. <div className="p-6 border-b border-slate-100 shrink-0">
  1361. <h4 className="text-xs font-black text-slate-900 uppercase tracking-widest mb-3">{t('addMembers')}</h4>
  1362. <div className="relative">
  1363. <Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={14} />
  1364. <input
  1365. 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"
  1366. placeholder={t('searchSystemUsers')}
  1367. value={userSearchQuery}
  1368. onChange={e => setUserSearchQuery(e.target.value)}
  1369. />
  1370. </div>
  1371. <div className="mt-3 flex gap-1 p-1 bg-white border border-slate-200 rounded-xl">
  1372. <button
  1373. onClick={() => setBindingRole('USER')}
  1374. className={`flex-1 py-1.5 text-[10px] 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'}`}
  1375. >
  1376. {t('roleRegularUser')}
  1377. </button>
  1378. <button
  1379. onClick={() => setBindingRole('TENANT_ADMIN')}
  1380. className={`flex-1 py-1.5 text-[10px] 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'}`}
  1381. >
  1382. {t('roleTenantAdmin')}
  1383. </button>
  1384. </div>
  1385. </div>
  1386. <div className="flex-1 overflow-y-auto p-4 space-y-2">
  1387. {users
  1388. .filter(u =>
  1389. u.username !== 'admin' &&
  1390. u.username.toLowerCase().includes(userSearchQuery.toLowerCase())
  1391. )
  1392. .map(u => {
  1393. const isAlreadyMember = allMemberIds.has(u.id);
  1394. return (
  1395. <button
  1396. key={u.id}
  1397. onClick={() => !isAlreadyMember && handleAddMember(activeTenant.id, u.id, bindingRole)}
  1398. disabled={isAlreadyMember}
  1399. className={`w-full p-3 border rounded-xl flex items-center justify-between group transition-all ${isAlreadyMember
  1400. ? 'bg-slate-50 border-slate-100 cursor-not-allowed opacity-60'
  1401. : 'bg-white border-slate-100 hover:border-indigo-500 hover:shadow-sm'
  1402. }`}
  1403. >
  1404. <div className="flex items-center gap-2 min-w-0">
  1405. <div className={`w-8 h-8 rounded-lg flex items-center justify-center transition-colors ${isAlreadyMember
  1406. ? 'bg-slate-100 text-slate-300'
  1407. : 'bg-slate-50 text-slate-400 group-hover:bg-indigo-50 group-hover:text-indigo-600'
  1408. }`}>
  1409. <User size={14} />
  1410. </div>
  1411. <span className={`text-[13px] font-bold truncate ${isAlreadyMember ? 'text-slate-400' : 'text-slate-700'}`}>
  1412. {u.username}
  1413. </span>
  1414. </div>
  1415. {isAlreadyMember ? (
  1416. <Check size={14} className="text-emerald-500" />
  1417. ) : (
  1418. <Plus size={14} className="text-slate-300 group-hover:text-indigo-600" />
  1419. )}
  1420. </button>
  1421. );
  1422. })
  1423. }
  1424. <Pagination
  1425. current={userPage}
  1426. total={userTotal}
  1427. pageSize={USER_PAGE_SIZE}
  1428. onChange={setUserPage}
  1429. />
  1430. </div>
  1431. </div>
  1432. </div>
  1433. {/* Create Tenant Modal (Nested in Tab Content for scope) */}
  1434. {showCreateTenant && (
  1435. <div className="fixed inset-0 z-[120] bg-slate-900/40 backdrop-blur-sm flex items-center justify-center p-4 text-left">
  1436. <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">
  1437. <h3 className="text-xl font-black text-slate-900 mb-6">{editingTenant ? t('editOrg') : t('newTenant')}</h3>
  1438. <form onSubmit={handleCreateTenant} className="space-y-5">
  1439. <div>
  1440. <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1">{t('tenantName')}</label>
  1441. <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 />
  1442. </div>
  1443. <div>
  1444. <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1">{t('domainOptional')}</label>
  1445. <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 })} />
  1446. </div>
  1447. <div>
  1448. <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1">{t('parentOrg')}</label>
  1449. {!editingTenant ? (
  1450. <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">
  1451. {newTenant.parentId ? tenants.find(t => t.id === newTenant.parentId)?.name : t('noneRoot')}
  1452. </div>
  1453. ) : (
  1454. <select
  1455. 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"
  1456. value={newTenant.parentId || ''}
  1457. onChange={e => setNewTenant({ ...newTenant, parentId: e.target.value || null })}
  1458. >
  1459. <option value="">{t('noneRoot')}</option>
  1460. {tenants.filter(t => t.id !== editingTenant?.id).map(t => (
  1461. <option key={t.id} value={t.id}>{t.name}</option>
  1462. ))}
  1463. </select>
  1464. )}
  1465. </div>
  1466. <div className="flex gap-4 pt-2">
  1467. <button type="button" onClick={() => { setShowCreateTenant(false); setEditingTenant(null); }} className="flex-1 py-3 text-slate-500 font-bold text-sm">{t('cancel')}</button>
  1468. <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>
  1469. </div>
  1470. </form>
  1471. </motion.div>
  1472. </div>
  1473. )}
  1474. </div>
  1475. ) : (
  1476. <div className="flex-1 flex flex-col items-center justify-center p-12 text-center bg-slate-50/30">
  1477. <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">
  1478. <Building size={48} />
  1479. </div>
  1480. <h2 className="text-2xl font-black text-slate-900 tracking-tight mb-2">{t('selectOrg')}</h2>
  1481. <p className="text-sm text-slate-400 max-w-xs font-medium leading-relaxed">{t('selectOrgDesc')}</p>
  1482. <div className="mt-12 grid grid-cols-2 gap-4 w-full max-w-lg">
  1483. <div className="p-6 bg-white border border-slate-200 rounded-3xl text-left shadow-sm">
  1484. <div className="w-10 h-10 rounded-xl bg-orange-50 flex items-center justify-center text-orange-600 mb-4">
  1485. <Users size={20} />
  1486. </div>
  1487. <p className="text-xl font-black text-slate-900">{stats.users}</p>
  1488. <p className="text-[10px] font-bold text-slate-400 uppercase tracking-widest mt-1">{t('totalSystemUsers')}</p>
  1489. </div>
  1490. <div className="p-6 bg-white border border-slate-200 rounded-3xl text-left shadow-sm">
  1491. <div className="w-10 h-10 rounded-xl bg-emerald-50 flex items-center justify-center text-emerald-600 mb-4">
  1492. <Shield size={20} />
  1493. </div>
  1494. <p className="text-xl font-black text-slate-900">{tenants.filter(t => t.parentId === null).length}</p>
  1495. <p className="text-[10px] font-bold text-slate-400 uppercase tracking-widest mt-1">{t('rootOrgs')}</p>
  1496. </div>
  1497. </div>
  1498. {/* Scope Modal for Create even when no selection */}
  1499. {showCreateTenant && (
  1500. <div className="fixed inset-0 z-[120] bg-slate-900/40 backdrop-blur-sm flex items-center justify-center p-4 text-left">
  1501. <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">
  1502. <h3 className="text-xl font-black text-slate-900 mb-6">{t('newTenant')}</h3>
  1503. <form onSubmit={handleCreateTenant} className="space-y-5">
  1504. <div>
  1505. <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1">{t('tenantName')}</label>
  1506. <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 />
  1507. </div>
  1508. <div>
  1509. <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1">{t('domainOptional')}</label>
  1510. <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 })} />
  1511. </div>
  1512. <div>
  1513. <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1">{t('parentOrg')}</label>
  1514. <select
  1515. 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"
  1516. value={newTenant.parentId || ''}
  1517. onChange={e => setNewTenant({ ...newTenant, parentId: e.target.value || null })}
  1518. >
  1519. <option value="">{t('noneRoot')}</option>
  1520. {tenants.map(t => (
  1521. <option key={t.id} value={t.id}>{t.name}</option>
  1522. ))}
  1523. </select>
  1524. </div>
  1525. <div className="flex gap-4 pt-2">
  1526. <button
  1527. type="button"
  1528. onClick={() => {
  1529. setShowCreateTenant(false);
  1530. setNewTenant({ name: '', domain: '', parentId: null });
  1531. }}
  1532. className="flex-1 py-3 text-slate-500 font-bold text-sm"
  1533. >
  1534. {t('cancel')}
  1535. </button>
  1536. <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>
  1537. </div>
  1538. </form>
  1539. </motion.div>
  1540. </div>
  1541. )}
  1542. </div>
  1543. )}
  1544. </div>
  1545. </div>
  1546. );
  1547. };
  1548. const renderKnowledgeBaseTab = () => (
  1549. <div className="space-y-8 animate-in slide-in-from-right duration-300 w-full max-w-5xl pb-10">
  1550. {localKbSettings ? (
  1551. <>
  1552. {/* Save/Cancel Bar */}
  1553. <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">
  1554. <button
  1555. onClick={handleCancelKbSettings}
  1556. disabled={isSavingKbSettings || JSON.stringify(localKbSettings) === JSON.stringify(kbSettings)}
  1557. className="px-6 py-2 text-sm font-bold text-slate-500 hover:text-slate-700 disabled:opacity-30"
  1558. >
  1559. {t('cancel')}
  1560. </button>
  1561. <button
  1562. onClick={handleSaveKbSettings}
  1563. disabled={isSavingKbSettings || JSON.stringify(localKbSettings) === JSON.stringify(kbSettings)}
  1564. 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"
  1565. >
  1566. {isSavingKbSettings ? <Loader2 size={16} className="animate-spin" /> : <Save size={16} />}
  1567. {t('saveChanges')}
  1568. </button>
  1569. </div>
  1570. {/* Model Configuration */}
  1571. <section className="bg-white/80 backdrop-blur-md p-8 rounded-3xl border border-slate-200/50 shadow-sm space-y-6">
  1572. <div className="flex items-center gap-3 text-slate-900 font-black uppercase tracking-widest text-[11px] border-b border-slate-100 pb-4">
  1573. <div className="w-8 h-8 rounded-xl bg-indigo-50 flex items-center justify-center text-indigo-600">
  1574. <Cpu size={16} />
  1575. </div>
  1576. {t('modelConfiguration')}
  1577. </div>
  1578. <div className="grid grid-cols-1 gap-6">
  1579. <div>
  1580. <label className="block text-[10px] font-black text-slate-400 uppercase tracking-widest mb-2 px-1">{t('defaultLLMModel')}</label>
  1581. <select
  1582. value={localKbSettings.selectedLLMId || ''}
  1583. onChange={(e) => handleUpdateKbSettings('selectedLLMId', e.target.value)}
  1584. 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"
  1585. >
  1586. <option value="">--- {t('selectLLMModel')} ---</option>
  1587. {models.filter(m => m.type === ModelType.LLM).map(m => (
  1588. <option key={m.id} value={m.id}>{m.name} ({m.modelId})</option>
  1589. ))}
  1590. </select>
  1591. </div>
  1592. <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
  1593. <div>
  1594. <label className="block text-[10px] font-black text-slate-400 uppercase tracking-widest mb-2 px-1">{t('embeddingModel')}</label>
  1595. <select
  1596. value={localKbSettings.selectedEmbeddingId || ''}
  1597. onChange={(e) => handleUpdateKbSettings('selectedEmbeddingId', e.target.value)}
  1598. 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"
  1599. >
  1600. <option value="">--- {t('selectEmbeddingModel')} ---</option>
  1601. {models.filter(m => m.type === ModelType.EMBEDDING).map(m => (
  1602. <option key={m.id} value={m.id}>{m.name}</option>
  1603. ))}
  1604. </select>
  1605. </div>
  1606. <div>
  1607. <label className="block text-[10px] font-black text-slate-400 uppercase tracking-widest mb-2 px-1">{t('rerankModel')}</label>
  1608. <select
  1609. value={localKbSettings.selectedRerankId || ''}
  1610. onChange={(e) => handleUpdateKbSettings('selectedRerankId', e.target.value)}
  1611. 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"
  1612. >
  1613. <option value="">--- {t('selectRerankModel')} ---</option>
  1614. {models.filter(m => m.type === ModelType.RERANK).map(m => (
  1615. <option key={m.id} value={m.id}>{m.name}</option>
  1616. ))}
  1617. </select>
  1618. </div>
  1619. <div>
  1620. <label className="block text-[10px] font-black text-slate-400 uppercase tracking-widest mb-2 px-1">
  1621. {t('defaultVisionModel')}
  1622. <span className="ml-1 text-[8px] opacity-60">({t('typeVision')})</span>
  1623. </label>
  1624. <select
  1625. value={localKbSettings.selectedVisionId || ''}
  1626. onChange={(e) => handleUpdateKbSettings('selectedVisionId', e.target.value)}
  1627. 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"
  1628. >
  1629. <option value="">--- {t('selectVisionModel')} ---</option>
  1630. {models.filter(m => m.type === ModelType.VISION || m.supportsVision).map(m => (
  1631. <option key={m.id} value={m.id}>{m.name}</option>
  1632. ))}
  1633. </select>
  1634. </div>
  1635. </div>
  1636. </div>
  1637. </section>
  1638. {/* Indexing & Chunking Configuration */}
  1639. <section className="bg-white/80 backdrop-blur-md p-8 rounded-3xl border border-slate-200/50 shadow-sm space-y-6">
  1640. <div className="flex items-center gap-3 text-slate-900 font-black uppercase tracking-widest text-[11px] border-b border-slate-100 pb-4">
  1641. <div className="w-8 h-8 rounded-xl bg-orange-50 flex items-center justify-center text-orange-600">
  1642. <BookOpen size={16} />
  1643. </div>
  1644. {t('indexingChunkingConfig')}
  1645. </div>
  1646. <div className="grid grid-cols-1 md:grid-cols-2 gap-8">
  1647. <div>
  1648. <div className="flex justify-between mb-3 px-1">
  1649. <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest">{t('chunkSize')}</label>
  1650. <span className="text-sm font-black text-indigo-600 bg-indigo-50 px-2 py-0.5 rounded-lg">{localKbSettings.chunkSize || 1000}</span>
  1651. </div>
  1652. <input
  1653. type="range"
  1654. min="100"
  1655. max="8192"
  1656. step="100"
  1657. value={localKbSettings.chunkSize || 1000}
  1658. onChange={(e) => handleUpdateKbSettings('chunkSize', parseInt(e.target.value))}
  1659. className="w-full h-2 bg-slate-100 rounded-lg appearance-none cursor-pointer accent-indigo-600"
  1660. />
  1661. </div>
  1662. <div>
  1663. <div className="flex justify-between mb-3 px-1">
  1664. <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest">{t('chunkOverlap')}</label>
  1665. <span className="text-sm font-black text-indigo-600 bg-indigo-50 px-2 py-0.5 rounded-lg">{localKbSettings.chunkOverlap || 100}</span>
  1666. </div>
  1667. <input
  1668. type="range"
  1669. min="0"
  1670. max="2048"
  1671. step="10"
  1672. value={localKbSettings.chunkOverlap || 100}
  1673. onChange={(e) => handleUpdateKbSettings('chunkOverlap', parseInt(e.target.value))}
  1674. className="w-full h-2 bg-slate-100 rounded-lg appearance-none cursor-pointer accent-indigo-600"
  1675. />
  1676. </div>
  1677. </div>
  1678. </section>
  1679. {/* Chat Hyperparameters */}
  1680. <section className="bg-white/80 backdrop-blur-md p-8 rounded-3xl border border-slate-200/50 shadow-sm space-y-6">
  1681. <div className="flex items-center gap-3 text-slate-900 font-black uppercase tracking-widest text-[11px] border-b border-slate-100 pb-4">
  1682. <div className="w-8 h-8 rounded-xl bg-pink-50 flex items-center justify-center text-pink-600">
  1683. <Sparkles size={16} />
  1684. </div>
  1685. {t('chatHyperparameters')}
  1686. </div>
  1687. <div className="space-y-8">
  1688. <div>
  1689. <div className="flex justify-between mb-3 px-1">
  1690. <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest">{t('temperature')}</label>
  1691. <span className="text-sm font-black text-indigo-600 bg-indigo-50 px-2 py-0.5 rounded-lg">{localKbSettings.temperature}</span>
  1692. </div>
  1693. <input
  1694. type="range"
  1695. min="0"
  1696. max="1"
  1697. step="0.1"
  1698. value={localKbSettings.temperature || 0.7}
  1699. onChange={(e) => handleUpdateKbSettings('temperature', parseFloat(e.target.value))}
  1700. className="w-full h-2 bg-slate-100 rounded-lg appearance-none cursor-pointer accent-indigo-600"
  1701. />
  1702. <div className="flex justify-between mt-2 px-1 text-[9px] font-bold text-slate-300 uppercase tracking-tighter">
  1703. <span>{t('precise')}</span>
  1704. <span>{t('creative')}</span>
  1705. </div>
  1706. </div>
  1707. <div>
  1708. <label className="block text-[10px] font-black text-slate-400 uppercase tracking-widest mb-2 px-1">{t('maxResponseTokens')}</label>
  1709. <input
  1710. type="number"
  1711. value={localKbSettings.maxTokens || 2000}
  1712. onChange={(e) => handleUpdateKbSettings('maxTokens', parseInt(e.target.value))}
  1713. 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"
  1714. />
  1715. </div>
  1716. </div>
  1717. </section>
  1718. {/* Retrieval & Search Settings */}
  1719. <section className="bg-white/80 backdrop-blur-md p-8 rounded-3xl border border-slate-200/50 shadow-sm space-y-6">
  1720. <div className="flex items-center gap-3 text-slate-900 font-black uppercase tracking-widest text-[11px] border-b border-slate-100 pb-4">
  1721. <div className="w-8 h-8 rounded-xl bg-emerald-50 flex items-center justify-center text-emerald-600">
  1722. <Database size={16} />
  1723. </div>
  1724. {t('retrievalSearchSettings')}
  1725. </div>
  1726. <div className="space-y-8">
  1727. <div className="grid grid-cols-1 md:grid-cols-2 gap-8">
  1728. <div>
  1729. <div className="flex justify-between mb-3 px-1">
  1730. <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest">{t('topK')}</label>
  1731. <span className="text-sm font-black text-indigo-600 bg-indigo-50 px-2 py-0.5 rounded-lg">{localKbSettings.topK}</span>
  1732. </div>
  1733. <input
  1734. type="range"
  1735. min="1"
  1736. max="50"
  1737. step="1"
  1738. value={localKbSettings.topK || 10}
  1739. onChange={(e) => handleUpdateKbSettings('topK', parseInt(e.target.value))}
  1740. className="w-full h-2 bg-slate-100 rounded-lg appearance-none cursor-pointer accent-indigo-600"
  1741. />
  1742. </div>
  1743. <div>
  1744. <div className="flex justify-between mb-3 px-1">
  1745. <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest">{t('similarityThreshold')}</label>
  1746. <span className="text-sm font-black text-indigo-600 bg-indigo-50 px-2 py-0.5 rounded-lg">{localKbSettings.similarityThreshold}</span>
  1747. </div>
  1748. <input
  1749. type="range"
  1750. min="0"
  1751. max="1"
  1752. step="0.05"
  1753. value={localKbSettings.similarityThreshold || 0.5}
  1754. onChange={(e) => handleUpdateKbSettings('similarityThreshold', parseFloat(e.target.value))}
  1755. className="w-full h-2 bg-slate-100 rounded-lg appearance-none cursor-pointer accent-indigo-600"
  1756. />
  1757. </div>
  1758. </div>
  1759. <div className="space-y-4 pt-4 border-t border-slate-100">
  1760. <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">
  1761. <div>
  1762. <div className="text-sm font-bold text-slate-800">{t('enableHybridSearch')}</div>
  1763. <div className="text-[10px] text-slate-400 font-medium">{t('hybridSearchDesc')}</div>
  1764. </div>
  1765. <button
  1766. onClick={() => handleUpdateKbSettings('enableFullTextSearch', !localKbSettings.enableFullTextSearch)}
  1767. 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'}`}
  1768. >
  1769. <span className={`${localKbSettings.enableFullTextSearch ? 'translate-x-6' : 'translate-x-1'} inline-block h-4 w-4 transform rounded-full bg-white transition-transform`} />
  1770. </button>
  1771. </div>
  1772. {localKbSettings.enableFullTextSearch && (
  1773. <motion.div
  1774. initial={{ opacity: 0, y: -10 }}
  1775. animate={{ opacity: 1, y: 0 }}
  1776. className="p-5 bg-indigo-50/30 rounded-2xl border border-indigo-100/50 space-y-4"
  1777. >
  1778. <div className="flex justify-between mb-2 px-1">
  1779. <label className="text-[10px] font-black text-indigo-400 uppercase tracking-widest">{t('hybridWeight')}</label>
  1780. <span className="text-sm font-black text-indigo-600">{localKbSettings.hybridVectorWeight || 0.5}</span>
  1781. </div>
  1782. <input
  1783. type="range"
  1784. min="0"
  1785. max="1"
  1786. step="0.05"
  1787. value={localKbSettings.hybridVectorWeight || 0.5}
  1788. onChange={(e) => handleUpdateKbSettings('hybridVectorWeight', parseFloat(e.target.value))}
  1789. className="w-full h-2 bg-indigo-100 rounded-lg appearance-none cursor-pointer accent-indigo-600"
  1790. />
  1791. <div className="flex justify-between mt-1 px-1 text-[9px] font-bold text-indigo-300 uppercase">
  1792. <span>{t('pureText')}</span>
  1793. <span>{t('pureVector')}</span>
  1794. </div>
  1795. </motion.div>
  1796. )}
  1797. <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
  1798. <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">
  1799. <div>
  1800. <div className="text-sm font-bold text-slate-800">{t('enableQueryExpansion')}</div>
  1801. <div className="text-[10px] text-slate-400 font-medium">{t('queryExpansionDesc')}</div>
  1802. </div>
  1803. <button
  1804. onClick={() => handleUpdateKbSettings('enableQueryExpansion', !localKbSettings.enableQueryExpansion)}
  1805. 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'}`}
  1806. >
  1807. <span className={`${localKbSettings.enableQueryExpansion ? 'translate-x-6' : 'translate-x-1'} inline-block h-4 w-4 transform rounded-full bg-white transition-transform`} />
  1808. </button>
  1809. </div>
  1810. <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">
  1811. <div>
  1812. <div className="text-sm font-bold text-slate-800">{t('enableHyDE')}</div>
  1813. <div className="text-[10px] text-slate-400 font-medium">{t('hydeDesc')}</div>
  1814. </div>
  1815. <button
  1816. onClick={() => handleUpdateKbSettings('enableHyDE', !localKbSettings.enableHyDE)}
  1817. 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'}`}
  1818. >
  1819. <span className={`${localKbSettings.enableHyDE ? 'translate-x-6' : 'translate-x-1'} inline-block h-4 w-4 transform rounded-full bg-white transition-transform`} />
  1820. </button>
  1821. </div>
  1822. </div>
  1823. <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">
  1824. <div>
  1825. <div className="text-sm font-bold text-slate-800">{t('enableReranking')}</div>
  1826. <div className="text-[10px] text-slate-400 font-medium">{t('rerankingDesc')}</div>
  1827. </div>
  1828. <button
  1829. onClick={() => handleUpdateKbSettings('enableRerank', !localKbSettings.enableRerank)}
  1830. 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'}`}
  1831. >
  1832. <span className={`${localKbSettings.enableRerank ? 'translate-x-6' : 'translate-x-1'} inline-block h-4 w-4 transform rounded-full bg-white transition-transform`} />
  1833. </button>
  1834. </div>
  1835. {localKbSettings.enableRerank && (
  1836. <motion.div
  1837. initial={{ opacity: 0, y: -10 }}
  1838. animate={{ opacity: 1, y: 0 }}
  1839. className="p-5 bg-indigo-50/30 rounded-2xl border border-indigo-100/50 space-y-4"
  1840. >
  1841. <div className="flex justify-between mb-2 px-1">
  1842. <label className="text-[10px] font-black text-indigo-400 uppercase tracking-widest">{t('rerankSimilarityThreshold')}</label>
  1843. <span className="text-sm font-black text-indigo-600">{localKbSettings.rerankSimilarityThreshold || 0.5}</span>
  1844. </div>
  1845. <input
  1846. type="range"
  1847. min="0"
  1848. max="1"
  1849. step="0.05"
  1850. value={localKbSettings.rerankSimilarityThreshold || 0.5}
  1851. onChange={(e) => handleUpdateKbSettings('rerankSimilarityThreshold', parseFloat(e.target.value))}
  1852. className="w-full h-2 bg-indigo-100 rounded-lg appearance-none cursor-pointer accent-indigo-600"
  1853. />
  1854. <div className="flex justify-between mt-1 px-1 text-[9px] font-bold text-indigo-300 uppercase">
  1855. <span>{t('broad')}</span>
  1856. <span>{t('strict')}</span>
  1857. </div>
  1858. </motion.div>
  1859. )}
  1860. </div>
  1861. </div>
  1862. </section>
  1863. </>
  1864. ) : (
  1865. <div className="flex flex-col items-center justify-center py-20 space-y-4">
  1866. <Loader2 size={40} className="animate-spin text-indigo-600 opacity-20" />
  1867. <p className="text-sm font-medium text-slate-400 animate-pulse">{t('loading')}</p>
  1868. </div>
  1869. )}
  1870. </div>
  1871. );
  1872. const renderModelTab = () => (
  1873. <div className="w-full space-y-6">
  1874. <div className="flex justify-between items-center mb-6">
  1875. <div>
  1876. </div>
  1877. {!editingId && currentUser?.role === 'SUPER_ADMIN' && (
  1878. <button
  1879. onClick={() => { setEditingId('new'); setModelFormData({ type: ModelType.LLM, baseUrl: 'http://localhost:11434/v1', modelId: 'llama3', name: '', dimensions: 1536 }); }}
  1880. 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"
  1881. >
  1882. <Plus className="w-4 h-4" />
  1883. {t('mmAddBtn')}
  1884. </button>
  1885. )}
  1886. </div>
  1887. {editingId ? (
  1888. <motion.div
  1889. initial={{ opacity: 0, y: 10 }}
  1890. animate={{ opacity: 1, y: 0 }}
  1891. className="bg-white/90 backdrop-blur-md p-10 rounded-3xl border border-slate-200/50 shadow-xl space-y-8"
  1892. >
  1893. <div className="flex items-center gap-4 mb-2">
  1894. <div className="w-12 h-12 rounded-2xl bg-indigo-50 flex items-center justify-center text-indigo-600">
  1895. <Cpu className="w-6 h-6" />
  1896. </div>
  1897. <h3 className="text-xl font-black text-slate-900 tracking-tight">{editingId === 'new' ? t('mmAddBtn') : t('mmEdit')}</h3>
  1898. </div>
  1899. <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
  1900. <div className="space-y-2">
  1901. <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1">{t('mmFormName')} *</label>
  1902. <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} />
  1903. </div>
  1904. <div className="space-y-2">
  1905. <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1">{t('mmFormModelId')} *</label>
  1906. <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} />
  1907. </div>
  1908. </div>
  1909. <div className="space-y-2">
  1910. <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1">{t('mmFormType')} *</label>
  1911. <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}>
  1912. <option value={ModelType.LLM}>{t('typeLLM')}</option>
  1913. <option value={ModelType.EMBEDDING}>{t('typeEmbedding')}</option>
  1914. <option value={ModelType.RERANK}>{t('typeRerank')}</option>
  1915. <option value={ModelType.VISION}>{t('typeVision')}</option>
  1916. </select>
  1917. </div>
  1918. <div className="space-y-2">
  1919. <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1">{t('mmFormBaseUrl')} *</label>
  1920. <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} />
  1921. </div>
  1922. <div className="space-y-2">
  1923. <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1">{t('mmFormApiKey')}</label>
  1924. <input
  1925. type="password"
  1926. 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"
  1927. value={modelFormData.apiKey || ''}
  1928. onChange={e => setModelFormData({ ...modelFormData, apiKey: e.target.value })}
  1929. disabled={isLoading}
  1930. placeholder={t('mmFormApiKeyPlaceholder')}
  1931. />
  1932. </div>
  1933. {modelFormData.type === ModelType.EMBEDDING && (
  1934. <div className="grid grid-cols-2 gap-6 p-6 bg-slate-50 rounded-3xl border border-slate-200/50">
  1935. <div className="space-y-2">
  1936. <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1">{t('maxInput')}</label>
  1937. <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) })} />
  1938. </div>
  1939. <div className="space-y-2">
  1940. <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1">{t('dimensions')}</label>
  1941. <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) })} />
  1942. </div>
  1943. </div>
  1944. )}
  1945. <div className="flex justify-end gap-3 pt-4">
  1946. <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>
  1947. <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}>
  1948. {isLoading && <Loader2 className="w-4 h-4 animate-spin" />}
  1949. {t('mmSave')}
  1950. </button>
  1951. </div>
  1952. </motion.div>
  1953. ) : (
  1954. <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">
  1955. <AnimatePresence>
  1956. {models.map((model, index) => (
  1957. <motion.div
  1958. key={model.id}
  1959. initial={{ opacity: 0, scale: 0.95 }}
  1960. animate={{ opacity: 1, scale: 1 }}
  1961. transition={{ delay: index * 0.05 }}
  1962. 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"
  1963. >
  1964. {/* Subtle background pattern/glow */}
  1965. <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" />
  1966. <div className="relative z-10">
  1967. <div className="flex items-start justify-between mb-5">
  1968. <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'}`}>
  1969. <Cpu size={26} strokeWidth={2.5} />
  1970. </div>
  1971. <div className="flex gap-1 items-center bg-slate-50 p-1 rounded-xl border border-slate-100/50">
  1972. <button
  1973. onClick={() => handleToggleModel(model)}
  1974. 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'}`}
  1975. title={((currentUser?.role === 'SUPER_ADMIN' ? !!model.isEnabled : enabledModelIds.includes(model.id))) ? t('modelEnabled') : t('modelDisabled')}
  1976. >
  1977. {((currentUser?.role === 'SUPER_ADMIN' ? !!model.isEnabled : enabledModelIds.includes(model.id))) ? <ToggleRight size={24} /> : <ToggleLeft size={24} />}
  1978. </button>
  1979. </div>
  1980. </div>
  1981. <div className="space-y-1 mb-6">
  1982. <div className="flex items-center gap-2.5">
  1983. <h4 className="font-black text-slate-900 text-lg tracking-tight truncate">{model.name}</h4>
  1984. </div>
  1985. <div className="flex items-center gap-2">
  1986. <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">
  1987. {getTypeLabel(model.type)}
  1988. </span>
  1989. {model.isDefault && (
  1990. <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">
  1991. <Sparkles size={8} /> {t('defaultBadge')}
  1992. </span>
  1993. )}
  1994. </div>
  1995. <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">
  1996. {model.modelId}
  1997. </p>
  1998. </div>
  1999. {/* Additional info grid */}
  2000. <div className="grid grid-cols-2 gap-3 mb-6">
  2001. {model.type === ModelType.EMBEDDING && (
  2002. <>
  2003. <div className="bg-slate-50/50 p-2.5 rounded-2xl border border-slate-100/50">
  2004. <span className="block text-[8px] font-black text-slate-400 uppercase tracking-widest mb-0.5">{t('dims')}</span>
  2005. <span className="text-xs font-bold text-slate-700">{model.dimensions || '-'}</span>
  2006. </div>
  2007. <div className="bg-slate-50/50 p-2.5 rounded-2xl border border-slate-100/50">
  2008. <span className="block text-[8px] font-black text-slate-400 uppercase tracking-widest mb-0.5">{t('ctx')}</span>
  2009. <span className="text-xs font-bold text-slate-700">{model.maxInputTokens || '-'}</span>
  2010. </div>
  2011. </>
  2012. )}
  2013. {model.type === ModelType.LLM && (
  2014. <div className="col-span-2 bg-slate-50/50 p-2.5 rounded-2xl border border-slate-100/50 flex items-center justify-between">
  2015. <span className="text-[8px] font-black text-slate-400 uppercase tracking-widest">{t('baseApi')}</span>
  2016. <span className="text-[9px] font-mono font-medium text-slate-600 max-w-[140px] truncate">{model.baseUrl}</span>
  2017. </div>
  2018. )}
  2019. </div>
  2020. </div>
  2021. <div className="flex items-center justify-between pt-4 border-t border-slate-100/50 relative z-10">
  2022. <div className="flex items-center gap-1 text-[10px] font-bold text-slate-400">
  2023. <SettingsIcon size={12} />
  2024. {t('configured')}
  2025. </div>
  2026. <div className="flex gap-2">
  2027. {currentUser?.role === 'SUPER_ADMIN' && (
  2028. <>
  2029. <button
  2030. onClick={() => { setEditingId(model.id); setModelFormData({ ...model }); }}
  2031. 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"
  2032. >
  2033. <Edit2 size={15} />
  2034. </button>
  2035. <button
  2036. onClick={() => handleDeleteModel(model.id)}
  2037. 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"
  2038. >
  2039. <Trash2 size={15} />
  2040. </button>
  2041. </>
  2042. )}
  2043. </div>
  2044. </div>
  2045. </motion.div>
  2046. ))}
  2047. </AnimatePresence>
  2048. {models.length === 0 && (
  2049. <div className="bg-white/50 border-2 border-dashed border-slate-200 rounded-3xl p-16 text-center">
  2050. <Cpu className="w-12 h-12 text-slate-200 mx-auto mb-4" />
  2051. <p className="text-slate-400 font-bold uppercase tracking-widest text-xs">{t('mmEmpty')}</p>
  2052. </div>
  2053. )}
  2054. </div>
  2055. )}
  2056. </div>
  2057. );
  2058. return (
  2059. <div className="flex h-full bg-[#FCFDFF] overflow-hidden relative">
  2060. {/* Settings Sidebar */}
  2061. <div className="w-64 bg-slate-50/50 border-r border-slate-200/60 flex flex-col shrink-0 z-20">
  2062. <div className="p-6 pb-2">
  2063. <h2 className="text-lg font-bold text-slate-900 tracking-tight">{t('tabSettings')}</h2>
  2064. </div>
  2065. <div className="flex-1 overflow-y-auto p-3 space-y-1">
  2066. <button
  2067. onClick={() => setActiveTab('general')}
  2068. 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'
  2069. }`}
  2070. >
  2071. <SettingsIcon size={18} />
  2072. {t('generalSettings')}
  2073. </button>
  2074. <button
  2075. onClick={() => setActiveTab('api_key')}
  2076. className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-all ${activeTab === 'api_key' ? 'bg-white text-indigo-600 shadow-sm border border-slate-200/60' : 'text-slate-600 hover:bg-slate-100'
  2077. }`}
  2078. >
  2079. <Key size={18} />
  2080. API Key
  2081. </button>
  2082. {currentUser?.role === 'SUPER_ADMIN' && (
  2083. <button
  2084. onClick={() => setActiveTab('user')}
  2085. 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'
  2086. }`}
  2087. >
  2088. <UserCircle size={18} />
  2089. {t('userManagement')}
  2090. </button>
  2091. )}
  2092. {isAdmin && (
  2093. <>
  2094. <button
  2095. onClick={() => setActiveTab('model')}
  2096. 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'
  2097. }`}
  2098. >
  2099. <HardDrive size={18} />
  2100. {t('modelManagement')}
  2101. </button>
  2102. <button
  2103. onClick={() => setActiveTab('knowledge_base')}
  2104. 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'
  2105. }`}
  2106. >
  2107. <Database size={18} />
  2108. {t('sidebarTitle')}
  2109. </button>
  2110. </>
  2111. )}
  2112. {currentUser?.role === 'SUPER_ADMIN' && (
  2113. <button
  2114. onClick={() => setActiveTab('tenants')}
  2115. 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'
  2116. }`}
  2117. >
  2118. <LayoutGrid size={18} />
  2119. {t('navTenants')}
  2120. </button>
  2121. )}
  2122. </div>
  2123. </div>
  2124. {/* Content Area */}
  2125. <div className="flex-1 flex flex-col min-w-0 h-full overflow-hidden relative bg-white z-10">
  2126. <div className="px-8 pt-8 pb-6 flex items-start justify-between shrink-0">
  2127. <div>
  2128. <h1 className="text-2xl font-bold text-slate-900 leading-tight">
  2129. {activeTab === 'general' ? t('generalSettings') : activeTab === 'api_key' ? 'API Key 管理' : activeTab === 'user' ? t('userManagement') : activeTab === 'model' ? t('modelManagement') : activeTab === 'knowledge_base' ? t('sidebarTitle') : t('navTenants')}
  2130. </h1>
  2131. <p className="text-[15px] text-slate-500 mt-1">
  2132. {activeTab === 'general' ? t('generalSettingsSubtitle') : activeTab === 'api_key' ? '查看和管理您的 API Key' : activeTab === 'user' ? t('userManagementSubtitle') : activeTab === 'model' ? t('modelManagementSubtitle') : activeTab === 'knowledge_base' ? t('kbSettingsSubtitle') : t('tenantsSubtitle')}
  2133. </p>
  2134. </div>
  2135. </div>
  2136. <div className="flex-1 overflow-y-auto px-10 pb-10 scrollbar-hide">
  2137. <div className="w-full">
  2138. {error && (
  2139. <motion.div
  2140. initial={{ opacity: 0, y: -10 }}
  2141. animate={{ opacity: 1, y: 0 }}
  2142. 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"
  2143. >
  2144. <div className="w-8 h-8 rounded-full bg-red-100 flex items-center justify-center shrink-0">
  2145. <X className="w-4 h-4 text-red-600" />
  2146. </div>
  2147. <div>
  2148. <span className="font-black uppercase tracking-widest text-[10px] block mb-0.5">{t('errorLabel')}</span>
  2149. {error}
  2150. </div>
  2151. </motion.div>
  2152. )}
  2153. <AnimatePresence mode="wait">
  2154. <motion.div
  2155. key={activeTab}
  2156. initial={{ opacity: 0, x: 20 }}
  2157. animate={{ opacity: 1, x: 0 }}
  2158. exit={{ opacity: 0, x: -20 }}
  2159. transition={{ duration: 0.3 }}
  2160. >
  2161. {activeTab === 'general' && renderGeneralTab()}
  2162. {activeTab === 'api_key' && renderApiKeyTab()}
  2163. {activeTab === 'user' && currentUser?.role === 'SUPER_ADMIN' && renderUserTab()}
  2164. {activeTab === 'model' && isAdmin && renderModelTab()}
  2165. {activeTab === 'knowledge_base' && isAdmin && renderKnowledgeBaseTab()}
  2166. {activeTab === 'tenants' && currentUser?.role === 'SUPER_ADMIN' && renderTenantsTab()}
  2167. </motion.div>
  2168. </AnimatePresence>
  2169. </div>
  2170. </div>
  2171. </div>
  2172. </div>
  2173. );
  2174. };