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:
1 parent
7d6674bddd
commit
97c4207ff8
132 files changed
+1572
-34
No files matched your search
@@ -0,0 +1,378 @@
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Download,
|
||||
Upload,
|
||||
FileSpreadsheet,
|
||||
Check,
|
||||
X,
|
||||
AlertCircle,
|
||||
ChevronRight,
|
||||
ChevronLeft,
|
||||
} from 'lucide-react';
|
||||
import { regions } from '@/mocks/data';
|
||||
|
||||
interface ExcelServerImportProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const connectionMethods = [
|
||||
{ id: 'password', name: '密码认证', description: '使用用户名密码连接' },
|
||||
{ id: 'key', name: '密钥认证', description: '使用SSH密钥连接' },
|
||||
];
|
||||
|
||||
const mockPreviewData = [
|
||||
{ row: 2, hostname: 'web-server-01', ip: '192.168.1.101', port: 22, username: 'root', status: 'valid', message: '验证通过' },
|
||||
{ row: 3, hostname: 'web-server-02', ip: '192.168.1.102', port: 22, username: 'root', status: 'valid', message: '验证通过' },
|
||||
{ row: 4, hostname: 'db-server-01', ip: '192.168.1.201', port: 22, username: 'admin', status: 'warning', message: 'IP格式正确' },
|
||||
{ row: 5, hostname: '', ip: '192.168.1.999', port: 22, username: 'root', status: 'error', message: '主机名不能为空' },
|
||||
{ row: 6, hostname: 'cache-server-01', ip: '192.168.1.103', port: 22, username: 'root', status: 'valid', message: '验证通过' },
|
||||
];
|
||||
|
||||
export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) {
|
||||
const [step, setStep] = useState(1);
|
||||
const [selectedRegion, setSelectedRegion] = useState<string>('');
|
||||
const [connectionMethod, setConnectionMethod] = useState<string>('password');
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [previewData, setPreviewData] = useState<typeof mockPreviewData>([]);
|
||||
|
||||
const handleNext = () => {
|
||||
if (step === 2 && selectedFile) {
|
||||
setIsUploading(true);
|
||||
setTimeout(() => {
|
||||
setIsUploading(false);
|
||||
setPreviewData(mockPreviewData);
|
||||
setStep(step + 1);
|
||||
}, 1500);
|
||||
} else {
|
||||
setStep(step + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
if (step > 1) {
|
||||
setStep(step - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files && e.target.files[0]) {
|
||||
setSelectedFile(e.target.files[0]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImport = () => {
|
||||
setTimeout(() => {
|
||||
onClose();
|
||||
setStep(1);
|
||||
setSelectedRegion('');
|
||||
setConnectionMethod('password');
|
||||
setSelectedFile(null);
|
||||
setPreviewData([]);
|
||||
}, 1500);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
onClose();
|
||||
setStep(1);
|
||||
setSelectedRegion('');
|
||||
setConnectionMethod('password');
|
||||
setSelectedFile(null);
|
||||
setPreviewData([]);
|
||||
};
|
||||
|
||||
const validCount = previewData.filter(d => d.status === 'valid').length;
|
||||
const warningCount = previewData.filter(d => d.status === 'warning').length;
|
||||
const errorCount = previewData.filter(d => d.status === 'error').length;
|
||||
|
||||
const steps = [
|
||||
{ id: 1, title: '配置' },
|
||||
{ id: 2, title: '上传' },
|
||||
{ id: 3, title: '确认' },
|
||||
];
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-3xl bg-slate-900 border-slate-700 text-slate-100">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-xl">Excel导入服务器</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Step Indicator */}
|
||||
<div className="flex items-center justify-center mb-6">
|
||||
{steps.map((s, index) => (
|
||||
<div key={s.id} className="flex items-center">
|
||||
<div className={`flex items-center justify-center w-8 h-8 rounded-full ${
|
||||
step === s.id
|
||||
? 'bg-blue-600 text-white'
|
||||
: step > s.id
|
||||
? 'bg-emerald-600 text-white'
|
||||
: 'bg-slate-800 text-slate-500'
|
||||
}`}>
|
||||
{step > s.id ? <Check className="h-4 w-4" /> : s.id}
|
||||
</div>
|
||||
<span className={`ml-2 text-sm ${step === s.id ? 'text-slate-200' : 'text-slate-500'}`}>
|
||||
{s.title}
|
||||
</span>
|
||||
{index < steps.length - 1 && (
|
||||
<div className="w-12 h-0.5 bg-slate-700 mx-3" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Step 1: Configuration */}
|
||||
{step === 1 && (
|
||||
<div className="space-y-6">
|
||||
{/* Download Template */}
|
||||
<Card className="bg-slate-800 border-slate-700">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-emerald-500/20 flex items-center justify-center">
|
||||
<FileSpreadsheet className="h-5 w-5 text-emerald-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-slate-200">主机导入模板</p>
|
||||
<p className="text-xs text-slate-500">下载Excel模板,填写服务器信息后上传</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" className="border-slate-700 text-slate-300">
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
下载模板
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Region Selection */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-300 mb-2">选择区域</label>
|
||||
<select
|
||||
value={selectedRegion}
|
||||
onChange={(e) => setSelectedRegion(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-md text-slate-100 text-sm"
|
||||
>
|
||||
<option value="">请选择区域</option>
|
||||
{regions.map((region) => (
|
||||
<option key={region.id} value={region.id}>
|
||||
{region.name} ({region.code})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Connection Method */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-300 mb-2">连接方式</label>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{connectionMethods.map((method) => (
|
||||
<Card
|
||||
key={method.id}
|
||||
className={`cursor-pointer transition-all ${
|
||||
connectionMethod === method.id
|
||||
? 'border-blue-500 ring-1 ring-blue-500/30 bg-slate-800'
|
||||
: 'border-slate-700 hover:border-slate-600 bg-slate-800/50'
|
||||
}`}
|
||||
onClick={() => setConnectionMethod(method.id)}
|
||||
>
|
||||
<CardContent className="p-3">
|
||||
<p className="font-medium text-slate-200">{method.name}</p>
|
||||
<p className="text-xs text-slate-500">{method.description}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 2: Upload */}
|
||||
{step === 2 && (
|
||||
<div className="space-y-6">
|
||||
<div
|
||||
className="border-2 border-dashed border-slate-700 rounded-lg p-8 text-center hover:border-blue-500/50 transition-colors"
|
||||
>
|
||||
<Upload className="h-12 w-12 mx-auto mb-4 text-slate-500" />
|
||||
<p className="text-slate-300 mb-2">点击或拖拽文件到此处上传</p>
|
||||
<p className="text-xs text-slate-500 mb-4">支持 .xlsx, .xls 格式文件</p>
|
||||
<Input
|
||||
type="file"
|
||||
accept=".xlsx,.xls"
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
id="excel-upload"
|
||||
/>
|
||||
<label htmlFor="excel-upload">
|
||||
<Button variant="outline" className="border-slate-700 text-slate-300" asChild>
|
||||
<span>选择文件</span>
|
||||
</Button>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{selectedFile && (
|
||||
<Card className="bg-slate-800 border-slate-700">
|
||||
<CardContent className="p-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<FileSpreadsheet className="h-8 w-8 text-emerald-400" />
|
||||
<div>
|
||||
<p className="text-sm text-slate-200">{selectedFile.name}</p>
|
||||
<p className="text-xs text-slate-500">{(selectedFile.size / 1024).toFixed(2)} KB</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-slate-400 hover:text-red-400"
|
||||
onClick={() => setSelectedFile(null)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{isUploading && (
|
||||
<div className="text-center py-4">
|
||||
<div className="w-8 h-8 border-2 border-blue-500 border-t-transparent rounded-full animate-spin mx-auto mb-2" />
|
||||
<p className="text-slate-400 text-sm">正在解析文件...</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 3: Preview & Confirm */}
|
||||
{step === 3 && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-slate-400 text-sm">数据预览</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge className="bg-emerald-500/20 text-emerald-400 border-0">
|
||||
<Check className="h-3 w-3 mr-1" />
|
||||
{validCount} 有效
|
||||
</Badge>
|
||||
{warningCount > 0 && (
|
||||
<Badge className="bg-amber-500/20 text-amber-400 border-0">
|
||||
<AlertCircle className="h-3 w-3 mr-1" />
|
||||
{warningCount} 警告
|
||||
</Badge>
|
||||
)}
|
||||
{errorCount > 0 && (
|
||||
<Badge className="bg-red-500/20 text-red-400 border-0">
|
||||
<X className="h-3 w-3 mr-1" />
|
||||
{errorCount} 错误
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border rounded-lg border-slate-700 overflow-hidden max-h-[350px] overflow-y-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-slate-800 sticky top-0">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-slate-400">行号</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-slate-400">主机名</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-slate-400">IP地址</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-slate-400">端口</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-slate-400">用户名</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-slate-400">状态</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-800">
|
||||
{previewData.map((row) => (
|
||||
<tr key={row.row} className="hover:bg-slate-800/50">
|
||||
<td className="px-3 py-2 text-sm text-slate-500">{row.row}</td>
|
||||
<td className="px-3 py-2 text-sm text-slate-200">{row.hostname || '-'}</td>
|
||||
<td className="px-3 py-2 text-sm text-slate-300">{row.ip}</td>
|
||||
<td className="px-3 py-2 text-sm text-slate-300">{row.port}</td>
|
||||
<td className="px-3 py-2 text-sm text-slate-300">{row.username}</td>
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex items-center gap-1">
|
||||
{row.status === 'valid' && (
|
||||
<Badge className="bg-emerald-500/20 text-emerald-400 border-0 text-xs">
|
||||
<Check className="h-3 w-3 mr-1" />
|
||||
{row.message}
|
||||
</Badge>
|
||||
)}
|
||||
{row.status === 'warning' && (
|
||||
<Badge className="bg-amber-500/20 text-amber-400 border-0 text-xs">
|
||||
<AlertCircle className="h-3 w-3 mr-1" />
|
||||
{row.message}
|
||||
</Badge>
|
||||
)}
|
||||
{row.status === 'error' && (
|
||||
<Badge className="bg-red-500/20 text-red-400 border-0 text-xs">
|
||||
<X className="h-3 w-3 mr-1" />
|
||||
{row.message}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{errorCount > 0 && (
|
||||
<div className="p-3 rounded-lg bg-red-500/10 border border-red-500/30">
|
||||
<div className="flex items-center gap-2 text-red-400">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<span className="text-sm">存在 {errorCount} 条错误数据,请修正后重新上传</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<DialogFooter className="flex justify-between items-center">
|
||||
<div>
|
||||
{step > 1 && (
|
||||
<Button variant="outline" onClick={handleBack} className="border-slate-700 text-slate-300">
|
||||
<ChevronLeft className="h-4 w-4 mr-1" />
|
||||
上一步
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={handleClose} className="border-slate-700 text-slate-300">
|
||||
取消
|
||||
</Button>
|
||||
{step < 3 ? (
|
||||
<Button
|
||||
onClick={handleNext}
|
||||
disabled={(step === 1 && !selectedRegion) || (step === 2 && !selectedFile)}
|
||||
className="bg-blue-600 hover:bg-blue-700"
|
||||
>
|
||||
下一步
|
||||
<ChevronRight className="h-4 w-4 ml-1" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={handleImport}
|
||||
disabled={errorCount > 0 || validCount === 0}
|
||||
className="bg-emerald-600 hover:bg-emerald-700"
|
||||
>
|
||||
<Check className="h-4 w-4 mr-1" />
|
||||
确认导入 ({validCount})
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user