feat: add 89cat Dashboard, Management, Security, and FAQ pages
Add 4 new pages (Dashboard home, Management Center, Security Check, FAQ) with route restructuring and i18n translations in zh/en. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
10
src/App.tsx
10
src/App.tsx
@@ -10,6 +10,10 @@ import i18n from './i18n';
|
||||
import { MainLayout } from './components/layout/MainLayout';
|
||||
import { TooltipProvider } from '@/components/ui/tooltip';
|
||||
import { Models } from './pages/Models';
|
||||
import { Dashboard } from './pages/Dashboard';
|
||||
import { Management } from './pages/Management';
|
||||
import { Security } from './pages/Security';
|
||||
import { Faq } from './pages/Faq';
|
||||
import { Chat } from './pages/Chat';
|
||||
import { Agents } from './pages/Agents';
|
||||
import { Channels } from './pages/Channels';
|
||||
@@ -201,12 +205,16 @@ function App() {
|
||||
|
||||
{/* Main application routes */}
|
||||
<Route element={<MainLayout />}>
|
||||
<Route path="/" element={<Chat />} />
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/chat" element={<Chat />} />
|
||||
<Route path="/models" element={<Models />} />
|
||||
<Route path="/agents" element={<Agents />} />
|
||||
<Route path="/channels" element={<Channels />} />
|
||||
<Route path="/skills" element={<Skills />} />
|
||||
<Route path="/cron" element={<Cron />} />
|
||||
<Route path="/management" element={<Management />} />
|
||||
<Route path="/security" element={<Security />} />
|
||||
<Route path="/faq" element={<Faq />} />
|
||||
<Route path="/image-generation" element={devModeUnlocked ? <ImageGenerationPage /> : <Navigate to="/" replace />} />
|
||||
<Route path="/dreams" element={devModeUnlocked ? <Dreams /> : <Navigate to="/" replace />} />
|
||||
<Route path="/settings/*" element={<Settings />} />
|
||||
|
||||
@@ -24,6 +24,8 @@ import {
|
||||
ImagePlus,
|
||||
Moon,
|
||||
ChevronRight,
|
||||
LayoutDashboard,
|
||||
MessageSquare,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { rendererExtensionRegistry } from '@/extensions/registry';
|
||||
@@ -157,7 +159,7 @@ export function Sidebar() {
|
||||
}, [isMac, sidebarCollapsed]);
|
||||
|
||||
const navigate = useNavigate();
|
||||
const isOnChat = useLocation().pathname === '/';
|
||||
const isOnChat = useLocation().pathname === '/chat';
|
||||
|
||||
const getSessionLabel = (key: string, displayName?: string, label?: string) =>
|
||||
sessionLabels[key] ?? label ?? displayName ?? key;
|
||||
@@ -309,6 +311,8 @@ export function Sidebar() {
|
||||
const extraNavItems = rendererExtensionRegistry.getExtraNavItems();
|
||||
|
||||
const coreNavItems = [
|
||||
{ to: '/', icon: <LayoutDashboard className="h-4 w-4" strokeWidth={2} />, label: t('sidebar.dashboard'), testId: 'sidebar-nav-dashboard' },
|
||||
{ to: '/chat', icon: <MessageSquare className="h-4 w-4" strokeWidth={2} />, label: t('sidebar.chat'), testId: 'sidebar-nav-chat' },
|
||||
{ to: '/models', icon: <Cpu className="h-4 w-4" strokeWidth={2} />, label: t('sidebar.models'), testId: 'sidebar-nav-models' },
|
||||
{ to: '/agents', icon: <Bot className="h-4 w-4" strokeWidth={2} />, label: t('sidebar.agents'), testId: 'sidebar-nav-agents' },
|
||||
{ to: '/channels', icon: <Network className="h-4 w-4" strokeWidth={2} />, label: t('sidebar.channels'), testId: 'sidebar-nav-channels' },
|
||||
@@ -391,7 +395,7 @@ export function Sidebar() {
|
||||
onClick={() => {
|
||||
const { messages } = useChatStore.getState();
|
||||
if (messages.length > 0) newSession();
|
||||
navigate('/');
|
||||
navigate('/chat');
|
||||
}}
|
||||
className={cn(
|
||||
'sidebar-nav-text flex items-center gap-2 rounded-lg px-2.5 py-2 transition-colors',
|
||||
@@ -482,7 +486,7 @@ export function Sidebar() {
|
||||
} else {
|
||||
switchSession(s.key);
|
||||
}
|
||||
navigate('/');
|
||||
navigate('/chat');
|
||||
}}
|
||||
onDoubleClick={() => handleStartRename(s.key, sessionLabel)}
|
||||
className={cn(
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"skills": "Skills",
|
||||
"agents": "Agents",
|
||||
"channels": "Channels",
|
||||
"dashboard": "Dashboard",
|
||||
"dashboard": "Home",
|
||||
"settings": "Settings",
|
||||
"devConsole": "Developer Console",
|
||||
"models": "Models",
|
||||
|
||||
@@ -1,4 +1,50 @@
|
||||
{
|
||||
"title": "89cat",
|
||||
"subtitle": "Your AI Smart Assistant",
|
||||
"cards": {
|
||||
"management": {
|
||||
"title": "Management",
|
||||
"desc": "Start or stop 89cat, access super admin console"
|
||||
},
|
||||
"tokenRecharge": {
|
||||
"title": "Token Recharge",
|
||||
"desc": "Visit 89.is89.com for token recharge"
|
||||
},
|
||||
"plugins": {
|
||||
"title": "Plugins",
|
||||
"desc": "Install and manage 89 plugins, OpenClaw skills"
|
||||
},
|
||||
"security": {
|
||||
"title": "Security Check",
|
||||
"desc": "Update virus definitions and scan system security"
|
||||
},
|
||||
"apiKey": {
|
||||
"title": "Work Key",
|
||||
"desc": "Enter your API key to get started, no URL needed"
|
||||
},
|
||||
"faq": {
|
||||
"title": "FAQ",
|
||||
"desc": "View usage help and frequently asked questions"
|
||||
},
|
||||
"language": {
|
||||
"title": "Language",
|
||||
"desc": "Switch the interface display language"
|
||||
}
|
||||
},
|
||||
"apiKey": {
|
||||
"placeholder": "Enter API Key",
|
||||
"validate": "Validate",
|
||||
"valid": "Key verified successfully",
|
||||
"invalidKey": "Invalid key, please check and try again",
|
||||
"validateFailed": "Validation failed, please check network"
|
||||
},
|
||||
"gatewayRunning": "89cat Running",
|
||||
"gatewayStopped": "89cat Stopped",
|
||||
"stop89cat": "Stop 89cat",
|
||||
"start89cat": "Start 89cat",
|
||||
"adminConsole": "89 Super AI Brain Management Center",
|
||||
"adminConsoleDesc": "Management center runs in the cloud to protect your privacy",
|
||||
"openAdminConsole": "Open Admin Console",
|
||||
"gateway": "Gateway",
|
||||
"channels": "Channels",
|
||||
"skills": "Skills",
|
||||
@@ -110,5 +156,55 @@
|
||||
"relayModelRequired": "Model name is required",
|
||||
"relayModelInvalid": "Enter only the model name, for example gpt-image-2. Do not include provider/model."
|
||||
}
|
||||
},
|
||||
"security": {
|
||||
"virusDbVersion": "Virus DB Version",
|
||||
"lastScan": "Last Scan",
|
||||
"updateDb": "Update Virus DB",
|
||||
"startScan": "Start Scan",
|
||||
"idle": "Waiting for virus database update",
|
||||
"updatingDb": "Updating virus database...",
|
||||
"readyToScan": "Virus database ready",
|
||||
"scanning": "Scanning...",
|
||||
"scanComplete": "Scan Complete",
|
||||
"filesScanned": "Files Scanned",
|
||||
"threatsFound": "{{count}} threat(s) found",
|
||||
"noThreats": "No threats found",
|
||||
"neverScanned": "Never scanned",
|
||||
"updateDbFirst": "Please update virus database first",
|
||||
"clickToScan": "Virus database is ready, you can start scanning",
|
||||
"scanResult": "Scan completed",
|
||||
"scanSummary": "Scanned {{files}} files in {{time}} seconds"
|
||||
},
|
||||
"faq": {
|
||||
"sections": {
|
||||
"install": "Installation",
|
||||
"usage": "Usage",
|
||||
"key": "API Key",
|
||||
"security": "Security",
|
||||
"other": "Other"
|
||||
},
|
||||
"q": {
|
||||
"howToInstall": "How to install 89cat?",
|
||||
"systemRequirements": "What are the system requirements?",
|
||||
"howToUse": "How to start using 89cat?",
|
||||
"stopRestart": "How to stop or restart 89cat?",
|
||||
"whereToGetKey": "Where to get an API Key?",
|
||||
"keyNotWorking": "What if my key shows as invalid?",
|
||||
"isMyDataSafe": "Is my data safe?",
|
||||
"howToScan": "How to run a security check?",
|
||||
"contactUs": "How to contact support?"
|
||||
},
|
||||
"a": {
|
||||
"howToInstall": "Download the installer for your platform from the official website, double-click to run and it will complete installation automatically. After installation, 89cat icons will appear on your desktop and start menu.",
|
||||
"systemRequirements": "Supports Windows 10+, macOS 11+, Linux (Ubuntu 20.04+). Minimum 4GB RAM, 8GB recommended. 1GB free disk space required.",
|
||||
"howToUse": "Enter your API Key in the \"Work Key\" card on the home page and click Validate. Once verified, you can chat with AI on the Chat page.",
|
||||
"stopRestart": "Go to the \"Management\" page, click \"Stop 89cat\" to stop the service, and click \"Start 89cat\" to restart.",
|
||||
"whereToGetKey": "Visit 89.is89.com to register an account and obtain an API Key.",
|
||||
"keyNotWorking": "Check if the key is entered correctly (case-sensitive and no spaces), confirm it hasn't expired, and verify network connectivity. Contact support if issues persist.",
|
||||
"isMyDataSafe": "89cat securely stores your API Key using the system's native keychain. The management center runs in the cloud while your personal data stays local, ensuring full privacy protection.",
|
||||
"howToScan": "Go to the \"Security Check\" page, click \"Update Virus DB\" first, then click \"Start Scan\" to perform a full system security scan.",
|
||||
"contactUs": "Send email to public@valuecell.ai or visit 89.is89.com for more help."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"skills": "技能",
|
||||
"agents": "Agents",
|
||||
"channels": "频道",
|
||||
"dashboard": "仪表盘",
|
||||
"dashboard": "首页",
|
||||
"settings": "设置",
|
||||
"devConsole": "开发者控制台",
|
||||
"models": "模型",
|
||||
|
||||
@@ -1,4 +1,50 @@
|
||||
{
|
||||
"title": "89猫",
|
||||
"subtitle": "你的AI智能助手",
|
||||
"cards": {
|
||||
"management": {
|
||||
"title": "管理中心",
|
||||
"desc": "停止或启动89猫,访问超级管理后台"
|
||||
},
|
||||
"tokenRecharge": {
|
||||
"title": "Token充值",
|
||||
"desc": "前往89.is89.com进行Token充值"
|
||||
},
|
||||
"plugins": {
|
||||
"title": "插件安装",
|
||||
"desc": "安装和管理89插件、OpenClaw技能"
|
||||
},
|
||||
"security": {
|
||||
"title": "安全检查",
|
||||
"desc": "升级病毒库并扫描系统安全"
|
||||
},
|
||||
"apiKey": {
|
||||
"title": "工作钥匙",
|
||||
"desc": "输入API Key即可开始工作,无需填写网址"
|
||||
},
|
||||
"faq": {
|
||||
"title": "常见问题",
|
||||
"desc": "查看使用帮助和常见问题解答"
|
||||
},
|
||||
"language": {
|
||||
"title": "语言选择",
|
||||
"desc": "切换界面显示语言"
|
||||
}
|
||||
},
|
||||
"apiKey": {
|
||||
"placeholder": "请输入API Key",
|
||||
"validate": "验证",
|
||||
"valid": "Key验证通过",
|
||||
"invalidKey": "Key无效,请检查后重试",
|
||||
"validateFailed": "验证失败,请检查网络连接"
|
||||
},
|
||||
"gatewayRunning": "89猫运行中",
|
||||
"gatewayStopped": "89猫已停止",
|
||||
"stop89cat": "停止使用89猫",
|
||||
"start89cat": "打开使用89猫",
|
||||
"adminConsole": "89超级人工智能脑管理中心",
|
||||
"adminConsoleDesc": "管理中心运行在云端,保护您的个人隐私",
|
||||
"openAdminConsole": "前往管理中心",
|
||||
"gateway": "网关",
|
||||
"channels": "频道",
|
||||
"skills": "技能",
|
||||
@@ -110,5 +156,55 @@
|
||||
"relayModelRequired": "请填写模型名",
|
||||
"relayModelInvalid": "请只填写模型名,例如 gpt-image-2;不要填写 provider/model。"
|
||||
}
|
||||
},
|
||||
"security": {
|
||||
"virusDbVersion": "病毒库版本",
|
||||
"lastScan": "上次扫描时间",
|
||||
"updateDb": "升级病毒库",
|
||||
"startScan": "开始扫描",
|
||||
"idle": "等待升级病毒库",
|
||||
"updatingDb": "正在升级病毒库...",
|
||||
"readyToScan": "病毒库已就绪",
|
||||
"scanning": "正在扫描中...",
|
||||
"scanComplete": "扫描完成",
|
||||
"filesScanned": "扫描文件数",
|
||||
"threatsFound": "发现 {{count}} 个威胁",
|
||||
"noThreats": "未发现威胁",
|
||||
"neverScanned": "暂未扫描",
|
||||
"updateDbFirst": "请先升级病毒库",
|
||||
"clickToScan": "病毒库已就绪,可以开始扫描",
|
||||
"scanResult": "扫描已完成",
|
||||
"scanSummary": "共扫描 {{files}} 个文件,耗时 {{time}} 秒"
|
||||
},
|
||||
"faq": {
|
||||
"sections": {
|
||||
"install": "安装相关",
|
||||
"usage": "使用相关",
|
||||
"key": "Key相关",
|
||||
"security": "安全相关",
|
||||
"other": "其他"
|
||||
},
|
||||
"q": {
|
||||
"howToInstall": "如何安装89猫?",
|
||||
"systemRequirements": "89猫的系统要求是什么?",
|
||||
"howToUse": "如何开始使用89猫?",
|
||||
"stopRestart": "如何停止或重启89猫?",
|
||||
"whereToGetKey": "从哪里获取API Key?",
|
||||
"keyNotWorking": "Key显示无效怎么办?",
|
||||
"isMyDataSafe": "我的数据安全吗?",
|
||||
"howToScan": "如何进行安全检查?",
|
||||
"contactUs": "如何联系客服?"
|
||||
},
|
||||
"a": {
|
||||
"howToInstall": "从官方网站下载对应平台的安装包,双击运行即可自动完成安装。安装完成后桌面和开始菜单都会出现89猫图标。",
|
||||
"systemRequirements": "支持 Windows 10+、macOS 11+、Linux (Ubuntu 20.04+)。最低需要4GB内存,推荐8GB。需要1GB可用磁盘空间。",
|
||||
"howToUse": "在首页的\"工作钥匙\"卡片中输入你的API Key并点击验证,验证通过后即可在聊天页面与AI对话。",
|
||||
"stopRestart": "进入\"管理中心\"页面,点击\"停止使用89猫\"按钮即可停止服务,再次点击\"打开使用89猫\"即可重新启动。",
|
||||
"whereToGetKey": "请访问 89.is89.com 注册账号并获取API Key。",
|
||||
"keyNotWorking": "请检查Key是否输入正确(注意大小写和空格),确认Key未过期,确认网络连接正常。如仍有问题,请联系客服。",
|
||||
"isMyDataSafe": "89猫使用系统原生密钥链安全存储你的API Key。管理中心运行在云端,你的个人数据保留在本地,隐私受到充分保护。",
|
||||
"howToScan": "进入\"安全检查\"页面,先点击\"升级病毒库\",升级完成后点击\"开始扫描\"即可对系统进行全面安全扫描。",
|
||||
"contactUs": "请发送邮件至 public@valuecell.ai 或访问 89.is89.com 获取更多帮助。"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
273
src/pages/Dashboard/index.tsx
Normal file
273
src/pages/Dashboard/index.tsx
Normal file
@@ -0,0 +1,273 @@
|
||||
/**
|
||||
* Dashboard page — main landing page with 7 feature entry cards
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Building2,
|
||||
Coins,
|
||||
Puzzle,
|
||||
ShieldCheck,
|
||||
KeyRound,
|
||||
HelpCircle,
|
||||
Globe,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Check,
|
||||
X,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useSettingsStore } from '@/stores/settings';
|
||||
import { SUPPORTED_LANGUAGES } from '@/i18n';
|
||||
import { hostApiFetch } from '@/lib/host-api';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function ApiKeyCard() {
|
||||
const { t } = useTranslation('dashboard');
|
||||
const [showKey, setShowKey] = useState(false);
|
||||
const [key, setKey] = useState('');
|
||||
const [validating, setValidating] = useState(false);
|
||||
const [result, setResult] = useState<'idle' | 'valid' | 'invalid'>('idle');
|
||||
const [errorMsg, setErrorMsg] = useState('');
|
||||
|
||||
const handleValidate = async () => {
|
||||
const trimmed = key.trim();
|
||||
if (!trimmed) return;
|
||||
setValidating(true);
|
||||
setResult('idle');
|
||||
setErrorMsg('');
|
||||
try {
|
||||
const res = await hostApiFetch<{ valid: boolean; message?: string }>(
|
||||
'/api/provider-accounts/validate',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
vendorId: 'cat89',
|
||||
apiKey: trimmed,
|
||||
}),
|
||||
},
|
||||
);
|
||||
if (res.valid) {
|
||||
setResult('valid');
|
||||
} else {
|
||||
setResult('invalid');
|
||||
setErrorMsg(res.message || t('apiKey.invalidKey'));
|
||||
}
|
||||
} catch {
|
||||
setResult('invalid');
|
||||
setErrorMsg(t('apiKey.validateFailed'));
|
||||
} finally {
|
||||
setValidating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
void handleValidate();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
type={showKey ? 'text' : 'password'}
|
||||
placeholder={t('apiKey.placeholder')}
|
||||
value={key}
|
||||
onChange={(e) => {
|
||||
setKey(e.target.value);
|
||||
setResult('idle');
|
||||
setErrorMsg('');
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={cn(
|
||||
'pr-10 font-mono',
|
||||
result === 'valid' && 'border-green-500 focus-visible:ring-green-500',
|
||||
result === 'invalid' && 'border-red-500 focus-visible:ring-red-500',
|
||||
)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowKey(!showKey)}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{showKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => { void handleValidate(); }}
|
||||
disabled={validating || !key.trim()}
|
||||
className="shrink-0"
|
||||
>
|
||||
{validating ? <Loader2 className="h-4 w-4 animate-spin" /> : t('apiKey.validate')}
|
||||
</Button>
|
||||
</div>
|
||||
{result === 'valid' && (
|
||||
<div className="flex items-center gap-1 text-sm text-green-600 dark:text-green-400">
|
||||
<Check className="h-4 w-4" />
|
||||
<span>{t('apiKey.valid')}</span>
|
||||
</div>
|
||||
)}
|
||||
{result === 'invalid' && (
|
||||
<div className="flex items-center gap-1 text-sm text-red-600 dark:text-red-400">
|
||||
<X className="h-4 w-4" />
|
||||
<span>{errorMsg}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface DashboardCardProps {
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
description: string;
|
||||
onClick: () => void;
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
function DashboardCard({ icon, title, description, onClick, testId }: DashboardCardProps) {
|
||||
return (
|
||||
<button
|
||||
data-testid={testId}
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'group flex flex-col items-center gap-3 rounded-2xl border border-border/60 p-6',
|
||||
'bg-card text-left transition-all',
|
||||
'hover:border-primary/30 hover:bg-accent/30 hover:shadow-md',
|
||||
'active:scale-[0.98]',
|
||||
)}
|
||||
>
|
||||
<div className="flex h-14 w-14 items-center justify-center rounded-xl bg-primary/10 text-primary transition-colors group-hover:bg-primary/20 group-hover:scale-110">
|
||||
{icon}
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h3 className="text-base font-semibold text-foreground">{title}</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function Dashboard() {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation('dashboard');
|
||||
const language = useSettingsStore((s) => s.language);
|
||||
const setLanguage = useSettingsStore((s) => s.setLanguage);
|
||||
|
||||
// Filter to only zh and en for 89猫
|
||||
const visibleLanguages = SUPPORTED_LANGUAGES.filter(
|
||||
(lang) => lang.code === 'zh' || lang.code === 'en',
|
||||
);
|
||||
|
||||
const cards: DashboardCardProps[] = [
|
||||
{
|
||||
icon: <Building2 className="h-7 w-7" strokeWidth={1.5} />,
|
||||
title: t('cards.management.title'),
|
||||
description: t('cards.management.desc'),
|
||||
onClick: () => navigate('/management'),
|
||||
testId: 'dashboard-card-management',
|
||||
},
|
||||
{
|
||||
icon: <Coins className="h-7 w-7" strokeWidth={1.5} />,
|
||||
title: t('cards.tokenRecharge.title'),
|
||||
description: t('cards.tokenRecharge.desc'),
|
||||
onClick: () => window.electron.openExternal('https://89.is89.com'),
|
||||
testId: 'dashboard-card-token',
|
||||
},
|
||||
{
|
||||
icon: <Puzzle className="h-7 w-7" strokeWidth={1.5} />,
|
||||
title: t('cards.plugins.title'),
|
||||
description: t('cards.plugins.desc'),
|
||||
onClick: () => navigate('/skills'),
|
||||
testId: 'dashboard-card-plugins',
|
||||
},
|
||||
{
|
||||
icon: <ShieldCheck className="h-7 w-7" strokeWidth={1.5} />,
|
||||
title: t('cards.security.title'),
|
||||
description: t('cards.security.desc'),
|
||||
onClick: () => navigate('/security'),
|
||||
testId: 'dashboard-card-security',
|
||||
},
|
||||
{
|
||||
icon: <HelpCircle className="h-7 w-7" strokeWidth={1.5} />,
|
||||
title: t('cards.faq.title'),
|
||||
description: t('cards.faq.desc'),
|
||||
onClick: () => navigate('/faq'),
|
||||
testId: 'dashboard-card-faq',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl space-y-8 py-4">
|
||||
{/* Header */}
|
||||
<div className="text-center">
|
||||
<h1 className="text-3xl font-serif font-normal tracking-tight text-foreground">
|
||||
{t('title')}
|
||||
</h1>
|
||||
<p className="mt-2 text-muted-foreground">{t('subtitle')}</p>
|
||||
</div>
|
||||
|
||||
{/* Feature Cards Grid */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{cards.map((card) => (
|
||||
<DashboardCard key={card.testId} {...card} />
|
||||
))}
|
||||
|
||||
{/* API Key Card — inline with input */}
|
||||
<div className="flex flex-col gap-3 rounded-2xl border border-border/60 bg-card p-6 sm:col-span-2 lg:col-span-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary">
|
||||
<KeyRound className="h-5 w-5" strokeWidth={1.5} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-foreground">
|
||||
{t('cards.apiKey.title')}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('cards.apiKey.desc')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<ApiKeyCard />
|
||||
</div>
|
||||
|
||||
{/* Language Selector Card */}
|
||||
<div className="flex flex-col items-center gap-3 rounded-2xl border border-border/60 bg-card p-6 sm:col-span-2 lg:col-span-3">
|
||||
<div className="flex items-center gap-3 self-start">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary">
|
||||
<Globe className="h-5 w-5" strokeWidth={1.5} />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<h3 className="text-base font-semibold text-foreground">
|
||||
{t('cards.language.title')}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('cards.language.desc')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-full flex-wrap gap-2">
|
||||
{visibleLanguages.map((lang) => (
|
||||
<Button
|
||||
key={lang.code}
|
||||
variant={language === lang.code ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setLanguage(lang.code)}
|
||||
>
|
||||
{lang.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
187
src/pages/Faq/index.tsx
Normal file
187
src/pages/Faq/index.tsx
Normal file
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* FAQ page — accordion-style Q&A grouped by category
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ChevronDown, HelpCircle } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface FaqItem {
|
||||
q: string;
|
||||
a: string;
|
||||
}
|
||||
|
||||
interface FaqSection {
|
||||
title: string;
|
||||
items: FaqItem[];
|
||||
}
|
||||
|
||||
function FaqAccordion({ item, defaultOpen }: { item: FaqItem; defaultOpen: boolean }) {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-border/60 bg-card overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(!open)}
|
||||
className="flex w-full items-center justify-between gap-3 px-5 py-4 text-left transition-colors hover:bg-accent/30"
|
||||
>
|
||||
<span className="text-sm font-medium text-foreground">{item.q}</span>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'h-4 w-4 shrink-0 text-muted-foreground transition-transform',
|
||||
open && 'rotate-180',
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="px-5 pb-4">
|
||||
<p className="text-sm text-muted-foreground leading-relaxed whitespace-pre-wrap">
|
||||
{item.a}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FaqSectionBlock({ section, defaultOpen }: { section: FaqSection; defaultOpen: boolean }) {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(!open)}
|
||||
className="flex w-full items-center gap-2 text-left"
|
||||
>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'h-4 w-4 text-muted-foreground transition-transform',
|
||||
open && 'rotate-180',
|
||||
)}
|
||||
/>
|
||||
<h2 className="text-lg font-semibold text-foreground">{section.title}</h2>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="space-y-2">
|
||||
{section.items.map((item, idx) => (
|
||||
<FaqAccordion key={idx} item={item} defaultOpen={idx === 0 && defaultOpen} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Faq() {
|
||||
const { t } = useTranslation('dashboard');
|
||||
|
||||
const sections: FaqSection[] = [
|
||||
{
|
||||
title: t('faq.sections.install', '安装相关'),
|
||||
items: [
|
||||
{
|
||||
q: t('faq.q.howToInstall', '如何安装89猫?'),
|
||||
a: t(
|
||||
'faq.a.howToInstall',
|
||||
'从官方网站下载对应平台的安装包,双击运行即可自动完成安装。安装完成后桌面和开始菜单都会出现89猫图标。',
|
||||
),
|
||||
},
|
||||
{
|
||||
q: t('faq.q.systemRequirements', '89猫的系统要求是什么?'),
|
||||
a: t(
|
||||
'faq.a.systemRequirements',
|
||||
'支持 Windows 10+、macOS 11+、Linux (Ubuntu 20.04+)。最低需要4GB内存,推荐8GB。需要1GB可用磁盘空间。',
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t('faq.sections.usage', '使用相关'),
|
||||
items: [
|
||||
{
|
||||
q: t('faq.q.howToUse', '如何开始使用89猫?'),
|
||||
a: t(
|
||||
'faq.a.howToUse',
|
||||
'在首页的"工作钥匙"卡片中输入你的API Key并点击验证,验证通过后即可在聊天页面与AI对话。',
|
||||
),
|
||||
},
|
||||
{
|
||||
q: t('faq.q.stopRestart', '如何停止或重启89猫?'),
|
||||
a: t(
|
||||
'faq.a.stopRestart',
|
||||
'进入"管理中心"页面,点击"停止使用89猫"按钮即可停止服务,再次点击"打开使用89猫"即可重新启动。',
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t('faq.sections.key', 'Key相关'),
|
||||
items: [
|
||||
{
|
||||
q: t('faq.q.whereToGetKey', '从哪里获取API Key?'),
|
||||
a: t('faq.a.whereToGetKey', '请访问 89.is89.com 注册账号并获取API Key。'),
|
||||
},
|
||||
{
|
||||
q: t('faq.q.keyNotWorking', 'Key显示无效怎么办?'),
|
||||
a: t(
|
||||
'faq.a.keyNotWorking',
|
||||
'请检查Key是否输入正确(注意大小写和空格),确认Key未过期,确认网络连接正常。如仍有问题,请联系客服。',
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t('faq.sections.security', '安全相关'),
|
||||
items: [
|
||||
{
|
||||
q: t('faq.q.isMyDataSafe', '我的数据安全吗?'),
|
||||
a: t(
|
||||
'faq.a.isMyDataSafe',
|
||||
'89猫使用系统原生密钥链安全存储你的API Key。管理中心运行在云端,你的个人数据保留在本地,隐私受到充分保护。',
|
||||
),
|
||||
},
|
||||
{
|
||||
q: t('faq.q.howToScan', '如何进行安全检查?'),
|
||||
a: t(
|
||||
'faq.a.howToScan',
|
||||
'进入"安全检查"页面,先点击"升级病毒库",升级完成后点击"开始扫描"即可对系统进行全面安全扫描。',
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t('faq.sections.other', '其他'),
|
||||
items: [
|
||||
{
|
||||
q: t('faq.q.contactUs', '如何联系客服?'),
|
||||
a: t(
|
||||
'faq.a.contactUs',
|
||||
'请发送邮件至 public@valuecell.ai 或访问 89.is89.com 获取更多帮助。',
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl space-y-8 py-4">
|
||||
<div className="text-center">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<HelpCircle className="h-7 w-7 text-primary" strokeWidth={1.5} />
|
||||
<h1 className="text-3xl font-serif font-normal tracking-tight text-foreground">
|
||||
{t('cards.faq.title')}
|
||||
</h1>
|
||||
</div>
|
||||
<p className="mt-2 text-muted-foreground">{t('cards.faq.desc')}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{sections.map((section, idx) => (
|
||||
<FaqSectionBlock key={idx} section={section} defaultOpen={idx === 0} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
92
src/pages/Management/index.tsx
Normal file
92
src/pages/Management/index.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Management Center page — Gateway control + admin console link
|
||||
*/
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ExternalLink, Power, PowerOff, Loader2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { useGatewayStore } from '@/stores/gateway';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export function Management() {
|
||||
const { t } = useTranslation('dashboard');
|
||||
const gatewayStatus = useGatewayStore((s) => s.status);
|
||||
const startGateway = useGatewayStore((s) => s.start);
|
||||
const stopGateway = useGatewayStore((s) => s.stop);
|
||||
|
||||
const isRunning = gatewayStatus.state === 'running';
|
||||
const isTransitioning = gatewayStatus.state === 'starting';
|
||||
|
||||
const handleToggle = async () => {
|
||||
if (isRunning) {
|
||||
await stopGateway();
|
||||
} else {
|
||||
await startGateway();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl space-y-8 py-4">
|
||||
<div className="text-center">
|
||||
<h1 className="text-3xl font-serif font-normal tracking-tight text-foreground">
|
||||
{t('cards.management.title')}
|
||||
</h1>
|
||||
<p className="mt-2 text-muted-foreground">{t('cards.management.desc')}</p>
|
||||
</div>
|
||||
|
||||
{/* Gateway Toggle Card */}
|
||||
<div className="rounded-2xl border border-border/60 bg-card p-8 text-center space-y-4">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
'px-3 py-1 text-sm',
|
||||
isRunning && 'border-green-500 text-green-600 dark:text-green-400',
|
||||
!isRunning && 'border-red-500 text-red-600 dark:text-red-400',
|
||||
)}
|
||||
>
|
||||
{isRunning ? t('gatewayRunning', '89猫运行中') : t('gatewayStopped', '89猫已停止')}
|
||||
</Badge>
|
||||
</div>
|
||||
<Button
|
||||
size="lg"
|
||||
variant={isRunning ? 'destructive' : 'default'}
|
||||
disabled={isTransitioning}
|
||||
onClick={() => { void handleToggle(); }}
|
||||
className="min-w-40"
|
||||
>
|
||||
{isTransitioning ? (
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
) : isRunning ? (
|
||||
<PowerOff className="h-5 w-5" />
|
||||
) : (
|
||||
<Power className="h-5 w-5" />
|
||||
)}
|
||||
<span className="ml-2">
|
||||
{isRunning
|
||||
? t('stop89cat', '停止使用89猫')
|
||||
: t('start89cat', '打开使用89猫')}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Admin Console Link */}
|
||||
<div className="rounded-2xl border border-border/60 bg-card p-8 text-center space-y-4">
|
||||
<h2 className="text-xl font-semibold text-foreground">
|
||||
{t('adminConsole', '89超级人工智能脑管理中心')}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('adminConsoleDesc', '管理中心运行在云端,保护您的个人隐私')}
|
||||
</p>
|
||||
<Button
|
||||
size="lg"
|
||||
variant="outline"
|
||||
onClick={() => window.electron.openExternal('https://89.is89.com')}
|
||||
>
|
||||
<ExternalLink className="h-5 w-5" />
|
||||
<span className="ml-2">{t('openAdminConsole', '前往管理中心')}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
260
src/pages/Security/index.tsx
Normal file
260
src/pages/Security/index.tsx
Normal file
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* Security Check page — static UI with simulated scan flow
|
||||
*/
|
||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ShieldCheck, Download, Play, Check, Loader2, Clock, FileSearch } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type ScanStep = 'idle' | 'updating' | 'ready' | 'scanning' | 'done';
|
||||
|
||||
const SCAN_PATHS = [
|
||||
'C:\\Windows\\System32\\',
|
||||
'C:\\Program Files\\',
|
||||
'C:\\Users\\Documents\\',
|
||||
'D:\\Downloads\\',
|
||||
'C:\\Temp\\',
|
||||
];
|
||||
|
||||
const getRandomThreats = () => Math.floor(Math.random() * 5);
|
||||
|
||||
export function Security() {
|
||||
const { t } = useTranslation('dashboard');
|
||||
|
||||
const [step, setStep] = useState<ScanStep>('idle');
|
||||
const [updateProgress, setUpdateProgress] = useState(0);
|
||||
const [scanProgress, setScanProgress] = useState(0);
|
||||
const [currentPath, setCurrentPath] = useState('');
|
||||
const [filesScanned, setFilesScanned] = useState(0);
|
||||
const [threatsFound, setThreatsFound] = useState(0);
|
||||
const [scanTime, setScanTime] = useState(0);
|
||||
const [lastScanTime, setLastScanTime] = useState<string>(t('security.neverScanned'));
|
||||
const [virusDbVersion, setVirusDbVersion] = useState('2026.06.01-v1');
|
||||
|
||||
const updateTimerRef = useRef<ReturnType<typeof setInterval>>();
|
||||
const scanTimerRef = useRef<ReturnType<typeof setInterval>>();
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearInterval(updateTimerRef.current);
|
||||
clearInterval(scanTimerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleUpdateDb = useCallback(() => {
|
||||
setStep('updating');
|
||||
setUpdateProgress(0);
|
||||
|
||||
updateTimerRef.current = setInterval(() => {
|
||||
setUpdateProgress((prev) => {
|
||||
if (prev >= 100) {
|
||||
clearInterval(updateTimerRef.current);
|
||||
setStep('ready');
|
||||
setVirusDbVersion(`2026.06.04-v${Math.floor(Math.random() * 10) + 1}`);
|
||||
return 100;
|
||||
}
|
||||
return prev + Math.random() * 15 + 5;
|
||||
});
|
||||
}, 300);
|
||||
}, []);
|
||||
|
||||
const handleStartScan = useCallback(() => {
|
||||
setStep('scanning');
|
||||
setScanProgress(0);
|
||||
setFilesScanned(0);
|
||||
setThreatsFound(0);
|
||||
setScanTime(0);
|
||||
setCurrentPath(SCAN_PATHS[0]);
|
||||
|
||||
const startTime = Date.now();
|
||||
let pathIndex = 0;
|
||||
|
||||
scanTimerRef.current = setInterval(() => {
|
||||
setScanProgress((prev) => {
|
||||
const next = prev + Math.random() * 4 + 1;
|
||||
if (next >= 100) {
|
||||
clearInterval(scanTimerRef.current);
|
||||
setStep('done');
|
||||
setFilesScanned(Math.floor(Math.random() * 5000) + 10000);
|
||||
setThreatsFound(getRandomThreats());
|
||||
setScanTime(Math.floor((Date.now() - startTime) / 1000));
|
||||
setLastScanTime(new Date().toLocaleString());
|
||||
setCurrentPath('');
|
||||
return 100;
|
||||
}
|
||||
|
||||
// Rotate scan paths
|
||||
const newPathIndex = Math.min(
|
||||
Math.floor((next / 100) * SCAN_PATHS.length),
|
||||
SCAN_PATHS.length - 1,
|
||||
);
|
||||
if (newPathIndex !== pathIndex) {
|
||||
pathIndex = newPathIndex;
|
||||
setCurrentPath(SCAN_PATHS[pathIndex]);
|
||||
}
|
||||
|
||||
setFilesScanned((prev2) => prev2 + Math.floor(Math.random() * 50) + 10);
|
||||
return next;
|
||||
});
|
||||
}, 200);
|
||||
}, []);
|
||||
|
||||
const getStepLabel = () => {
|
||||
switch (step) {
|
||||
case 'idle':
|
||||
return t('security.idle');
|
||||
case 'updating':
|
||||
return t('security.updatingDb');
|
||||
case 'ready':
|
||||
return t('security.readyToScan');
|
||||
case 'scanning':
|
||||
return t('security.scanning');
|
||||
case 'done':
|
||||
return t('security.scanComplete');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl space-y-8 py-4">
|
||||
<div className="text-center">
|
||||
<h1 className="text-3xl font-serif font-normal tracking-tight text-foreground">
|
||||
{t('cards.security.title')}
|
||||
</h1>
|
||||
<p className="mt-2 text-muted-foreground">{t('cards.security.desc')}</p>
|
||||
</div>
|
||||
|
||||
{/* Virus DB Status */}
|
||||
<div className="rounded-2xl border border-border/60 bg-card p-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-semibold text-foreground">{t('security.virusDbVersion')}</h3>
|
||||
<p className="text-sm text-muted-foreground">{virusDbVersion}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Clock className="h-4 w-4" />
|
||||
<span>
|
||||
{t('security.lastScan')}: {lastScanTime}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleUpdateDb}
|
||||
disabled={step === 'updating' || step === 'scanning'}
|
||||
className="w-full"
|
||||
variant="outline"
|
||||
>
|
||||
{step === 'updating' ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Download className="h-4 w-4" />
|
||||
)}
|
||||
<span className="ml-2">{t('security.updateDb')}</span>
|
||||
</Button>
|
||||
|
||||
{step === 'updating' && (
|
||||
<div className="space-y-1">
|
||||
<Progress value={updateProgress} className="h-2" />
|
||||
<p className="text-xs text-muted-foreground text-right">
|
||||
{Math.floor(updateProgress)}%
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Scan Section */}
|
||||
<div className="rounded-2xl border border-border/60 bg-card p-6 space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-primary/10 text-primary">
|
||||
<ShieldCheck className="h-5 w-5" strokeWidth={1.5} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-foreground">{getStepLabel()}</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{step === 'idle' && t('security.updateDbFirst', '请先升级病毒库')}
|
||||
{step === 'ready' && t('security.clickToScan', '病毒库已就绪,可以开始扫描')}
|
||||
{step === 'scanning' && currentPath}
|
||||
{step === 'done' && t('security.scanResult', '扫描已完成')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleStartScan}
|
||||
disabled={step !== 'ready' || step === 'scanning'}
|
||||
className="w-full"
|
||||
variant={step === 'done' ? 'outline' : 'default'}
|
||||
>
|
||||
{step === 'scanning' ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Play className="h-4 w-4" />
|
||||
)}
|
||||
<span className="ml-2">{t('security.startScan')}</span>
|
||||
</Button>
|
||||
|
||||
{/* Scan Progress */}
|
||||
{(step === 'scanning' || step === 'done') && (
|
||||
<div className="space-y-3">
|
||||
<Progress
|
||||
value={scanProgress}
|
||||
className={cn('h-2', step === 'done' && '[&>div]:bg-green-500')}
|
||||
/>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
<FileSearch className="inline h-3.5 w-3.5 mr-1" />
|
||||
{t('security.filesScanned')}: {filesScanned.toLocaleString()}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
{Math.floor(scanProgress)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Result */}
|
||||
{step === 'done' && (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-xl border p-4 text-center',
|
||||
threatsFound > 0
|
||||
? 'border-red-200 bg-red-50 dark:border-red-800 dark:bg-red-950'
|
||||
: 'border-green-200 bg-green-50 dark:border-green-800 dark:bg-green-950',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Check
|
||||
className={cn(
|
||||
'h-5 w-5',
|
||||
threatsFound > 0
|
||||
? 'text-red-600 dark:text-red-400'
|
||||
: 'text-green-600 dark:text-green-400',
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
'font-semibold',
|
||||
threatsFound > 0
|
||||
? 'text-red-700 dark:text-red-300'
|
||||
: 'text-green-700 dark:text-green-300',
|
||||
)}
|
||||
>
|
||||
{threatsFound > 0
|
||||
? t('security.threatsFound', { count: threatsFound })
|
||||
: t('security.noThreats')}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{t('security.scanSummary', {
|
||||
files: filesScanned.toLocaleString(),
|
||||
time: scanTime,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user