- 新增 PageShell/PageTabs/ContentPanel/PageLoading/PageEmpty 页面骨架 - index.css 设计令牌文档化,布局令牌与纯色主按钮(去除渐变粉彩) - 全站路由页接入 PageShell,云账单三 Tab 为标杆 - 主题体系:跟随系统 + 主流/开发者分组,新增 Dracula/Tokyo Night,One Dark 暖灰差异化 - 登录页/侧栏/顶栏去渐变光球,企业克制风 - 修复 AIAlertAnalyzer JSX 与构建依赖导入问题
570 lines
22 KiB
TypeScript
570 lines
22 KiB
TypeScript
import { useState, useEffect } from 'react';
|
||
import { toast } from 'sonner';
|
||
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,
|
||
DialogDescription,
|
||
DialogFooter,
|
||
} from '@/components/ui/dialog';
|
||
import {
|
||
Download,
|
||
Upload,
|
||
FileSpreadsheet,
|
||
Check,
|
||
X,
|
||
AlertCircle,
|
||
ChevronRight,
|
||
ChevronLeft,
|
||
Loader2,
|
||
} from 'lucide-react';
|
||
import { regionApi, serverApi, credentialApi } from '@/api';
|
||
import type { JsonData } from '@/types/common';
|
||
|
||
interface RegionOption {
|
||
id: string;
|
||
name: string;
|
||
code: string;
|
||
}
|
||
|
||
interface ExcelServerImportProps {
|
||
open: boolean;
|
||
onClose: () => void;
|
||
}
|
||
|
||
type PreviewRow = {
|
||
row: number; hostname: string; ip: string; port: number;
|
||
username: string; os: string; cpu: number; memory: number; disk: number; tags: string;
|
||
status: string; message: string;
|
||
};
|
||
|
||
// 解析单行 CSV(支持引号包裹与转义) // Logan.Li
|
||
function parseCSVLine(line: string): string[] {
|
||
const result: string[] = [];
|
||
let current = '';
|
||
let inQuotes = false;
|
||
for (let i = 0; i < line.length; i++) {
|
||
const ch = line[i];
|
||
if (inQuotes) {
|
||
if (ch === '"') {
|
||
if (line[i + 1] === '"') { current += '"'; i++; }
|
||
else inQuotes = false;
|
||
} else {
|
||
current += ch;
|
||
}
|
||
} else if (ch === '"') {
|
||
inQuotes = true;
|
||
} else if (ch === ',') {
|
||
result.push(current);
|
||
current = '';
|
||
} else {
|
||
current += ch;
|
||
}
|
||
}
|
||
result.push(current);
|
||
return result.map((s) => s.trim());
|
||
}
|
||
|
||
// 解析整个 CSV 文本为对象数组(首行为表头) // Logan.Li
|
||
function parseCSV(text: string): Record<string, string>[] {
|
||
const lines = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n').filter((l) => l.trim());
|
||
if (lines.length < 2) return [];
|
||
const headers = parseCSVLine(lines[0]).map((h) => h.toLowerCase());
|
||
const rows: Record<string, string>[] = [];
|
||
for (let i = 1; i < lines.length; i++) {
|
||
const cols = parseCSVLine(lines[i]);
|
||
const row: Record<string, string> = {};
|
||
headers.forEach((h, idx) => { row[h] = cols[idx] ?? ''; });
|
||
rows.push(row);
|
||
}
|
||
return rows;
|
||
}
|
||
|
||
// IPv4 格式校验 // Logan.Li
|
||
function isValidIP(ip: string): boolean {
|
||
return /^(\d{1,3}\.){3}\d{1,3}$/.test(ip) && ip.split('.').every((o) => Number(o) >= 0 && Number(o) <= 255);
|
||
}
|
||
|
||
// 下载 CSV 导入模板 — 主机名+IP+端口(默认22),账号root,Agent 安装后自动补全 // Logan.Li
|
||
function downloadTemplate() {
|
||
const csv = [
|
||
'hostname,ip,port',
|
||
'web-01,192.168.1.10,22',
|
||
'db-01,192.168.1.20,22',
|
||
'cache-01,192.168.1.30,2222',
|
||
].join('\n');
|
||
const blob = new Blob(['\ufeff' + csv], { type: 'text/csv;charset=utf-8;' });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = 'servers-template.csv';
|
||
a.click();
|
||
URL.revokeObjectURL(url);
|
||
}
|
||
|
||
export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) {
|
||
const [step, setStep] = useState(1);
|
||
const [selectedRegion, setSelectedRegion] = useState<string>('');
|
||
const [, _setConnectionMethod] = useState<string>('password');
|
||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||
const [isUploading, setIsUploading] = useState(false);
|
||
const [previewData, setPreviewData] = useState<PreviewRow[]>([]);
|
||
const [regions, setRegions] = useState<RegionOption[]>([]);
|
||
const [isImporting, setIsImporting] = useState(false);
|
||
// 凭据选择 // Logan.Li
|
||
const [credentials, setCredentials] = useState<JsonData[]>([]);
|
||
const [selectedCredentialId, setSelectedCredentialId] = useState<string>('');
|
||
const [defaultSshPort, setDefaultSshPort] = useState('22');
|
||
|
||
// 对话框打开时拉取区域列表 + 凭据列表 // Logan.Li
|
||
useEffect(() => {
|
||
if (!open) return;
|
||
let cancelled = false;
|
||
(async () => {
|
||
try {
|
||
const [regionResp, credResp] = await Promise.all([
|
||
regionApi.list(),
|
||
credentialApi.list(),
|
||
]);
|
||
if (cancelled) return;
|
||
const list = regionResp?.list ?? [];
|
||
setRegions(list.map((r: JsonData) => ({
|
||
id: String(r.id ?? ''),
|
||
name: r.name ?? '',
|
||
code: r.code ?? '',
|
||
})));
|
||
setCredentials(credResp?.list ?? []);
|
||
} catch {
|
||
if (!cancelled) {
|
||
setRegions([]);
|
||
setCredentials([]);
|
||
}
|
||
}
|
||
})();
|
||
return () => { cancelled = true; };
|
||
}, [open]);
|
||
|
||
const handleNext = async () => {
|
||
if (step === 2 && selectedFile) {
|
||
setIsUploading(true);
|
||
try {
|
||
const text = await selectedFile.text();
|
||
const rows = parseCSV(text);
|
||
if (rows.length === 0) {
|
||
toast.error('文件无有效数据,请使用模板格式(首行为表头)');
|
||
setIsUploading(false);
|
||
return;
|
||
}
|
||
const seen = new Set<string>();
|
||
const preview: PreviewRow[] = rows.map((r, idx) => {
|
||
const hostname = (r.hostname ?? '').trim();
|
||
const ip = (r.ip ?? '').trim();
|
||
const row: PreviewRow = {
|
||
row: idx + 2,
|
||
hostname,
|
||
ip,
|
||
port: Number(r.port) || 22,
|
||
username: (r.username ?? '').trim() || 'root',
|
||
os: r.os ?? '',
|
||
cpu: Number(r.cpu) || 0,
|
||
memory: Number(r.memory) || 0,
|
||
disk: Number(r.disk) || 0,
|
||
tags: r.tags ?? '',
|
||
status: 'valid',
|
||
message: '有效',
|
||
};
|
||
const errs: string[] = [];
|
||
if (!hostname) errs.push('主机名缺失');
|
||
if (!ip) errs.push('IP缺失');
|
||
else if (!isValidIP(ip)) errs.push('IP格式错误');
|
||
const key = `${hostname}|${ip}`;
|
||
if (hostname && ip && seen.has(key)) errs.push('重复行');
|
||
else seen.add(key);
|
||
if (errs.length > 0) {
|
||
row.status = 'error';
|
||
row.message = errs.join('、');
|
||
}
|
||
return row;
|
||
});
|
||
setPreviewData(preview);
|
||
setStep(step + 1);
|
||
} catch {
|
||
toast.error('文件解析失败,请确认是 CSV 格式');
|
||
} finally {
|
||
setIsUploading(false);
|
||
}
|
||
} 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 resetState = () => {
|
||
setStep(1);
|
||
setSelectedRegion('');
|
||
setDefaultSshPort('22');
|
||
setSelectedCredentialId('');
|
||
setSelectedFile(null);
|
||
setPreviewData([]);
|
||
};
|
||
|
||
const handleImport = async () => {
|
||
const valid = previewData.filter((d) => d.status === 'valid');
|
||
if (valid.length === 0) return;
|
||
setIsImporting(true);
|
||
try {
|
||
const servers = valid.map((d) => ({
|
||
hostname: d.hostname,
|
||
ip: d.ip,
|
||
port: d.port,
|
||
username: d.username,
|
||
os: d.os,
|
||
cpu: d.cpu,
|
||
memory: d.memory,
|
||
disk: d.disk,
|
||
tags: d.tags,
|
||
}));
|
||
const resp: Record<string, unknown> = await serverApi.batchCreate({
|
||
servers,
|
||
region_id: selectedRegion || undefined,
|
||
credential_id: selectedCredentialId ? Number(selectedCredentialId) : undefined,
|
||
default_port: Number(defaultSshPort) || 22,
|
||
});
|
||
const r = resp ?? {};
|
||
const success = r.success ?? 0;
|
||
const skipped = r.skipped ?? 0;
|
||
const failed = r.failed ?? 0;
|
||
toast.success(`导入完成:成功 ${success},跳过 ${skipped},失败 ${failed}`);
|
||
resetState();
|
||
onClose();
|
||
} catch (e) {
|
||
toast.error(e?.message || '导入失败,请重试');
|
||
} finally {
|
||
setIsImporting(false);
|
||
}
|
||
};
|
||
|
||
const handleClose = () => {
|
||
onClose();
|
||
resetState();
|
||
};
|
||
|
||
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-[var(--dg-bg-primary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]">
|
||
<DialogHeader>
|
||
<DialogTitle className="text-xl">CSV 批量导入服务器</DialogTitle>
|
||
<DialogDescription className="sr-only">通过 CSV 文件批量导入服务器资产</DialogDescription>
|
||
</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-[var(--dg-bg-secondary)] text-[var(--dg-text-muted)]'
|
||
}`}>
|
||
{step > s.id ? <Check className="h-4 w-4" /> : s.id}
|
||
</div>
|
||
<span className={`ml-2 text-sm ${step === s.id ? 'text-[var(--dg-text-primary)]' : 'text-[var(--dg-text-muted)]'}`}>
|
||
{s.title}
|
||
</span>
|
||
{index < steps.length - 1 && (
|
||
<div className="w-12 h-0.5 bg-[var(--dg-border)] mx-3" />
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{/* Step 1: Configuration */}
|
||
{step === 1 && (
|
||
<div className="space-y-6">
|
||
{/* Download Template */}
|
||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||
<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 dg-text-green" />
|
||
</div>
|
||
<div>
|
||
<p className="font-medium text-[var(--dg-text-primary)]">主机导入模板</p>
|
||
<p className="text-xs text-[var(--dg-text-muted)]">下载 CSV 模板,只需填主机名、IP和端口(默认22),Agent安装后自动补全</p>
|
||
</div>
|
||
</div>
|
||
<Button variant="outline" className="border-[var(--dg-border)] text-[var(--dg-text-secondary)]" onClick={downloadTemplate}>
|
||
<Download className="h-4 w-4 mr-2" />
|
||
下载模板
|
||
</Button>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* Region Selection */}
|
||
<div>
|
||
<label className="block text-sm font-medium text-[var(--dg-text-secondary)] mb-2">选择区域</label>
|
||
<select
|
||
value={selectedRegion}
|
||
onChange={(e) => setSelectedRegion(e.target.value)}
|
||
className="w-full px-3 py-2 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-md text-[var(--dg-text-primary)] text-sm"
|
||
>
|
||
<option value="">请选择区域</option>
|
||
{regions.map((region) => (
|
||
<option key={region.id} value={region.id}>
|
||
{region.name} ({region.code})
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
|
||
{/* 凭据选择(SSH密钥/密码) — 选中后自动关联到导入的每台服务器 // Logan.Li */}
|
||
<div>
|
||
<label className="block text-sm font-medium text-[var(--dg-text-secondary)] mb-2">
|
||
选择凭据
|
||
<span className="text-xs text-[var(--dg-text-muted)] ml-1">(可选,用于SSH连接)</span>
|
||
</label>
|
||
{credentials.length === 0 ? (
|
||
<p className="text-xs text-[var(--dg-text-muted)] py-2">
|
||
暂无凭据,先去「凭证配置」添加 SSH 密钥或密码
|
||
</p>
|
||
) : (
|
||
<select
|
||
value={selectedCredentialId}
|
||
onChange={(e) => setSelectedCredentialId(e.target.value)}
|
||
className="w-full px-3 py-2 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-md text-[var(--dg-text-primary)] text-sm"
|
||
>
|
||
<option value="">不使用凭据(后续手动配置)</option>
|
||
{credentials.map((c: JsonData) => (
|
||
<option key={c.id} value={String(c.id)}>
|
||
{c.type === 'ssh_key' ? '🔑' : c.type === 'api_token' ? '🌐' : '🔒'} {c.name} ({c.type === 'ssh_key' ? 'SSH密钥' : c.type === 'api_token' ? 'API Token' : '密码'})
|
||
</option>
|
||
))}
|
||
</select>
|
||
)}
|
||
</div>
|
||
|
||
{/* SSH 端口配置 */}
|
||
<div>
|
||
<label className="block text-sm font-medium text-[var(--dg-text-secondary)] mb-2">
|
||
SSH 端口
|
||
<span className="text-xs text-[var(--dg-text-muted)] ml-1">(导入时统一设置,可在服务器管理页单独修改)</span>
|
||
</label>
|
||
<Input
|
||
type="number"
|
||
placeholder="22"
|
||
value={defaultSshPort}
|
||
onChange={(e) => setDefaultSshPort(e.target.value)}
|
||
className="w-32 bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]"
|
||
/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Step 2: Upload */}
|
||
{step === 2 && (
|
||
<div className="space-y-6">
|
||
<div
|
||
className="border-2 border-dashed border-[var(--dg-border)] rounded-lg p-8 text-center hover:border-blue-500/50 transition-colors"
|
||
>
|
||
<Upload className="h-12 w-12 mx-auto mb-4 text-[var(--dg-text-muted)]" />
|
||
<p className="text-[var(--dg-text-secondary)] mb-2">点击或拖拽文件到此处上传</p>
|
||
<p className="text-xs text-[var(--dg-text-muted)] mb-4">支持 CSV 格式文件(可点击左侧"下载模板"获取)</p>
|
||
<Input
|
||
type="file"
|
||
accept=".csv,text/csv"
|
||
onChange={handleFileChange}
|
||
className="hidden"
|
||
id="excel-upload"
|
||
/>
|
||
<label htmlFor="excel-upload">
|
||
<Button variant="outline" className="border-[var(--dg-border)] text-[var(--dg-text-secondary)]" asChild>
|
||
<span>选择文件</span>
|
||
</Button>
|
||
</label>
|
||
</div>
|
||
|
||
{selectedFile && (
|
||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||
<CardContent className="p-3 flex items-center justify-between">
|
||
<div className="flex items-center gap-3">
|
||
<FileSpreadsheet className="h-8 w-8 dg-text-green" />
|
||
<div>
|
||
<p className="text-sm text-[var(--dg-text-primary)]">{selectedFile.name}</p>
|
||
<p className="text-xs text-[var(--dg-text-muted)]">{(selectedFile.size / 1024).toFixed(2)} KB</p>
|
||
</div>
|
||
</div>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
className="text-[var(--dg-text-muted)] hover:dg-text-red"
|
||
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-[var(--dg-text-secondary)] 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-[var(--dg-text-secondary)] text-sm">数据预览</p>
|
||
<div className="flex items-center gap-2">
|
||
<Badge className="bg-emerald-500/20 dg-text-green border-0">
|
||
<Check className="h-3 w-3 mr-1" />
|
||
{validCount} 有效
|
||
</Badge>
|
||
{warningCount > 0 && (
|
||
<Badge className="bg-amber-500/20 dg-text-yellow border-0">
|
||
<AlertCircle className="h-3 w-3 mr-1" />
|
||
{warningCount} 警告
|
||
</Badge>
|
||
)}
|
||
{errorCount > 0 && (
|
||
<Badge className="bg-red-500/20 dg-text-red border-0">
|
||
<X className="h-3 w-3 mr-1" />
|
||
{errorCount} 错误
|
||
</Badge>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="border rounded-lg border-[var(--dg-border)] overflow-hidden max-h-[350px] overflow-y-auto">
|
||
<table className="w-full">
|
||
<thead className="bg-[var(--dg-bg-secondary)] sticky top-0">
|
||
<tr>
|
||
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)]">行号</th>
|
||
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)]">主机名</th>
|
||
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)]">IP地址</th>
|
||
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)]">状态</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-[var(--dg-border)]">
|
||
{previewData.map((row) => (
|
||
<tr key={row.row} className="hover:bg-[var(--dg-bg-hover)]">
|
||
<td className="px-3 py-2 text-sm text-[var(--dg-text-muted)]">{row.row}</td>
|
||
<td className="px-3 py-2 text-sm text-[var(--dg-text-primary)]">{row.hostname || '-'}</td>
|
||
<td className="px-3 py-2 text-sm text-[var(--dg-text-secondary)]">{row.ip}</td>
|
||
<td className="px-3 py-2">
|
||
<div className="flex items-center gap-1">
|
||
{row.status === 'valid' && (
|
||
<Badge className="bg-emerald-500/20 dg-text-green border-0 text-xs">
|
||
<Check className="h-3 w-3 mr-1" />
|
||
{row.message}
|
||
</Badge>
|
||
)}
|
||
{row.status === 'warning' && (
|
||
<Badge className="bg-amber-500/20 dg-text-yellow border-0 text-xs">
|
||
<AlertCircle className="h-3 w-3 mr-1" />
|
||
{row.message}
|
||
</Badge>
|
||
)}
|
||
{row.status === 'error' && (
|
||
<Badge className="bg-red-500/20 dg-text-red 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 dg-text-red">
|
||
<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-[var(--dg-border)] text-[var(--dg-text-secondary)]">
|
||
<ChevronLeft className="h-4 w-4 mr-1" />
|
||
上一步
|
||
</Button>
|
||
)}
|
||
</div>
|
||
<div className="flex gap-2">
|
||
<Button variant="outline" onClick={handleClose} className="border-[var(--dg-border)] text-[var(--dg-text-secondary)]">
|
||
取消
|
||
</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={isImporting || errorCount > 0 || validCount === 0}
|
||
className="bg-emerald-600 hover:bg-emerald-700"
|
||
>
|
||
{isImporting ? (
|
||
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
|
||
) : (
|
||
<Check className="h-4 w-4 mr-1" />
|
||
)}
|
||
{isImporting ? '导入中...' : `确认导入 (${validCount})`}
|
||
</Button>
|
||
)}
|
||
</div>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
);
|
||
}
|