feat: implement QuestionBank CRUD with pagination and template query
- Add pagination support to findAll (page, limit query params) - Add findByTemplateId method to service - Add GET /by-template/:templateId endpoint to controller - Service already includes CRUD for QuestionBank and QuestionBankItem
This commit is contained in:
@@ -0,0 +1,289 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
|
||||
import {
|
||||
MessageSquare,
|
||||
Database,
|
||||
Settings,
|
||||
LogOut,
|
||||
LayoutGrid,
|
||||
LayoutTemplate,
|
||||
Plus,
|
||||
Menu,
|
||||
BookOpen,
|
||||
Library,
|
||||
HardDrive,
|
||||
Building2,
|
||||
ChevronRight,
|
||||
Bot,
|
||||
Blocks,
|
||||
ClipboardCheck,
|
||||
BarChart3
|
||||
} from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { useLanguage } from '../../../contexts/LanguageContext';
|
||||
import { cn } from '../../utils/cn';
|
||||
|
||||
interface SidebarItemProps {
|
||||
icon: React.ElementType;
|
||||
label: string;
|
||||
path: string;
|
||||
isActive: boolean;
|
||||
onClick: (path: string) => void;
|
||||
badge?: number;
|
||||
}
|
||||
|
||||
const SidebarItem = ({ icon: Icon, label, path, isActive, onClick, badge }: SidebarItemProps) => (
|
||||
<button
|
||||
onClick={() => onClick(path)}
|
||||
className={cn(
|
||||
"w-full flex items-center justify-between px-3 py-2.5 rounded-lg transition-all duration-200 group",
|
||||
isActive
|
||||
? "bg-blue-50 text-blue-700 font-semibold"
|
||||
: "text-slate-500 hover:bg-slate-50 hover:text-slate-900 border border-transparent"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Icon size={18} className={cn("transition-colors", isActive ? "text-blue-600" : "text-slate-400 group-hover:text-slate-600")} />
|
||||
<span className="text-[14px]">{label}</span>
|
||||
</div>
|
||||
{badge !== undefined && badge > 0 && (
|
||||
<span className={cn(
|
||||
"px-2 py-0.5 text-[11px] font-bold rounded-full",
|
||||
isActive ? "bg-blue-100 text-blue-700" : "bg-slate-100 text-slate-400"
|
||||
)}>
|
||||
{badge}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
|
||||
const WorkspaceLayout: React.FC = () => {
|
||||
const { user, logout, availableTenants, activeTenant, switchTenant } = useAuth();
|
||||
const { t, language } = useLanguage();
|
||||
const isZh = language === 'zh';
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [isSidebarOpen, setIsSidebarOpen] = useState(true);
|
||||
const [showTenantMenu, setShowTenantMenu] = useState(false);
|
||||
|
||||
const handleNavClick = (path: string) => {
|
||||
navigate(path);
|
||||
};
|
||||
|
||||
const buildTenantTree = (memberships: typeof availableTenants) => {
|
||||
const map = new Map<string, any>();
|
||||
const roots: any[] = [];
|
||||
|
||||
memberships.forEach(m => {
|
||||
map.set(m.tenantId, { ...m, children: [] });
|
||||
});
|
||||
|
||||
memberships.forEach(m => {
|
||||
const node = map.get(m.tenantId)!;
|
||||
const parentId = m.tenant.parentId;
|
||||
if (parentId && map.has(parentId)) {
|
||||
const parent = map.get(parentId)!;
|
||||
parent.children.push(node);
|
||||
} else {
|
||||
roots.push(node);
|
||||
}
|
||||
});
|
||||
|
||||
return roots;
|
||||
};
|
||||
|
||||
const renderTenantItem = (membership: any, depth = 0) => (
|
||||
<React.Fragment key={membership.tenantId}>
|
||||
<button
|
||||
onClick={() => {
|
||||
switchTenant(membership.tenantId);
|
||||
setShowTenantMenu(false);
|
||||
}}
|
||||
className={cn(
|
||||
"w-full text-left px-4 py-3 rounded-xl flex items-center justify-between group transition-colors",
|
||||
activeTenant?.tenantId === membership.tenantId
|
||||
? "bg-blue-50 text-blue-700"
|
||||
: "hover:bg-slate-50 text-slate-600"
|
||||
)}
|
||||
style={{ paddingLeft: `${depth * 1.5 + 1}rem` }}
|
||||
>
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="text-sm font-bold truncate">{membership.tenant.name}</span>
|
||||
<span className="text-[10px] font-medium opacity-60 uppercase tracking-tight">{membership.role}</span>
|
||||
</div>
|
||||
{activeTenant?.tenantId === membership.tenantId && (
|
||||
<div className="w-1.5 h-1.5 bg-blue-600 rounded-full shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
{membership.children?.map((child: any) => renderTenantItem(child, depth + 1))}
|
||||
</React.Fragment>
|
||||
);
|
||||
|
||||
const tenantTree = buildTenantTree(availableTenants);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-[#FCFDFF] text-slate-900 font-sans selection:bg-blue-100 selection:text-blue-900">
|
||||
|
||||
{/* Sidebar */}
|
||||
<aside className={cn(
|
||||
"bg-white border-r border-slate-200/60 flex flex-col transition-all duration-300 overflow-hidden",
|
||||
isSidebarOpen ? "w-[260px]" : "w-0 border-r-0"
|
||||
)}>
|
||||
{/* Brand */}
|
||||
<div className="h-16 flex items-center px-6 border-b border-slate-100/50">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="w-8 h-8 bg-blue-600 rounded-lg flex items-center justify-center shadow-md shadow-blue-200 text-white font-bold shrink-0">
|
||||
<BookOpen size={18} />
|
||||
</div>
|
||||
<h1 className="text-xl font-bold tracking-tight text-slate-900">AuraK</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 px-4 py-6 space-y-6 overflow-y-auto">
|
||||
<div className="space-y-0.5">
|
||||
<SidebarItem
|
||||
icon={MessageSquare}
|
||||
label={t('navChat')}
|
||||
path="/chat"
|
||||
isActive={location.pathname.startsWith('/chat')}
|
||||
onClick={handleNavClick}
|
||||
/>
|
||||
<SidebarItem
|
||||
icon={Bot}
|
||||
label={t('navAgent')}
|
||||
path="/agents"
|
||||
isActive={location.pathname.startsWith('/agents')}
|
||||
onClick={handleNavClick}
|
||||
/>
|
||||
<SidebarItem
|
||||
icon={Blocks}
|
||||
label={t('navPlugin')}
|
||||
path="/plugins"
|
||||
isActive={location.pathname.startsWith('/plugins')}
|
||||
onClick={handleNavClick}
|
||||
/>
|
||||
|
||||
<SidebarItem
|
||||
icon={Database}
|
||||
label={t('navKnowledge')}
|
||||
path="/knowledge"
|
||||
isActive={location.pathname === '/knowledge' || location.pathname.startsWith('/knowledge/')}
|
||||
onClick={handleNavClick}
|
||||
/>
|
||||
<SidebarItem
|
||||
icon={BarChart3}
|
||||
label={isZh ? '评估统计' : 'Assessment Stats'}
|
||||
path="/assessment-stats"
|
||||
isActive={location.pathname === '/assessment-stats'}
|
||||
onClick={handleNavClick}
|
||||
/>
|
||||
{(activeTenant?.features?.isNotebookEnabled ?? true) && (
|
||||
<SidebarItem
|
||||
icon={BookOpen}
|
||||
label={t('navNotebook')}
|
||||
path="/notebook"
|
||||
isActive={location.pathname.startsWith('/notebook')}
|
||||
onClick={handleNavClick}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-0.5">
|
||||
<SidebarItem
|
||||
icon={Settings}
|
||||
label={t('tabSettings')}
|
||||
path="/settings"
|
||||
isActive={location.pathname.startsWith('/settings')}
|
||||
onClick={handleNavClick}
|
||||
/>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div className="p-4 border-t border-slate-100/80 mt-auto">
|
||||
<button
|
||||
onClick={logout}
|
||||
className="flex items-center gap-2 px-3 py-2 text-[13px] font-medium text-red-500 hover:bg-red-50 w-full rounded-lg transition-colors"
|
||||
>
|
||||
<LogOut size={14} />
|
||||
{t('logout')}
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main Content Area */}
|
||||
<main className="flex-1 flex flex-col min-w-0 overflow-hidden relative">
|
||||
{/* Top Header */}
|
||||
<header className="flex-none h-16 px-6 border-b border-slate-200/60 flex items-center justify-between bg-white/80 backdrop-blur-md relative z-40">
|
||||
<div className="flex items-center gap-4 flex-1">
|
||||
<button
|
||||
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
|
||||
className="p-2 text-slate-400 rounded-lg hover:bg-slate-100 transition-colors"
|
||||
>
|
||||
<Menu size={20} />
|
||||
</button>
|
||||
|
||||
{/* Tenant Switcher */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowTenantMenu(!showTenantMenu)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-slate-50 border border-slate-200 rounded-xl hover:bg-slate-100 transition-all group"
|
||||
>
|
||||
<Building2 size={16} className="text-blue-600" />
|
||||
<span className="text-sm font-bold text-slate-700">{activeTenant?.tenant?.name || t('selectOrganization')}</span>
|
||||
<ChevronRight size={14} className={cn("text-slate-400 transition-transform", showTenantMenu ? "rotate-90" : "")} />
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{showTenantMenu && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40" onClick={() => setShowTenantMenu(false)} />
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 10 }}
|
||||
className="absolute top-full mt-2 left-0 w-64 bg-white border border-slate-200 rounded-2xl shadow-xl z-50 overflow-hidden"
|
||||
>
|
||||
<div className="p-3 border-b border-slate-100">
|
||||
<span className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-2">{t('navTenants')}</span>
|
||||
</div>
|
||||
<div className="max-h-64 overflow-y-auto p-1">
|
||||
{tenantTree.map(membership => renderTenantItem(membership))}
|
||||
</div>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-3 px-3 py-1.5 bg-slate-50/50 border border-slate-100 rounded-2xl">
|
||||
<div className="flex items-center justify-center w-8 h-8 text-xs font-bold text-white bg-indigo-600 rounded-full shrink-0 shadow-sm">
|
||||
{user?.displayName?.[0]?.toUpperCase() || user?.username?.[0]?.toUpperCase() || 'A'}
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<p className="text-sm font-bold text-slate-900 truncate max-w-[120px]">
|
||||
{user?.displayName || user?.username}
|
||||
</p>
|
||||
<span className="text-[9px] font-black text-blue-600 uppercase tracking-tighter">
|
||||
{activeTenant?.role?.replace('_', ' ') || user?.role?.replace('_', ' ') || 'USER'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Page Content Rendered Here via Outlet */}
|
||||
<div className="flex-1 bg-[#FCFDFF] overflow-hidden flex flex-col relative">
|
||||
<div className="max-w-[1400px] w-full mx-auto h-full relative">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WorkspaceLayout;
|
||||
@@ -0,0 +1,509 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Plus, Trash2, Copy, Check, RefreshCcw, ToggleLeft, ToggleRight, ExternalLink, Wifi, WifiOff, Loader2 } from 'lucide-react';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { useLanguage } from '../../../contexts/LanguageContext';
|
||||
|
||||
interface FeishuBotInfo {
|
||||
id: string;
|
||||
appId: string;
|
||||
botName?: string;
|
||||
enabled: boolean;
|
||||
webhookUrl: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
type WsState = 'disconnected' | 'connecting' | 'connected' | 'error';
|
||||
|
||||
interface WsStatus {
|
||||
botId: string;
|
||||
state: WsState;
|
||||
connectedAt?: string;
|
||||
lastHeartbeat?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export const FeishuPluginConfig: React.FC = () => {
|
||||
const { apiKey } = useAuth();
|
||||
const { t } = useLanguage();
|
||||
const [bots, setBots] = useState<FeishuBotInfo[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||
const [wsStatuses, setWsStatuses] = useState<Record<string, WsStatus>>({});
|
||||
const [wsLoading, setWsLoading] = useState<Record<string, boolean>>({});
|
||||
const [form, setForm] = useState({
|
||||
appId: '',
|
||||
appSecret: '',
|
||||
botName: '',
|
||||
verificationToken: '',
|
||||
encryptKey: '',
|
||||
});
|
||||
|
||||
const fetchBots = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch('/api/feishu/bots', {
|
||||
headers: { 'x-api-key': apiKey },
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setBots(data);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch Feishu bots', e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [apiKey]);
|
||||
|
||||
const fetchWsStatuses = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/feishu/ws/status', {
|
||||
headers: { 'x-api-key': apiKey },
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
const map: Record<string, WsStatus> = {};
|
||||
for (const s of (data.connections ?? [])) {
|
||||
map[s.botId] = s;
|
||||
}
|
||||
setWsStatuses(map);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch WS statuses', e);
|
||||
}
|
||||
}, [apiKey]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchBots();
|
||||
}, [fetchBots]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchWsStatuses();
|
||||
}, [fetchWsStatuses]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!form.appId || !form.appSecret) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const res = await fetch('/api/feishu/bots', {
|
||||
method: 'POST',
|
||||
headers: { 'x-api-key': apiKey, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(form),
|
||||
});
|
||||
if (res.ok) {
|
||||
await fetchBots();
|
||||
setShowForm(false);
|
||||
setForm({ appId: '', appSecret: '', botName: '', verificationToken: '', encryptKey: '' });
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to create bot', e);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (botId: string) => {
|
||||
if (!window.confirm(t('feishuConfirmDelete'))) return;
|
||||
try {
|
||||
await fetch(`/api/feishu/bots/${botId}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'x-api-key': apiKey },
|
||||
});
|
||||
await fetchBots();
|
||||
} catch (e) {
|
||||
console.error('Failed to delete bot', e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggle = async (bot: FeishuBotInfo) => {
|
||||
try {
|
||||
await fetch(`/api/feishu/bots/${bot.id}/toggle`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'x-api-key': apiKey, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ enabled: !bot.enabled }),
|
||||
});
|
||||
await fetchBots();
|
||||
} catch (e) {
|
||||
console.error('Failed to toggle bot', e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleWsConnect = async (botId: string) => {
|
||||
setWsLoading((prev) => ({ ...prev, [botId]: true }));
|
||||
try {
|
||||
const res = await fetch(`/api/feishu/bots/${botId}/ws/connect`, {
|
||||
method: 'POST',
|
||||
headers: { 'x-api-key': apiKey },
|
||||
});
|
||||
if (res.ok) {
|
||||
setWsStatuses((prev) => ({
|
||||
...prev,
|
||||
[botId]: { botId, state: 'connecting' },
|
||||
}));
|
||||
// Poll for status after a short delay
|
||||
setTimeout(async () => {
|
||||
await fetchWsStatuses();
|
||||
setWsLoading((prev) => ({ ...prev, [botId]: false }));
|
||||
}, 2000);
|
||||
} else {
|
||||
setWsLoading((prev) => ({ ...prev, [botId]: false }));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to connect WS', e);
|
||||
setWsLoading((prev) => ({ ...prev, [botId]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleWsDisconnect = async (botId: string) => {
|
||||
setWsLoading((prev) => ({ ...prev, [botId]: true }));
|
||||
try {
|
||||
const res = await fetch(`/api/feishu/bots/${botId}/ws/disconnect`, {
|
||||
method: 'POST',
|
||||
headers: { 'x-api-key': apiKey },
|
||||
});
|
||||
if (res.ok) {
|
||||
setWsStatuses((prev) => ({
|
||||
...prev,
|
||||
[botId]: { botId, state: 'disconnected' },
|
||||
}));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to disconnect WS', e);
|
||||
} finally {
|
||||
setWsLoading((prev) => ({ ...prev, [botId]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
const copyWebhookUrl = (url: string, id: string) => {
|
||||
const fullUrl = `${window.location.origin}${url}`;
|
||||
navigator.clipboard.writeText(fullUrl);
|
||||
setCopiedId(id);
|
||||
setTimeout(() => setCopiedId(null), 2000);
|
||||
};
|
||||
|
||||
const getWsStateColor = (state: WsState) => {
|
||||
switch (state) {
|
||||
case 'connected': return 'text-emerald-500';
|
||||
case 'connecting': return 'text-amber-500';
|
||||
case 'error': return 'text-red-500';
|
||||
default: return 'text-slate-400';
|
||||
}
|
||||
};
|
||||
|
||||
const getWsStateBg = (state: WsState) => {
|
||||
switch (state) {
|
||||
case 'connected': return 'bg-emerald-50 text-emerald-700 border-emerald-200';
|
||||
case 'connecting': return 'bg-amber-50 text-amber-700 border-amber-200';
|
||||
case 'error': return 'bg-red-50 text-red-700 border-red-200';
|
||||
default: return 'bg-slate-50 text-slate-500 border-slate-200';
|
||||
}
|
||||
};
|
||||
|
||||
const getWsStateLabel = (state: WsState) => {
|
||||
switch (state) {
|
||||
case 'connected': return t('feishuWsConnected');
|
||||
case 'connecting': return t('feishuWsConnecting');
|
||||
case 'error': return t('feishuWsError');
|
||||
default: return t('feishuWsDisconnected');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-3xl">🪶</span>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-slate-900">{t('pluginFeishuName')}</h2>
|
||||
<p className="text-sm text-slate-500 mt-0.5">{t('pluginFeishuDesc')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => { fetchBots(); fetchWsStatuses(); }}
|
||||
className="p-2 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded-lg transition-colors"
|
||||
title={t('refresh')}
|
||||
>
|
||||
<RefreshCcw size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowForm(!showForm)}
|
||||
className="flex items-center gap-2 px-4 h-9 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg text-sm font-semibold transition-colors"
|
||||
>
|
||||
<Plus size={15} />
|
||||
{t('feishuAddBot')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Setup Guide */}
|
||||
<div className="bg-indigo-50 border border-indigo-200 rounded-xl p-4 mb-6 text-sm text-indigo-800">
|
||||
<p className="font-semibold mb-2">📌 {t('feishuSetupGuide')}</p>
|
||||
<ol className="list-decimal list-inside space-y-1 text-indigo-700">
|
||||
<li>{t('feishuStep1')}</li>
|
||||
<li>{t('feishuStep2')}</li>
|
||||
<li>{t('feishuStep3')}</li>
|
||||
<li>{t('feishuStep4')}</li>
|
||||
</ol>
|
||||
<a
|
||||
href="https://open.feishu.cn/document/faq/bot"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 mt-2 text-indigo-600 hover:underline font-medium"
|
||||
>
|
||||
{t('feishuDocs')} <ExternalLink size={12} />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Add Bot Form */}
|
||||
{showForm && (
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="bg-white border border-slate-200 rounded-xl p-6 mb-6 shadow-sm"
|
||||
>
|
||||
<h3 className="font-semibold text-slate-800 mb-4">{t('feishuAddBot')}</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-600 mb-1">
|
||||
App ID <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={form.appId}
|
||||
onChange={(e) => setForm((f) => ({ ...f, appId: e.target.value }))}
|
||||
placeholder="cli_xxxxxxxxxxxxxxxx"
|
||||
className="w-full h-9 px-3 border border-slate-200 rounded-lg text-sm focus:ring-2 focus:ring-indigo-500/20 focus:border-indigo-500 outline-none transition"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-600 mb-1">
|
||||
App Secret <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
value={form.appSecret}
|
||||
onChange={(e) => setForm((f) => ({ ...f, appSecret: e.target.value }))}
|
||||
placeholder="••••••••••••••••"
|
||||
className="w-full h-9 px-3 border border-slate-200 rounded-lg text-sm focus:ring-2 focus:ring-indigo-500/20 focus:border-indigo-500 outline-none transition"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-600 mb-1">
|
||||
{t('feishuBotDisplayName')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.botName}
|
||||
onChange={(e) => setForm((f) => ({ ...f, botName: e.target.value }))}
|
||||
placeholder={t('feishuBotNamePlaceholder')}
|
||||
className="w-full h-9 px-3 border border-slate-200 rounded-lg text-sm focus:ring-2 focus:ring-indigo-500/20 focus:border-indigo-500 outline-none transition"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-600 mb-1">
|
||||
Verification Token
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.verificationToken}
|
||||
onChange={(e) => setForm((f) => ({ ...f, verificationToken: e.target.value }))}
|
||||
placeholder={t('feishuTokenPlaceholder')}
|
||||
className="w-full h-9 px-3 border border-slate-200 rounded-lg text-sm focus:ring-2 focus:ring-indigo-500/20 focus:border-indigo-500 outline-none transition"
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-xs font-semibold text-slate-600 mb-1">
|
||||
Encrypt Key ({t('optional')})
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={form.encryptKey}
|
||||
onChange={(e) => setForm((f) => ({ ...f, encryptKey: e.target.value }))}
|
||||
placeholder={t('feishuEncryptKeyPlaceholder')}
|
||||
className="w-full h-9 px-3 border border-slate-200 rounded-lg text-sm focus:ring-2 focus:ring-indigo-500/20 focus:border-indigo-500 outline-none transition"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 mt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowForm(false)}
|
||||
className="px-4 h-9 text-sm text-slate-600 hover:bg-slate-100 rounded-lg transition-colors"
|
||||
>
|
||||
{t('cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="px-4 h-9 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg text-sm font-semibold transition-colors disabled:opacity-50"
|
||||
>
|
||||
{submitting ? t('saving') : t('save')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* Bot List */}
|
||||
{loading ? (
|
||||
<div className="text-center py-12 text-slate-400">{t('loading')}</div>
|
||||
) : bots.length === 0 ? (
|
||||
<div className="text-center py-12 bg-white rounded-xl border border-dashed border-slate-200">
|
||||
<span className="text-4xl mb-3 block">🪶</span>
|
||||
<p className="text-slate-500 text-sm">{t('feishuNoBots')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{bots.map((bot) => {
|
||||
const wsStatus = wsStatuses[bot.id];
|
||||
const wsState: WsState = wsStatus?.state ?? 'disconnected';
|
||||
const isWsLoading = wsLoading[bot.id] ?? false;
|
||||
const isWsConnected = wsState === 'connected';
|
||||
const isWsConnecting = wsState === 'connecting';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={bot.id}
|
||||
className="bg-white rounded-xl border border-slate-200 shadow-sm p-5"
|
||||
>
|
||||
{/* Bot Header */}
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<p className="font-semibold text-slate-800">
|
||||
{bot.botName || bot.appId}
|
||||
</p>
|
||||
<p className="text-xs text-slate-400 mt-0.5">App ID: {bot.appId}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => handleToggle(bot)}
|
||||
className="flex items-center gap-1.5 text-xs font-medium transition-colors"
|
||||
title={bot.enabled ? t('feishuDisableBot') : t('feishuEnableBot')}
|
||||
>
|
||||
{bot.enabled ? (
|
||||
<ToggleRight size={22} className="text-emerald-500" />
|
||||
) : (
|
||||
<ToggleLeft size={22} className="text-slate-300" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(bot.id)}
|
||||
className="p-1.5 text-slate-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
|
||||
>
|
||||
<Trash2 size={15} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Webhook URL */}
|
||||
<div className="bg-slate-50 rounded-lg p-3 flex items-center justify-between gap-2 mb-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-[11px] font-semibold text-slate-500 uppercase tracking-wide mb-0.5">
|
||||
{t('feishuWebhookUrl')}
|
||||
</p>
|
||||
<code className="text-xs text-slate-700 break-all">
|
||||
{window.location.origin}{bot.webhookUrl}
|
||||
</code>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => copyWebhookUrl(bot.webhookUrl, bot.id)}
|
||||
className="shrink-0 p-2 text-slate-400 hover:text-indigo-600 hover:bg-indigo-50 rounded-lg transition-colors"
|
||||
>
|
||||
{copiedId === bot.id ? (
|
||||
<Check size={15} className="text-emerald-500" />
|
||||
) : (
|
||||
<Copy size={15} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* WebSocket Mode Panel */}
|
||||
<div className={`rounded-lg border p-3 ${isWsConnected ? 'border-emerald-200 bg-emerald-50/50' : 'border-slate-200 bg-slate-50'}`}>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-start gap-2 flex-1 min-w-0">
|
||||
{isWsConnected ? (
|
||||
<Wifi size={15} className="mt-0.5 shrink-0 text-emerald-500" />
|
||||
) : (
|
||||
<WifiOff size={15} className="mt-0.5 shrink-0 text-slate-400" />
|
||||
)}
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-slate-700">
|
||||
{t('feishuWsMode')}
|
||||
</p>
|
||||
<p className="text-[11px] text-slate-500 mt-0.5">
|
||||
{t('feishuWsModeDesc')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* WS State Badge + Button */}
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-[11px] font-semibold border ${getWsStateBg(wsState)}`}>
|
||||
{getWsStateLabel(wsState)}
|
||||
</span>
|
||||
{isWsConnected || isWsConnecting ? (
|
||||
<button
|
||||
onClick={() => handleWsDisconnect(bot.id)}
|
||||
disabled={isWsLoading || isWsConnecting}
|
||||
className="flex items-center gap-1.5 px-3 h-7 text-xs font-medium text-red-600 bg-red-50 hover:bg-red-100 rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isWsLoading ? <Loader2 size={12} className="animate-spin" /> : <WifiOff size={12} />}
|
||||
{t('feishuWsDisconnect')}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => handleWsConnect(bot.id)}
|
||||
disabled={isWsLoading || !bot.enabled}
|
||||
className="flex items-center gap-1.5 px-3 h-7 text-xs font-medium text-indigo-600 bg-indigo-50 hover:bg-indigo-100 rounded-lg transition-colors disabled:opacity-50"
|
||||
title={!bot.enabled ? t('feishuEnableBot') : undefined}
|
||||
>
|
||||
{isWsLoading ? <Loader2 size={12} className="animate-spin" /> : <Wifi size={12} />}
|
||||
{t('feishuWsConnect')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hint text */}
|
||||
{!isWsConnected && (
|
||||
<p className="text-[11px] text-slate-400 mt-2 pl-5">
|
||||
💡 {t('feishuWsConnectHint')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Connected At info */}
|
||||
{isWsConnected && wsStatus?.connectedAt && (
|
||||
<p className="text-[11px] text-emerald-600 mt-2 pl-5">
|
||||
✓ {t('feishuWsConnected')} · {new Date(wsStatus.connectedAt).toLocaleTimeString()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer badges */}
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<span
|
||||
className={`inline-flex items-center px-2 py-0.5 rounded-full text-[11px] font-semibold ${
|
||||
bot.enabled
|
||||
? 'bg-emerald-50 text-emerald-600'
|
||||
: 'bg-slate-100 text-slate-500'
|
||||
}`}
|
||||
>
|
||||
{bot.enabled ? t('statusRunning') : t('statusStopped')}
|
||||
</span>
|
||||
<span className="text-[11px] text-slate-400">
|
||||
{t('createdAt')}: {new Date(bot.createdAt).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
import React from 'react';
|
||||
|
||||
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
children: React.ReactNode;
|
||||
variant?: 'default' | 'outline' | 'ghost';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
}
|
||||
|
||||
export const Button: React.FC<ButtonProps> = ({
|
||||
children,
|
||||
className = '',
|
||||
variant = 'default',
|
||||
size = 'md',
|
||||
...props
|
||||
}) => {
|
||||
const baseClasses = 'inline-flex items-center justify-center rounded-lg font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed';
|
||||
|
||||
const variantClasses = {
|
||||
default: 'bg-blue-600 text-white hover:bg-blue-700',
|
||||
outline: 'border border-slate-300 bg-white text-slate-700 hover:bg-slate-50',
|
||||
ghost: 'text-slate-700 hover:bg-slate-100'
|
||||
};
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'px-3 py-1.5 text-sm',
|
||||
md: 'px-4 py-2 text-sm',
|
||||
lg: 'px-6 py-3 text-base'
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`${baseClasses} ${variantClasses[variant]} ${sizeClasses[size]} ${className}`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
import React from 'react';
|
||||
|
||||
interface CardProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const Card: React.FC<CardProps> = ({ children, className = '' }) => {
|
||||
return (
|
||||
<div className={`bg-white rounded-lg border border-slate-200 shadow-sm ${className}`}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const CardHeader: React.FC<CardProps> = ({ children, className = '' }) => {
|
||||
return (
|
||||
<div className={`p-4 border-b border-slate-100 ${className}`}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const CardTitle: React.FC<CardProps> = ({ children, className = '' }) => {
|
||||
return (
|
||||
<h3 className={`text-lg font-semibold text-slate-800 ${className}`}>
|
||||
{children}
|
||||
</h3>
|
||||
);
|
||||
};
|
||||
|
||||
export const CardContent: React.FC<CardProps> = ({ children, className = '' }) => {
|
||||
return (
|
||||
<div className={`p-4 ${className}`}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,121 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
|
||||
interface SelectProps {
|
||||
value: string;
|
||||
onValueChange: (value: string) => void;
|
||||
children: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface SelectTriggerProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface SelectContentProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
interface SelectItemProps {
|
||||
value: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
interface SelectValueProps {
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
const SelectContext = React.createContext<{
|
||||
value: string;
|
||||
onValueChange: (value: string) => void;
|
||||
isOpen: boolean;
|
||||
setIsOpen: (open: boolean) => void;
|
||||
}>({
|
||||
value: '',
|
||||
onValueChange: () => {},
|
||||
isOpen: false,
|
||||
setIsOpen: () => {}
|
||||
});
|
||||
|
||||
export const Select: React.FC<SelectProps> = ({ value, onValueChange, children, disabled = false }) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const selectRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (selectRef.current && !selectRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<SelectContext.Provider value={{ value, onValueChange, isOpen, setIsOpen }}>
|
||||
<div ref={selectRef} className="relative">
|
||||
{children}
|
||||
</div>
|
||||
</SelectContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const SelectTrigger: React.FC<SelectTriggerProps> = ({ children, className = '' }) => {
|
||||
const { isOpen, setIsOpen } = React.useContext(SelectContext);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className={`w-full flex items-center justify-between px-3 py-2 text-sm border border-slate-300 rounded-md bg-white hover:bg-slate-50 focus:outline-none focus:ring-2 focus:ring-blue-500 ${className}`}
|
||||
>
|
||||
{children}
|
||||
<ChevronDown className={`w-4 h-4 text-slate-400 transition-transform ${isOpen ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export const SelectValue: React.FC<SelectValueProps> = ({ placeholder }) => {
|
||||
const { value } = React.useContext(SelectContext);
|
||||
|
||||
return (
|
||||
<span className={value ? 'text-slate-900' : 'text-slate-500'}>
|
||||
{value || placeholder}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export const SelectContent: React.FC<SelectContentProps> = ({ children }) => {
|
||||
const { isOpen } = React.useContext(SelectContext);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="absolute z-50 w-full mt-1 bg-white border border-slate-200 rounded-md shadow-lg max-h-60 overflow-auto">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const SelectItem: React.FC<SelectItemProps> = ({ value, children }) => {
|
||||
const { value: selectedValue, onValueChange, setIsOpen } = React.useContext(SelectContext);
|
||||
|
||||
const handleClick = () => {
|
||||
onValueChange(value);
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
className={`w-full px-3 py-2 text-sm text-left hover:bg-slate-100 focus:outline-none focus:bg-slate-100 ${
|
||||
selectedValue === value ? 'bg-blue-50 text-blue-700' : 'text-slate-900'
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,186 @@
|
||||
import React from 'react';
|
||||
import { useLanguage } from '../../../contexts/LanguageContext';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Search, Plus, MoreHorizontal, MessageSquare } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { cn } from '../../utils/cn';
|
||||
|
||||
// Mock data based on the provided design
|
||||
interface AgentMock {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
status: 'running' | 'stopped';
|
||||
updatedAt: string;
|
||||
iconEmoji: string;
|
||||
iconBgClass: string;
|
||||
path?: string;
|
||||
}
|
||||
|
||||
const mockAgents: AgentMock[] = [
|
||||
{
|
||||
id: 'assessment',
|
||||
name: 'assessmentTitle',
|
||||
description: 'assessmentDesc',
|
||||
status: 'running',
|
||||
updatedAt: 'agent1Time',
|
||||
iconEmoji: '📋',
|
||||
iconBgClass: 'bg-blue-50',
|
||||
path: '/assessment'
|
||||
},
|
||||
{
|
||||
id: 'data-analyst',
|
||||
name: 'agent1Name',
|
||||
description: 'agent1Desc',
|
||||
status: 'running',
|
||||
updatedAt: 'agent1Time',
|
||||
iconEmoji: '📊',
|
||||
iconBgClass: 'bg-emerald-50'
|
||||
},
|
||||
{
|
||||
id: 'code-review',
|
||||
name: 'agent2Name',
|
||||
description: 'agent2Desc',
|
||||
status: 'running',
|
||||
updatedAt: 'agent2Time',
|
||||
iconEmoji: '💻',
|
||||
iconBgClass: 'bg-indigo-50'
|
||||
},
|
||||
{
|
||||
id: 'paper-polisher',
|
||||
name: 'agent3Name',
|
||||
description: 'agent3Desc',
|
||||
status: 'stopped',
|
||||
updatedAt: 'agent3Time',
|
||||
iconEmoji: '✍️',
|
||||
iconBgClass: 'bg-amber-50'
|
||||
},
|
||||
{
|
||||
id: 'legal-consultant',
|
||||
name: 'agent4Name',
|
||||
description: 'agent4Desc',
|
||||
status: 'running',
|
||||
updatedAt: 'agent4Time',
|
||||
iconEmoji: '⚖️',
|
||||
iconBgClass: 'bg-rose-50'
|
||||
},
|
||||
{
|
||||
id: 'market-researcher',
|
||||
name: 'agent5Name',
|
||||
description: 'agent5Desc',
|
||||
status: 'running',
|
||||
updatedAt: 'agent5Time',
|
||||
iconEmoji: '📈',
|
||||
iconBgClass: 'bg-cyan-50'
|
||||
}
|
||||
];
|
||||
|
||||
export const AgentsView: React.FC = () => {
|
||||
const { t } = useLanguage();
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-[#f4f7fb] overflow-hidden">
|
||||
{/* Header Area */}
|
||||
<div className="px-8 pt-8 pb-6 flex items-start justify-between shrink-0">
|
||||
<div>
|
||||
<h1 className="text-[22px] font-bold text-slate-900 leading-tight">
|
||||
{t('agentTitle')}
|
||||
</h1>
|
||||
<p className="text-[14px] text-slate-500 mt-1">{t('agentDesc')}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative w-64">
|
||||
<Search className="absolute text-slate-400 left-3 top-1/2 -translate-y-1/2" size={16} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('searchAgent')}
|
||||
className="w-full h-10 pl-10 pr-4 bg-white border border-slate-200 rounded-lg focus:bg-white focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 outline-none transition-all text-sm font-medium"
|
||||
/>
|
||||
</div>
|
||||
<button className="flex items-center gap-2 px-5 h-10 bg-blue-600 hover:bg-blue-700 text-white rounded-lg shadow-sm shadow-blue-100 transition-all font-semibold text-sm active:scale-95">
|
||||
<Plus size={18} />
|
||||
<span>{t('createAgent')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content Area */}
|
||||
<div className="px-8 pb-8 flex-1 overflow-y-auto">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6 max-w-[1600px] mx-auto">
|
||||
<AnimatePresence>
|
||||
{mockAgents.map((agent) => (
|
||||
<motion.div
|
||||
key={agent.id}
|
||||
layout
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className={cn(
|
||||
"bg-white rounded-2xl p-6 shadow-sm border border-slate-100 hover:shadow-md transition-all group flex flex-col h-[220px]",
|
||||
agent.path && "cursor-pointer hover:border-blue-200"
|
||||
)}
|
||||
onClick={() => {
|
||||
if (agent.path) {
|
||||
navigate(agent.path);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Top layer */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className={`w-12 h-12 flex items-center justify-center rounded-xl ${agent.iconBgClass} text-2xl`}>
|
||||
{agent.iconEmoji}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Status Badge */}
|
||||
{agent.status === 'running' ? (
|
||||
<div className="px-2.5 py-1 text-[12px] font-semibold text-emerald-600 bg-emerald-50 rounded-full border border-emerald-100/50 flex flex-row items-center justify-center">
|
||||
{t('statusRunning')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-2.5 py-1 text-[12px] font-semibold text-slate-500 bg-slate-50 rounded-full border border-slate-100 flex flex-row items-center justify-center">
|
||||
{t('statusStopped')}
|
||||
</div>
|
||||
)}
|
||||
{/* Options button */}
|
||||
<button className="text-slate-400 hover:text-slate-600 transition-colors">
|
||||
<MoreHorizontal size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Middle layer */}
|
||||
<div className="flex-1">
|
||||
<h3 className="font-bold text-slate-800 text-[17px] mb-2 leading-tight">
|
||||
{t(agent.name as any)}
|
||||
</h3>
|
||||
<p className="text-[13px] text-slate-500 leading-relaxed line-clamp-2">
|
||||
{t(agent.description as any)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Bottom layer */}
|
||||
<div className="mt-4 pt-4 border-t border-slate-50 flex items-center justify-between">
|
||||
<span className="text-[12px] font-medium text-slate-400">
|
||||
{t('updatedAtPrefix')}{t(agent.updatedAt as any)}
|
||||
</span>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (agent.path) {
|
||||
navigate(agent.path);
|
||||
}
|
||||
}}
|
||||
className="flex items-center justify-center gap-1.5 px-3 py-1.5 text-blue-600 bg-blue-50 hover:bg-blue-100 rounded-lg transition-colors"
|
||||
>
|
||||
<MessageSquare size={14} className="text-blue-500" />
|
||||
<span className="text-[13px] font-bold">{t('btnChat')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,102 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Blocks } from 'lucide-react';
|
||||
import { useLanguage } from '../../../contexts/LanguageContext';
|
||||
|
||||
import { FeishuPluginConfig } from '../plugins/FeishuPluginConfig';
|
||||
|
||||
|
||||
interface PluginDef {
|
||||
id: string;
|
||||
icon: string;
|
||||
nameKey: string;
|
||||
descKey: string;
|
||||
available: boolean;
|
||||
component?: React.ComponentType;
|
||||
}
|
||||
|
||||
const PLUGIN_LIST: PluginDef[] = [
|
||||
{
|
||||
id: 'feishu',
|
||||
icon: '🪶',
|
||||
nameKey: 'pluginFeishuName',
|
||||
descKey: 'pluginFeishuDesc',
|
||||
available: true,
|
||||
component: FeishuPluginConfig,
|
||||
},
|
||||
];
|
||||
|
||||
export const PluginsView: React.FC = () => {
|
||||
const { t } = useLanguage();
|
||||
const [activePlugin, setActivePlugin] = useState<PluginDef | null>(null);
|
||||
|
||||
if (activePlugin?.component) {
|
||||
const ConfigComp = activePlugin.component;
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-[#f4f7fb]">
|
||||
<div className="px-8 pt-8 pb-4 shrink-0">
|
||||
<button
|
||||
onClick={() => setActivePlugin(null)}
|
||||
className="flex items-center gap-1.5 text-sm text-slate-500 hover:text-blue-600 transition-colors mb-4"
|
||||
>
|
||||
← {t('back')}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto px-8 pb-8">
|
||||
<ConfigComp />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-[#f4f7fb] overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="px-8 pt-8 pb-6 shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-indigo-50 rounded-xl flex items-center justify-center">
|
||||
<Blocks size={20} className="text-indigo-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-[22px] font-bold text-slate-900 leading-tight">
|
||||
{t('pluginViewTitle')}
|
||||
</h1>
|
||||
<p className="text-[14px] text-slate-500 mt-0.5">{t('pluginViewDesc')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Plugin Grid */}
|
||||
<div className="px-8 pb-8 flex-1 overflow-y-auto">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6 max-w-[1200px]">
|
||||
{PLUGIN_LIST.map((plugin) => (
|
||||
<div
|
||||
key={plugin.id}
|
||||
className="bg-white rounded-2xl p-6 shadow-sm border border-slate-100 hover:shadow-md hover:border-indigo-200 transition-all cursor-pointer group"
|
||||
onClick={() => setActivePlugin(plugin)}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="w-12 h-12 bg-indigo-50 rounded-xl flex items-center justify-center text-2xl">
|
||||
{plugin.icon}
|
||||
</div>
|
||||
<div className="px-2.5 py-1 text-[12px] font-semibold text-emerald-600 bg-emerald-50 rounded-full border border-emerald-100">
|
||||
{t('pluginStatusAvailable')}
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="font-bold text-slate-800 text-[17px] mb-1.5">
|
||||
{t(plugin.nameKey as any)}
|
||||
</h3>
|
||||
<p className="text-[13px] text-slate-500 leading-relaxed line-clamp-2">
|
||||
{t(plugin.descKey as any)}
|
||||
</p>
|
||||
<div className="mt-4 pt-4 border-t border-slate-50">
|
||||
<button className="text-sm font-bold text-indigo-600 hover:text-indigo-800 transition-colors group-hover:underline">
|
||||
{t('pluginConfigure')} →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,162 @@
|
||||
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||
|
||||
export type UserRole = 'SUPER_ADMIN' | 'TENANT_ADMIN' | 'USER';
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
role: UserRole;
|
||||
tenantId?: string;
|
||||
// Legacy support
|
||||
email?: string;
|
||||
tenant_name?: string;
|
||||
isNotebookEnabled?: boolean;
|
||||
displayName?: string;
|
||||
}
|
||||
|
||||
export interface TenantMembership {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
role: UserRole;
|
||||
tenant: {
|
||||
id: string;
|
||||
name: string;
|
||||
domain?: string;
|
||||
parentId?: string | null;
|
||||
};
|
||||
features?: {
|
||||
isNotebookEnabled: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
interface AuthContextType {
|
||||
user: User | null;
|
||||
apiKey: string;
|
||||
availableTenants: TenantMembership[];
|
||||
activeTenant: TenantMembership | null;
|
||||
login: (key: string, userData: Partial<User> & { role?: string }) => void;
|
||||
logout: () => void;
|
||||
switchTenant: (tenantId: string) => void;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [apiKey, setApiKey] = useState<string>(localStorage.getItem('kb_api_key') || '');
|
||||
const [availableTenants, setAvailableTenants] = useState<TenantMembership[]>([]);
|
||||
const [activeTenant, setActiveTenant] = useState<TenantMembership | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const fetchTenants = async (key: string, currentTenantId?: string) => {
|
||||
try {
|
||||
const res = await fetch('/api/users/tenants', {
|
||||
headers: { 'x-api-key': key }
|
||||
});
|
||||
if (res.ok) {
|
||||
const tenants: TenantMembership[] = await res.json();
|
||||
console.log('[AuthContext] Fetched tenants:', tenants);
|
||||
const filteredTenants = tenants.filter(t => t.tenant?.name !== 'Default');
|
||||
setAvailableTenants(filteredTenants);
|
||||
|
||||
const savedTenantId = localStorage.getItem('kb_active_tenant_id') || currentTenantId;
|
||||
const active = filteredTenants.find(t => t.tenantId === savedTenantId) || filteredTenants[0] || null;
|
||||
setActiveTenant(active);
|
||||
if (active) {
|
||||
localStorage.setItem('kb_active_tenant_id', active.tenantId);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch tenants', e);
|
||||
}
|
||||
};
|
||||
|
||||
// On mount, restore session
|
||||
useEffect(() => {
|
||||
const restoreSession = async () => {
|
||||
if (!apiKey) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch('/api/users/me', {
|
||||
headers: { 'x-api-key': apiKey }
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
console.log('[AuthContext] Restored user:', data);
|
||||
setUser({
|
||||
id: data.id,
|
||||
username: data.username,
|
||||
role: (data.role as UserRole) ?? 'USER',
|
||||
tenantId: data.tenantId,
|
||||
tenant_name: data.tenantName,
|
||||
isNotebookEnabled: data.isNotebookEnabled ?? true,
|
||||
displayName: data.displayName,
|
||||
});
|
||||
await fetchTenants(apiKey, data.tenantId);
|
||||
} else {
|
||||
localStorage.removeItem('kb_api_key');
|
||||
localStorage.removeItem('kb_active_tenant_id');
|
||||
setApiKey('');
|
||||
setUser(null);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to restore session', e);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
restoreSession();
|
||||
}, [apiKey]);
|
||||
|
||||
const login = (key: string, userData: Partial<User> & { role?: string }) => {
|
||||
localStorage.setItem('kb_api_key', key);
|
||||
setApiKey(key);
|
||||
setUser({
|
||||
id: userData.id ?? '',
|
||||
username: userData.username ?? '',
|
||||
role: (userData.role as UserRole) ?? 'USER',
|
||||
tenantId: userData.tenantId,
|
||||
tenant_name: (userData as any).tenantName || userData.tenant_name,
|
||||
isNotebookEnabled: userData.isNotebookEnabled ?? true,
|
||||
displayName: userData.displayName,
|
||||
});
|
||||
fetchTenants(key, userData.tenantId);
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
localStorage.removeItem('kb_api_key');
|
||||
localStorage.removeItem('kb_active_tenant_id');
|
||||
setApiKey('');
|
||||
setUser(null);
|
||||
setAvailableTenants([]);
|
||||
setActiveTenant(null);
|
||||
};
|
||||
|
||||
const switchTenant = (tenantId: string) => {
|
||||
const target = availableTenants.find(t => t.tenantId === tenantId);
|
||||
if (target) {
|
||||
setActiveTenant(target);
|
||||
localStorage.setItem('kb_active_tenant_id', tenantId);
|
||||
// Optionally reload page to reset all states, or just let components re-render
|
||||
window.location.reload();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, apiKey, availableTenants, activeTenant, login, logout, switchTenant, isLoading }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import React, { useState } from 'react';
|
||||
import { BookOpen } from 'lucide-react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { cn } from '../../utils/cn';
|
||||
|
||||
export default function Login() {
|
||||
const { login, user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [apiKeyInput, setApiKeyInput] = useState('');
|
||||
const [loginUsername, setLoginUsername] = useState('');
|
||||
const [loginPassword, setLoginPassword] = useState('');
|
||||
const [loginMode, setLoginMode] = useState<'password' | 'apikey'>('password');
|
||||
const [error, setError] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// Redirect if already logged in
|
||||
React.useEffect(() => {
|
||||
if (user) {
|
||||
navigate('/');
|
||||
}
|
||||
}, [user, navigate]);
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
if (loginMode === 'apikey') {
|
||||
if (!apiKeyInput.trim()) {
|
||||
setError('API Key is required');
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify API key is valid by calling the profile endpoint
|
||||
const res = await fetch('/api/users/me', {
|
||||
headers: { 'x-api-key': apiKeyInput }
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const userData = await res.json();
|
||||
login(apiKeyInput, { ...userData, role: userData.role ?? 'USER' });
|
||||
} else {
|
||||
setError('Invalid API Key');
|
||||
}
|
||||
} else {
|
||||
const res = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username: loginUsername, password: loginPassword })
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
// data has { access_token, user }
|
||||
// Get the API key using the JWT token
|
||||
const keyRes = await fetch('/api/users/api-key', {
|
||||
headers: { 'Authorization': `Bearer ${data.access_token}` }
|
||||
});
|
||||
if (keyRes.ok) {
|
||||
const keyData = await keyRes.json();
|
||||
login(keyData.apiKey, { ...data.user, role: data.user.role ?? 'USER' });
|
||||
navigate('/');
|
||||
} else {
|
||||
// Fall back to using JWT access token as the key
|
||||
login(data.access_token, { ...data.user, role: data.user.role ?? 'USER' });
|
||||
navigate('/');
|
||||
}
|
||||
} else {
|
||||
setError('Invalid username or password');
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
setError('An error occurred during login. Please try again.');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-slate-50">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="w-full max-w-md p-8 bg-white border border-slate-200 shadow-xl rounded-2xl"
|
||||
>
|
||||
<div className="flex flex-col items-center mb-8">
|
||||
<div className="p-3 mb-4 bg-blue-600 rounded-xl">
|
||||
<BookOpen className="text-white" size={32} />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-slate-900">AuraK V2</h1>
|
||||
<p className="text-slate-500">Sign in to your workspace</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 p-1 mb-6 bg-slate-100 rounded-lg">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setLoginMode('password'); setError(''); }}
|
||||
className={cn(
|
||||
"flex-1 py-1.5 text-sm font-medium rounded-md transition-all",
|
||||
loginMode === 'password' ? "bg-white shadow-sm text-blue-600" : "text-slate-500 hover:text-slate-700"
|
||||
)}
|
||||
>
|
||||
Password
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setLoginMode('apikey'); setError(''); }}
|
||||
className={cn(
|
||||
"flex-1 py-1.5 text-sm font-medium rounded-md transition-all",
|
||||
loginMode === 'apikey' ? "bg-white shadow-sm text-blue-600" : "text-slate-500 hover:text-slate-700"
|
||||
)}
|
||||
>
|
||||
API Key
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 text-sm text-red-600 bg-red-50 border border-red-100 rounded-lg">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleLogin} className="space-y-4">
|
||||
{loginMode === 'password' ? (
|
||||
<>
|
||||
<div>
|
||||
<label className="block mb-1 text-sm font-medium text-slate-700">Username</label>
|
||||
<input
|
||||
type="text"
|
||||
value={loginUsername}
|
||||
onChange={(e) => setLoginUsername(e.target.value)}
|
||||
placeholder="admin"
|
||||
className="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none transition-all"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block mb-1 text-sm font-medium text-slate-700">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={loginPassword}
|
||||
onChange={(e) => setLoginPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
className="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none transition-all"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div>
|
||||
<label className="block mb-1 text-sm font-medium text-slate-700">API Key</label>
|
||||
<input
|
||||
type="password"
|
||||
value={apiKeyInput}
|
||||
onChange={(e) => setApiKeyInput(e.target.value)}
|
||||
placeholder="sk-..."
|
||||
className="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none transition-all"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="w-full py-2.5 mt-2 text-white bg-blue-600 hover:bg-blue-700 disabled:opacity-70 disabled:cursor-not-allowed rounded-lg font-semibold transition-colors flex items-center justify-center"
|
||||
>
|
||||
{isLoading ? 'Signing in...' : 'Sign In'}
|
||||
</button>
|
||||
</form>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import React from 'react';
|
||||
import { AgentsView } from '../../components/views/AgentsView';
|
||||
|
||||
const AgentsPage: React.FC = () => {
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<AgentsView />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AgentsPage;
|
||||
@@ -0,0 +1,15 @@
|
||||
import React from 'react';
|
||||
import { AssessmentView } from '../../../components/views/AssessmentView';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
|
||||
export default function AssessmentPage() {
|
||||
const { apiKey, logout, user } = useAuth();
|
||||
|
||||
return (
|
||||
<AssessmentView
|
||||
onLogout={logout}
|
||||
onNavigate={() => { }}
|
||||
isAdmin={user?.role === 'TENANT_ADMIN' || user?.role === 'SUPER_ADMIN'}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { ChatView } from '../../../components/views/ChatView';
|
||||
import { ModelConfig, DEFAULT_MODELS } from '../../../types';
|
||||
import { modelConfigService } from '../../../services/modelConfigService';
|
||||
|
||||
export default function ChatPage() {
|
||||
const { apiKey, logout, user } = useAuth();
|
||||
const [modelConfigs, setModelConfigs] = useState<ModelConfig[]>(DEFAULT_MODELS);
|
||||
|
||||
const fetchModels = useCallback(async () => {
|
||||
if (!apiKey) return;
|
||||
try {
|
||||
const backendModels = await modelConfigService.getAll(apiKey);
|
||||
const map = new Map<string, ModelConfig>();
|
||||
DEFAULT_MODELS.forEach(m => map.set(m.id, m));
|
||||
backendModels.forEach(m => map.set(m.id, m));
|
||||
setModelConfigs(Array.from(map.values()));
|
||||
} catch {
|
||||
setModelConfigs(DEFAULT_MODELS);
|
||||
}
|
||||
}, [apiKey]);
|
||||
|
||||
useEffect(() => { fetchModels(); }, [fetchModels]);
|
||||
|
||||
return (
|
||||
<ChatView
|
||||
authToken={apiKey}
|
||||
onLogout={logout}
|
||||
modelConfigs={modelConfigs}
|
||||
onNavigate={() => { }}
|
||||
isAdmin={user?.role === 'TENANT_ADMIN' || user?.role === 'SUPER_ADMIN'}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { KnowledgeBaseView } from '../../../components/views/KnowledgeBaseView';
|
||||
import { modelConfigService } from '../../../services/modelConfigService';
|
||||
import { ModelConfig } from '../../../types';
|
||||
|
||||
export default function KnowledgePage() {
|
||||
const { apiKey, user, logout, activeTenant } = useAuth();
|
||||
const [modelConfigs, setModelConfigs] = useState<ModelConfig[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchModels = async () => {
|
||||
if (!apiKey) return;
|
||||
try {
|
||||
const models = await modelConfigService.getAll(apiKey);
|
||||
setModelConfigs(models);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch model configs:', error);
|
||||
}
|
||||
};
|
||||
fetchModels();
|
||||
}, [apiKey]);
|
||||
|
||||
const isAdmin = user?.role === 'SUPER_ADMIN' || activeTenant?.role === 'TENANT_ADMIN' || activeTenant?.role === 'SUPER_ADMIN';
|
||||
|
||||
return (
|
||||
<KnowledgeBaseView
|
||||
authToken={apiKey || ''}
|
||||
onLogout={logout}
|
||||
onNavigate={() => { }}
|
||||
modelConfigs={modelConfigs}
|
||||
isAdmin={isAdmin}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import React from 'react';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { MemosView } from '../../../components/views/MemosView';
|
||||
|
||||
export default function MemosPage() {
|
||||
const { apiKey, user, activeTenant } = useAuth();
|
||||
|
||||
const isAdmin = user?.role === 'SUPER_ADMIN' || activeTenant?.role === 'TENANT_ADMIN' || activeTenant?.role === 'SUPER_ADMIN';
|
||||
|
||||
return (
|
||||
<MemosView
|
||||
authToken={apiKey}
|
||||
isAdmin={isAdmin}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { NotebooksView } from '../../../components/views/NotebooksView';
|
||||
|
||||
const NotebooksPage: React.FC = () => {
|
||||
const { apiKey, user, activeTenant } = useAuth()
|
||||
|
||||
const isAdmin = user?.role === 'SUPER_ADMIN' || activeTenant?.role === 'TENANT_ADMIN' || activeTenant?.role === 'SUPER_ADMIN';
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<NotebooksView
|
||||
authToken={apiKey}
|
||||
onChatWithContext={() => { }}
|
||||
isAdmin={isAdmin}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default NotebooksPage;
|
||||
@@ -0,0 +1,12 @@
|
||||
import React from 'react';
|
||||
import { PluginsView } from '../../components/views/PluginsView';
|
||||
|
||||
const PluginsPage: React.FC = () => {
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<PluginsView />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PluginsPage;
|
||||
@@ -0,0 +1,49 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { SettingsView } from '../../../components/views/SettingsView';
|
||||
import { ModelConfig, DEFAULT_MODELS } from '../../../types';
|
||||
import { modelConfigService } from '../../../services/modelConfigService';
|
||||
|
||||
interface SettingsPageProps {
|
||||
initialTab?: 'general' | 'user' | 'model' | 'tenants' | 'knowledge_base';
|
||||
}
|
||||
|
||||
export default function SettingsPage({ initialTab }: SettingsPageProps) {
|
||||
const { apiKey, user, activeTenant } = useAuth();
|
||||
const [modelConfigs, setModelConfigs] = useState<ModelConfig[]>(DEFAULT_MODELS);
|
||||
|
||||
const fetchModels = useCallback(async () => {
|
||||
if (!apiKey) return;
|
||||
try {
|
||||
const backendModels = await modelConfigService.getAll(apiKey);
|
||||
const map = new Map<string, ModelConfig>();
|
||||
DEFAULT_MODELS.forEach(m => map.set(m.id, m));
|
||||
backendModels.forEach(m => map.set(m.id, m));
|
||||
setModelConfigs(Array.from(map.values()));
|
||||
} catch {
|
||||
setModelConfigs(DEFAULT_MODELS);
|
||||
}
|
||||
}, [apiKey]);
|
||||
|
||||
useEffect(() => { fetchModels(); }, [fetchModels]);
|
||||
|
||||
const handleUpdateModels = useCallback(async (action: 'create' | 'update' | 'delete', model: ModelConfig) => {
|
||||
if (!apiKey) return;
|
||||
const { id, ...data } = model;
|
||||
if (action === 'create') await modelConfigService.create(apiKey, data);
|
||||
else if (action === 'update') await modelConfigService.update(apiKey, id, data);
|
||||
else if (action === 'delete') await modelConfigService.remove(apiKey, id);
|
||||
await fetchModels();
|
||||
}, [apiKey, fetchModels]);
|
||||
|
||||
return (
|
||||
<SettingsView
|
||||
models={modelConfigs}
|
||||
onUpdateModels={handleUpdateModels}
|
||||
authToken={apiKey}
|
||||
isAdmin={user?.role === 'SUPER_ADMIN' || activeTenant?.role === 'TENANT_ADMIN'}
|
||||
currentUser={user}
|
||||
initialTab={initialTab}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { apiClient } from './apiClient';
|
||||
|
||||
export interface AssessmentStats {
|
||||
totalAttempts: number;
|
||||
highestScore: number;
|
||||
averageScore: number;
|
||||
completionRate: number;
|
||||
recentRecords: {
|
||||
id: string;
|
||||
userId?: string;
|
||||
knowledgeBase: string;
|
||||
template: string;
|
||||
score: number | null;
|
||||
status: 'IN_PROGRESS' | 'COMPLETED';
|
||||
createdAt: string;
|
||||
user?: { id: string };
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface StatsQueryParams {
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
templateId?: string;
|
||||
knowledgeGroupId?: string;
|
||||
}
|
||||
|
||||
export class AssessmentStatsService {
|
||||
async getStats(params?: StatsQueryParams): Promise<AssessmentStats> {
|
||||
const query = new URLSearchParams();
|
||||
if (params?.startDate) query.set('startDate', params.startDate);
|
||||
if (params?.endDate) query.set('endDate', params.endDate);
|
||||
if (params?.templateId) query.set('templateId', params.templateId);
|
||||
if (params?.knowledgeGroupId) query.set('knowledgeGroupId', params.knowledgeGroupId);
|
||||
|
||||
const queryString = query.toString();
|
||||
const url = `/assessment/stats${queryString ? `?${queryString}` : ''}`;
|
||||
const { data } = await apiClient.get<AssessmentStats>(url);
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
export const assessmentStatsService = new AssessmentStatsService();
|
||||
@@ -0,0 +1,6 @@
|
||||
import { type ClassValue, clsx } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
|
||||
type ToastType = 'success' | 'error' | 'warning' | 'info';
|
||||
|
||||
interface ToastHandler {
|
||||
showToast: (type: ToastType, message: string, title?: string) => void;
|
||||
}
|
||||
|
||||
let handler: ToastHandler | null = null;
|
||||
|
||||
export const registerToastHandler = (h: ToastHandler) => {
|
||||
handler = h;
|
||||
};
|
||||
|
||||
export const toast = {
|
||||
success: (message: string, title?: string) => {
|
||||
if (handler) handler.showToast('success', message, title);
|
||||
else console.log(`✅ ${message}`);
|
||||
},
|
||||
error: (message: string, title?: string) => {
|
||||
if (handler) handler.showToast('error', message, title);
|
||||
else console.log(`❌ ${message}`);
|
||||
},
|
||||
info: (message: string, title?: string) => {
|
||||
if (handler) handler.showToast('info', message, title);
|
||||
else console.log(`ℹ️ ${message}`);
|
||||
},
|
||||
warning: (message: string, title?: string) => {
|
||||
if (handler) handler.showToast('warning', message, title);
|
||||
else console.log(`⚠️ ${message}`);
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user