refactor: 标准化项目结构 frontend/ + backend/ + SQLite + run.sh

- 目录重构: app/ -> frontend/,与 backend/ 并列
- 数据库: 默认 SQLite(零配置),修复 DSN() bug
- run.sh: 一键启动前后端,支持 start/stop/restart/status
- Makefile: install/dev/build/lint/clean 快捷命令
- vite.config.ts: 配置 dev server 端口 5173 + API 代理到 8080
- config.yaml: SQLite 默认配置,开箱即用
- docs/: 完整开发文档(项目结构/API/数据库/规范/部署)
- 修复: GORM foreign key 关联声明
- 修复: go get gorm.io/driver/sqlite 依赖
This commit is contained in:
attacker committed 2026-06-13 23:34:23 +08:00
1 parent 7d6674bddd
commit 97c4207ff8
132 files changed
+1572 -34

No files matched your search

+365
View File
@@ -0,0 +1,365 @@
import { useState, useRef, useEffect } from 'react';
import { cn } from '@/lib/utils';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import {
Terminal,
Plus,
X,
Server,
Play,
Pause,
Square,
Download,
Upload,
Copy,
Settings,
Monitor,
RefreshCw,
Maximize2,
Minimize2,
} from 'lucide-react';
import { servers } from '@/mocks/data';
interface TerminalSession {
id: string;
serverId: string;
serverName: string;
serverIp: string;
connected: boolean;
logs: string[];
}
export function WebShell() {
const [sessions, setSessions] = useState<TerminalSession[]>([]);
const [activeSession, setActiveSession] = useState<string | null>(null);
const [selectedServer, setSelectedServer] = useState<string>('');
const [command, setCommand] = useState('');
const [isFullscreen, setIsFullscreen] = useState(false);
const terminalRef = useRef<HTMLDivElement>(null);
const runningServers = servers.filter((s) => s.status === 'running');
const createSession = () => {
if (!selectedServer) return;
const server = servers.find((s) => s.id === selectedServer);
if (!server) return;
const newSession: TerminalSession = {
id: `session-${Date.now()}`,
serverId: server.id,
serverName: server.hostname,
serverIp: server.ip,
connected: true,
logs: [
`Connecting to ${server.hostname} (${server.ip})...`,
`Connection established.`,
`Last login: ${new Date().toLocaleString()} from 192.168.1.100`,
`[root@${server.hostname} ~]# `,
],
};
setSessions([...sessions, newSession]);
setActiveSession(newSession.id);
setSelectedServer('');
};
const closeSession = (sessionId: string) => {
const newSessions = sessions.filter((s) => s.id !== sessionId);
setSessions(newSessions);
if (activeSession === sessionId) {
setActiveSession(newSessions.length > 0 ? newSessions[0].id : null);
}
};
const executeCommand = () => {
if (!command.trim() || !activeSession) return;
const session = sessions.find((s) => s.id === activeSession);
if (!session) return;
const newLogs = [...session.logs, command];
// 模拟命令执行结果
const mockResponses: Record<string, string> = {
ls: 'total 32\ndrwxr-xr-x 5 root root 4096 Mar 30 10:00 .\ndrwxr-xr-x 18 root root 4096 Mar 30 09:00 ..\n-rw-r--r-- 1 root root 220 Mar 30 08:00 .bash_logout\n-rw-r--r-- 1 root root 3771 Mar 30 08:00 .bashrc\n-rw-r--r-- 1 root root 807 Mar 30 08:00 .profile\ndrwxr-xr-x 2 root root 4096 Mar 30 09:00 scripts\ndrwxr-xr-x 3 root root 4096 Mar 30 09:00 logs',
pwd: '/root',
'uname -a': 'Linux web-server-01 5.4.210-1.el7.elrepo.x86_64 #1 SMP x86_64 x86_64 x86_64 GNU/Linux',
'df -h': 'Filesystem Size Used Avail Use% Mounted on\n/dev/sda1 500G 180G 320G 36% /\n/dev/sdb1 1.0T 450G 550G 45% /data',
'free -m': ' total used free shared buff/cache available\nMem: 65536 42000 8000 1024 15536 21000\nSwap: 8192 0 8192',
'ps aux': 'USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND\nroot 1 0.0 0.1 18532 3200 ? Ss Mar30 0:03 /sbin/init\nroot 512 0.1 0.5 45200 12800 ? Ss Mar30 2:15 nginx: master process\nroot 1024 0.5 2.0 256000 51200 ? Sl Mar30 15:30 java -jar app.jar',
top: 'top - 10:30:00 up 30 days, 12:00, 1 user, load average: 0.52, 0.58, 0.59\nTasks: 156 total, 1 running, 155 sleeping, 0 stopped, 0 zombie\n%Cpu(s): 25.0 us, 5.0 sy, 0.0 ni, 68.0 id, 0.0 wa, 0.0 hi, 2.0 si, 0.0 st',
};
const cmd = command.trim().split(' ')[0];
const response = mockResponses[command.trim()] || mockResponses[cmd] || `${cmd}: command executed successfully`;
newLogs.push(response);
newLogs.push(`[${session.serverName} ~]# `);
setSessions(
sessions.map((s) => (s.id === activeSession ? { ...s, logs: newLogs } : s))
);
setCommand('');
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
executeCommand();
}
};
// 自动滚动到底部
useEffect(() => {
if (terminalRef.current) {
terminalRef.current.scrollTop = terminalRef.current.scrollHeight;
}
}, [sessions, activeSession]);
const activeSessionData = sessions.find((s) => s.id === activeSession);
return (
<div className={cn('space-y-4', isFullscreen && 'fixed inset-0 z-50 bg-slate-950 p-4')}>
{/* Page Header */}
{!isFullscreen && (
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-slate-100">WebShell</h1>
<p className="text-sm text-slate-500 mt-1">远程服务器终端管理</p>
</div>
<div className="flex items-center gap-2">
<Dialog>
<DialogTrigger asChild>
<Button className="bg-blue-600 hover:bg-blue-700 text-white">
<Plus className="h-4 w-4 mr-2" />
新建连接
</Button>
</DialogTrigger>
<DialogContent className="bg-slate-900 border-slate-800">
<DialogHeader>
<DialogTitle className="text-slate-100">新建终端连接</DialogTitle>
<DialogDescription className="text-slate-500">
选择要连接的服务器
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-300">选择服务器</label>
<Select value={selectedServer} onValueChange={setSelectedServer}>
<SelectTrigger className="bg-slate-800 border-slate-700 text-slate-200">
<SelectValue placeholder="选择服务器" />
</SelectTrigger>
<SelectContent className="bg-slate-800 border-slate-700">
{runningServers.map((server) => (
<SelectItem key={server.id} value={server.id}>
<div className="flex items-center gap-2">
<Server className="h-4 w-4" />
<span>{server.hostname}</span>
<span className="text-slate-500">({server.ip})</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button
onClick={createSession}
disabled={!selectedServer}
className="bg-blue-600 hover:bg-blue-700 text-white"
>
<Terminal className="h-4 w-4 mr-2" />
连接
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</div>
)}
{/* Terminal Container */}
<Card className="bg-slate-900 border-slate-800 flex-1">
<CardHeader className="pb-2 border-b border-slate-800">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
{sessions.length > 0 ? (
<div className="flex items-center gap-1">
{sessions.map((session) => (
<button
key={session.id}
onClick={() => setActiveSession(session.id)}
className={cn(
'flex items-center gap-2 px-3 py-1.5 text-sm rounded-md transition-colors',
activeSession === session.id
? 'bg-slate-800 text-slate-100'
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/50'
)}
>
<div
className={cn(
'w-2 h-2 rounded-full',
session.connected ? 'bg-emerald-400' : 'bg-red-400'
)}
/>
<span className="max-w-[120px] truncate">{session.serverName}</span>
<button
onClick={(e) => {
e.stopPropagation();
closeSession(session.id);
}}
className="ml-1 p-0.5 rounded hover:bg-slate-700"
>
<X className="h-3 w-3" />
</button>
</button>
))}
</div>
) : (
<span className="text-sm text-slate-500">暂无连接</span>
)}
</div>
<div className="flex items-center gap-1">
{activeSessionData && (
<>
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-slate-100">
<Copy className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-slate-100">
<Download className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-slate-100">
<Upload className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-slate-100">
<Settings className="h-4 w-4" />
</Button>
</>
)}
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-slate-400 hover:text-slate-100"
onClick={() => setIsFullscreen(!isFullscreen)}
>
{isFullscreen ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
</Button>
</div>
</div>
</CardHeader>
<CardContent className="p-0">
{activeSessionData ? (
<div className="flex flex-col" style={{ height: isFullscreen ? 'calc(100vh - 140px)' : '500px' }}>
{/* Terminal Output */}
<div
ref={terminalRef}
className="flex-1 p-4 font-mono text-sm bg-slate-950 overflow-auto"
>
{activeSessionData.logs.map((log, index) => (
<div key={index} className="whitespace-pre-wrap text-slate-300 leading-relaxed">
{log.startsWith('[') && log.endsWith(']# ') ? (
<span>
<span className="text-emerald-400">{log.split('#')[0]}#</span>
<span className="text-slate-100">{log.split('#')[1]}</span>
</span>
) : log.startsWith('total') || log.startsWith('drwx') || log.startsWith('-rw') ? (
<span className="text-slate-400">{log}</span>
) : log.startsWith('Filesystem') || log.startsWith('/dev') ? (
<span className="text-slate-400">{log}</span>
) : log.startsWith('USER') || log.startsWith('root') ? (
<span className="text-slate-400">{log}</span>
) : log.startsWith('Connecting') || log.startsWith('Connection') || log.startsWith('Last login') ? (
<span className="text-slate-500">{log}</span>
) : (
<span className="text-slate-300">{log}</span>
)}
</div>
))}
</div>
{/* Command Input */}
<div className="flex items-center gap-2 p-3 border-t border-slate-800 bg-slate-900">
<span className="text-emerald-400 font-mono text-sm whitespace-nowrap">
[{activeSessionData.serverName} ~]#
</span>
<Input
value={command}
onChange={(e) => setCommand(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="输入命令..."
className="flex-1 bg-transparent border-0 text-slate-200 font-mono text-sm placeholder:text-slate-600 focus-visible:ring-0 focus-visible:ring-offset-0"
autoFocus
/>
<Button
size="sm"
onClick={executeCommand}
className="bg-blue-600 hover:bg-blue-700 text-white"
>
<Play className="h-4 w-4" />
</Button>
</div>
</div>
) : (
<div
className="flex flex-col items-center justify-center text-slate-500"
style={{ height: isFullscreen ? 'calc(100vh - 140px)' : '500px' }}
>
<Terminal className="h-16 w-16 mb-4 opacity-50" />
<p className="text-lg font-medium">暂无活动连接</p>
<p className="text-sm mt-1">点击"新建连接"开始远程会话</p>
</div>
)}
</CardContent>
</Card>
{/* Quick Commands */}
{activeSessionData && (
<Card className="bg-slate-900 border-slate-800">
<CardHeader className="pb-2">
<CardTitle className="text-sm text-slate-400">快捷命令</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
{['ls -la', 'pwd', 'df -h', 'free -m', 'ps aux', 'top', 'netstat -tlnp', 'systemctl status'].map(
(cmd) => (
<Button
key={cmd}
variant="outline"
size="sm"
className="border-slate-700 text-slate-300 hover:text-slate-100 hover:bg-slate-800"
onClick={() => {
setCommand(cmd);
setTimeout(executeCommand, 100);
}}
>
{cmd}
</Button>
)
)}
</div>
</CardContent>
</Card>
)}
</div>
);
}