feat: 服务器管理前后端字段对齐 — 移除CMDB幽灵字段 + SSH导向表单 + GB→MB内存转换

This commit is contained in:
attacker committed 2026-06-14 05:11:15 +08:00
1 parent 56e0cb15ac
commit a1237587f6
4 files changed
+146 -187

No files matched your search

+4
View File
@@ -148,6 +148,10 @@ func (h *ServerHandler) CreateServer(c *gin.Context) {
Status: "stopped", Status: "stopped",
} }
if req.Status != "" {
server.Status = req.Status
}
if server.Port == 0 { if server.Port == 0 {
server.Port = 22 server.Port = 22
} }
+1
View File
@@ -144,6 +144,7 @@ type CreateServerRequest struct {
CPU int `json:"cpu"` CPU int `json:"cpu"`
Memory int64 `json:"memory"` Memory int64 `json:"memory"`
Disk int64 `json:"disk"` Disk int64 `json:"disk"`
Status string `json:"status" binding:"omitempty,oneof=running stopped maintenance error"`
RegionID *uint `json:"region_id"` RegionID *uint `json:"region_id"`
CloudID *uint `json:"cloud_id"` CloudID *uint `json:"cloud_id"`
Tags string `json:"tags"` Tags string `json:"tags"`
+24
View File
@@ -129,3 +129,27 @@ cd ~/devops/aiops
- 成本统计从硬编码改为基于实际云账号数据计算 - 成本统计从硬编码改为基于实际云账号数据计算
- K8s 集群拓扑图(TopologyView)前后端数据联调 - K8s 集群拓扑图(TopologyView)前后端数据联调
- 告警"通知配置"按钮功能实现(通知渠道管理弹窗) - 告警"通知配置"按钮功能实现(通知渠道管理弹窗)
## 2026-06-14 05:15 自动推进
- **任务**: 服务器管理模块前后端字段对齐 — 修复前端表单使用后端不支持的CMDB幽灵字段导致数据丢失 + 内存单位 GB→MB 转换 + 表格列重构
- **修改**:
- `backend/internal/models/server.go` — CreateServerRequest 添加 Status 字段(原硬编码 "stopped")
- `backend/internal/api/handlers/server.go` — CreateServer handler 改用 req.Status 替代硬编码值
- `frontend/src/components/server/ServerManager.tsx` — 完全重写表单/表格/过滤器:
- 移除后端不支持的幽灵字段:asset_type/connect_type/floatingip/instance_id/expire_time/remark/region/region_id
- 表单改为SSH连接导向:主机名/IP/公网IP/SSH端口/登录用户/认证方式(密码/密钥)/密码 + 可折叠详情区(OS/状态/CPU/内存/磁盘/标签/描述)
- 内存字段前端输入GB,提交时 ×1024 转换为MB(后端存储MB)
- 表格列从11列精简到8列,新增公网IP列,移除资产类型/连接方式/区域列
- 移除不存在的区域筛选器(servers 无 region_id 数据)
- 移除未实现的"查看详情"下拉项(无详情页)
- **结果**:
- `go build -o aiops-server cmd/main.go` 编译通过 ✅
- `npx tsc --noEmit` 类型检查通过 ✅
- `npm run build` 构建通过 ✅
- API端到端验证: POST /servers → Code 200,hostname/ip/public_ip/port/username/auth_type/memory(16384MB=16GB)/status/tags 全部正确保存 ✅
- API端到端验证: PUT /servers/:id → Code 200,更新成功 ✅
- API端到端验证: DELETE /servers/:id → Code 200 "删除成功" ✅
- 浏览器UI验证: 服务器列表页正常渲染,内存显示为GB(128GB而非131072MB),公网IP列正确显示 ✅
- 表单UI验证: 添加服务器弹窗字段正确,验证逻辑正常(hostname+ip必填时按钮启用)✅
- 服务运行中: 后端:8080 + 前端:5173 ✅
+117 -187
View File
@@ -12,7 +12,6 @@ import {
CollapsibleContent, CollapsibleContent,
CollapsibleTrigger, CollapsibleTrigger,
} from '@/components/ui/collapsible'; } from '@/components/ui/collapsible';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@@ -53,7 +52,6 @@ import {
RotateCw, RotateCw,
Trash2, Trash2,
Edit, Edit,
Eye,
Download, Download,
Upload, Upload,
Server, Server,
@@ -70,59 +68,39 @@ import type { Server as ServerType } from '@/types';
import { CloudServerImport } from './CloudServerImport'; import { CloudServerImport } from './CloudServerImport';
import { ExcelServerImport } from './ExcelServerImport'; import { ExcelServerImport } from './ExcelServerImport';
// 资产类型字典(参考 CMDB 运维平台) // 表单数据结构 — 与后端 CreateServerRequest/UpdateServerRequest 字段对齐
const ASSET_TYPE_OPTIONS = [
{ value: 'physical', label: '物理机', color: 'var(--dg-orange)' },
{ value: 'virtual', label: '虚拟机', color: 'var(--dg-blue)' },
{ value: 'cloud', label: '云主机', color: 'var(--dg-cyan)' },
{ value: 'container', label: '容器', color: 'var(--dg-purple)' },
];
const getAssetTypeLabel = (value: string): string =>
ASSET_TYPE_OPTIONS.find((o) => o.value === value)?.label || value || '-';
const getAssetTypeColor = (value: string): string =>
ASSET_TYPE_OPTIONS.find((o) => o.value === value)?.color || 'var(--dg-text-secondary)';
const getConnectTypeLabel = (value: string): string => {
if (value === 'public') return '公网';
if (value === 'private') return '内网';
return value || '-';
};
// 表单数据结构
interface ServerFormData { interface ServerFormData {
hostname: string; hostname: string;
ip: string; ip: string;
public_ip: string;
port: string;
username: string;
auth_type: string; // password | key
password: string;
os: string; os: string;
cpu: string; cpu: string;
memory: string; memory: string; // GB (表单) → MB (后端)
disk: string; disk: string;
status: string; status: string;
region: string; tags: string;
asset_type: string; description: string;
connect_type: string;
floatingip: string;
instance_id: string;
expire_time: string;
remark: string;
} }
const EMPTY_FORM: ServerFormData = { const EMPTY_FORM: ServerFormData = {
hostname: '', hostname: '',
ip: '', ip: '',
public_ip: '',
port: '22',
username: 'root',
auth_type: 'password',
password: '',
os: '', os: '',
cpu: '', cpu: '',
memory: '', memory: '',
disk: '', disk: '',
status: 'running', status: 'running',
region: '', tags: '',
asset_type: 'physical', description: '',
connect_type: 'private',
floatingip: '',
instance_id: '',
expire_time: '',
remark: '',
}; };
export function ServerManager() { export function ServerManager() {
@@ -131,7 +109,6 @@ export function ServerManager() {
const [selectedServers, setSelectedServers] = useState<string[]>([]); const [selectedServers, setSelectedServers] = useState<string[]>([]);
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [statusFilter, setStatusFilter] = useState<string>('all'); const [statusFilter, setStatusFilter] = useState<string>('all');
const [regionFilter, setRegionFilter] = useState<string>('all');
const [cloudImportOpen, setCloudImportOpen] = useState(false); const [cloudImportOpen, setCloudImportOpen] = useState(false);
const [excelImportOpen, setExcelImportOpen] = useState(false); const [excelImportOpen, setExcelImportOpen] = useState(false);
@@ -175,21 +152,13 @@ export function ServerManager() {
} }
}; };
// 获取区域名称:后端 region 可能是对象 {name} 或字符串
const getRegionName = (server: any): string => {
if (!server.region) return '';
if (typeof server.region === 'string') return server.region;
return server.region.name || '';
};
const filteredServers = servers.filter((server) => { const filteredServers = servers.filter((server) => {
const region = getRegionName(server);
const matchesSearch = const matchesSearch =
(server.hostname || '').toLowerCase().includes(searchQuery.toLowerCase()) || (server.hostname || '').toLowerCase().includes(searchQuery.toLowerCase()) ||
(server.ip || '').includes(searchQuery); (server.ip || '').includes(searchQuery) ||
(server.public_ip || '').includes(searchQuery);
const matchesStatus = statusFilter === 'all' || server.status === statusFilter; const matchesStatus = statusFilter === 'all' || server.status === statusFilter;
const matchesRegion = regionFilter === 'all' || region === regionFilter; return matchesSearch && matchesStatus;
return matchesSearch && matchesStatus && matchesRegion;
}); });
const toggleSelectAll = () => { const toggleSelectAll = () => {
@@ -243,18 +212,18 @@ export function ServerManager() {
setFormData({ setFormData({
hostname: server.hostname || '', hostname: server.hostname || '',
ip: server.ip || '', ip: server.ip || '',
public_ip: server.public_ip || '',
port: String(server.port || 22),
username: server.username || 'root',
auth_type: server.auth_type || 'password',
password: '',
os: server.os || '', os: server.os || '',
cpu: String(server.cpu || ''), cpu: String(server.cpu || ''),
memory: String(server.memory || ''), memory: server.memory ? String(Math.round(server.memory / 1024)) : '', // MB → GB
disk: String(server.disk || ''), disk: String(server.disk || ''),
status: server.status || 'running', status: server.status || 'running',
region: getRegionName(server), tags: server.tags || '',
asset_type: server.asset_type || 'physical', description: server.description || '',
connect_type: server.connect_type || 'private',
floatingip: server.floatingip || '',
instance_id: server.instance_id || '',
expire_time: server.expire_time || '',
remark: server.remark || '',
}); });
setDetailsOpen(false); setDetailsOpen(false);
setFormOpen(true); setFormOpen(true);
@@ -265,12 +234,25 @@ export function ServerManager() {
if (!formData.hostname || !formData.ip) return; if (!formData.hostname || !formData.ip) return;
try { try {
setFormSaving(true); setFormSaving(true);
const payload = { const payload: Record<string, any> = {
...formData, hostname: formData.hostname,
ip: formData.ip,
public_ip: formData.public_ip || undefined,
port: Number(formData.port) || 22,
username: formData.username || 'root',
auth_type: formData.auth_type || 'password',
os: formData.os,
cpu: Number(formData.cpu) || 0, cpu: Number(formData.cpu) || 0,
memory: Number(formData.memory) || 0, memory: (Number(formData.memory) || 0) * 1024, // GB → MB
disk: Number(formData.disk) || 0, disk: Number(formData.disk) || 0,
status: formData.status,
tags: formData.tags || undefined,
description: formData.description || undefined,
}; };
// 密码仅在填写时提交
if (formData.password) {
payload.password = formData.password;
}
if (editingId) { if (editingId) {
await serverApi.updateServer(editingId, payload); await serverApi.updateServer(editingId, payload);
} else { } else {
@@ -318,9 +300,7 @@ export function ServerManager() {
} }
}; };
const regions = Array.from(new Set(servers.map((s) => getRegionName(s)).filter(Boolean))); const colSpanCount = 8;
const colSpanCount = 11;
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -470,19 +450,6 @@ export function ServerManager() {
<SelectItem value="offline">离线</SelectItem> <SelectItem value="offline">离线</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
<Select value={regionFilter} onValueChange={setRegionFilter}>
<SelectTrigger className="w-32 h-9 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]">
<SelectValue placeholder="区域" />
</SelectTrigger>
<SelectContent className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]">
<SelectItem value="all">全部区域</SelectItem>
{regions.map((region) => (
<SelectItem key={region} value={region}>
{region}
</SelectItem>
))}
</SelectContent>
</Select>
</div> </div>
{selectedServers.length > 0 && ( {selectedServers.length > 0 && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -523,12 +490,10 @@ export function ServerManager() {
</TableHead> </TableHead>
<TableHead className="text-[var(--dg-text-secondary)]">主机名</TableHead> <TableHead className="text-[var(--dg-text-secondary)]">主机名</TableHead>
<TableHead className="text-[var(--dg-text-secondary)]">IP地址</TableHead> <TableHead className="text-[var(--dg-text-secondary)]">IP地址</TableHead>
<TableHead className="text-[var(--dg-text-secondary)]">资产类型</TableHead> <TableHead className="text-[var(--dg-text-secondary)]">公网IP</TableHead>
<TableHead className="text-[var(--dg-text-secondary)]">连接方式</TableHead>
<TableHead className="text-[var(--dg-text-secondary)]">状态</TableHead> <TableHead className="text-[var(--dg-text-secondary)]">状态</TableHead>
<TableHead className="text-[var(--dg-text-secondary)]">配置</TableHead> <TableHead className="text-[var(--dg-text-secondary)]">配置</TableHead>
<TableHead className="text-[var(--dg-text-secondary)]">使用率</TableHead> <TableHead className="text-[var(--dg-text-secondary)]">使用率</TableHead>
<TableHead className="text-[var(--dg-text-secondary)]">区域</TableHead>
<TableHead className="text-[var(--dg-text-secondary)]">标签</TableHead> <TableHead className="text-[var(--dg-text-secondary)]">标签</TableHead>
<TableHead className="text-[var(--dg-text-secondary)] text-right">操作</TableHead> <TableHead className="text-[var(--dg-text-secondary)] text-right">操作</TableHead>
</TableRow> </TableRow>
@@ -552,7 +517,7 @@ export function ServerManager() {
const cpuUsage = Number(server.cpu_usage || 0); const cpuUsage = Number(server.cpu_usage || 0);
const memoryUsage = Number(server.memory_usage || 0); const memoryUsage = Number(server.memory_usage || 0);
const tags = getTags(server); const tags = getTags(server);
const region = getRegionName(server); const memGB = server.memory ? Math.round(server.memory / 1024) : 0;
return ( return (
<TableRow key={server.id} className="border-[var(--dg-border)] hover:bg-[var(--dg-bg-tertiary)]/50"> <TableRow key={server.id} className="border-[var(--dg-border)] hover:bg-[var(--dg-bg-tertiary)]/50">
<TableCell> <TableCell>
@@ -576,35 +541,21 @@ export function ServerManager() {
{server.ip} {server.ip}
</code> </code>
</TableCell> </TableCell>
{/* 资产类型 */}
<TableCell> <TableCell>
<Badge {server.public_ip ? (
variant="outline" <code className="text-sm text-[var(--dg-cyan)] bg-[var(--dg-cyan)]/10 px-2 py-0.5 rounded">
className="text-xs border-[var(--dg-border)]" {server.public_ip}
style={{ color: getAssetTypeColor(server.asset_type) }} </code>
> ) : (
{getAssetTypeLabel(server.asset_type)} <span className="text-sm text-[var(--dg-text-muted)]">-</span>
</Badge> )}
</TableCell>
{/* 连接方式 */}
<TableCell>
<span
className={cn(
'text-xs px-2 py-0.5 rounded',
server.connect_type === 'public'
? 'text-[var(--dg-cyan)] bg-[var(--dg-cyan)]/10'
: 'text-[var(--dg-text-secondary)] bg-[var(--dg-bg-tertiary)]'
)}
>
{getConnectTypeLabel(server.connect_type)}
</span>
</TableCell> </TableCell>
<TableCell>{getStatusBadge(server.status)}</TableCell> <TableCell>{getStatusBadge(server.status)}</TableCell>
<TableCell> <TableCell>
<div className="text-sm text-[var(--dg-text-secondary)]"> <div className="text-sm text-[var(--dg-text-secondary)]">
<span className="text-[var(--dg-text-muted)]">{server.cpu}核</span> <span className="text-[var(--dg-text-muted)]">{server.cpu}核</span>
<span className="mx-1 text-[var(--dg-text-muted)] opacity-50">/</span> <span className="mx-1 text-[var(--dg-text-muted)] opacity-50">/</span>
<span className="text-[var(--dg-text-muted)]">{server.memory}GB</span> <span className="text-[var(--dg-text-muted)]">{memGB}GB</span>
<span className="mx-1 text-[var(--dg-text-muted)] opacity-50">/</span> <span className="mx-1 text-[var(--dg-text-muted)] opacity-50">/</span>
<span className="text-[var(--dg-text-muted)]">{server.disk}GB</span> <span className="text-[var(--dg-text-muted)]">{server.disk}GB</span>
</div> </div>
@@ -639,9 +590,6 @@ export function ServerManager() {
</div> </div>
</div> </div>
</TableCell> </TableCell>
<TableCell>
<span className="text-sm text-[var(--dg-text-secondary)]">{region || '-'}</span>
</TableCell>
<TableCell> <TableCell>
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{tags.slice(0, 3).map((tag) => ( {tags.slice(0, 3).map((tag) => (
@@ -674,10 +622,6 @@ export function ServerManager() {
<DropdownMenuContent align="end" className="w-48 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]"> <DropdownMenuContent align="end" className="w-48 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]">
<DropdownMenuLabel className="text-[var(--dg-text-secondary)]">操作</DropdownMenuLabel> <DropdownMenuLabel className="text-[var(--dg-text-secondary)]">操作</DropdownMenuLabel>
<DropdownMenuSeparator className="bg-[var(--dg-border)]" /> <DropdownMenuSeparator className="bg-[var(--dg-border)]" />
<DropdownMenuItem className="text-[var(--dg-text-secondary)] focus:bg-[var(--dg-bg-hover)] focus:text-[var(--dg-text-primary)] cursor-pointer">
<Eye className="h-4 w-4 mr-2" />
查看详情
</DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
className="text-[var(--dg-text-secondary)] focus:bg-[var(--dg-bg-hover)] focus:text-[var(--dg-text-primary)] cursor-pointer" className="text-[var(--dg-text-secondary)] focus:bg-[var(--dg-bg-hover)] focus:text-[var(--dg-text-primary)] cursor-pointer"
onClick={() => openEditForm(server)} onClick={() => openEditForm(server)}
@@ -755,91 +699,68 @@ export function ServerManager() {
</div> </div>
</div> </div>
{/* CMDB 资产字段 */} {/* SSH 连接配置 */}
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label className="text-[var(--dg-text-secondary)]">资产类型</Label> <Label className="text-[var(--dg-text-secondary)]">公网IP</Label>
<Input
placeholder="如 203.0.113.10"
value={formData.public_ip}
onChange={(e) => setFormData({ ...formData, public_ip: e.target.value })}
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] placeholder:text-[var(--dg-text-muted)]"
/>
</div>
<div className="space-y-1.5">
<Label className="text-[var(--dg-text-secondary)]">SSH端口</Label>
<Input
type="number"
placeholder="22"
value={formData.port}
onChange={(e) => setFormData({ ...formData, port: e.target.value })}
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] placeholder:text-[var(--dg-text-muted)]"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label className="text-[var(--dg-text-secondary)]">登录用户</Label>
<Input
placeholder="root"
value={formData.username}
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] placeholder:text-[var(--dg-text-muted)]"
/>
</div>
<div className="space-y-1.5">
<Label className="text-[var(--dg-text-secondary)]">认证方式</Label>
<Select <Select
value={formData.asset_type} value={formData.auth_type}
onValueChange={(v) => setFormData({ ...formData, asset_type: v })} onValueChange={(v) => setFormData({ ...formData, auth_type: v })}
> >
<SelectTrigger className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]"> <SelectTrigger className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]">
<SelectValue placeholder="选择资产类型" /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]"> <SelectContent className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]">
{ASSET_TYPE_OPTIONS.map((opt) => ( <SelectItem value="password">密码认证</SelectItem>
<SelectItem key={opt.value} value={opt.value}> <SelectItem value="key">密钥认证</SelectItem>
<span style={{ color: opt.color }} className="mr-1">●</span>
{opt.label}
</SelectItem>
))}
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div className="space-y-1.5">
<Label className="text-[var(--dg-text-secondary)]">连接方式</Label>
<RadioGroup
value={formData.connect_type}
onValueChange={(v) => setFormData({ ...formData, connect_type: v })}
className="flex items-center gap-6 h-9"
>
<div className="flex items-center gap-2">
<RadioGroupItem value="public" id="connect-public" />
<Label htmlFor="connect-public" className="text-[var(--dg-text-secondary)] cursor-pointer font-normal">
公网
</Label>
</div>
<div className="flex items-center gap-2">
<RadioGroupItem value="private" id="connect-private" />
<Label htmlFor="connect-private" className="text-[var(--dg-text-secondary)] cursor-pointer font-normal">
内网
</Label>
</div>
</RadioGroup>
</div>
</div> </div>
{/* 云主机扩展字段 */} <div className="space-y-1.5">
<div className="grid grid-cols-2 gap-4"> <Label className="text-[var(--dg-text-secondary)]">
<div className="space-y-1.5"> {formData.auth_type === 'key' ? 'SSH 私钥' : '登录密码'}
<Label className="text-[var(--dg-text-secondary)]">弹性IP地址</Label> {!editingId && <span className="text-red-400 ml-1">*</span>}
<Input {editingId && <span className="text-[var(--dg-text-muted)] ml-1 text-xs">(留空则不修改)</span>}
placeholder="如 203.0.113.10" </Label>
value={formData.floatingip} <Textarea
onChange={(e) => setFormData({ ...formData, floatingip: e.target.value })} placeholder={formData.auth_type === 'key' ? '粘贴 SSH 私钥内容...' : '输入登录密码...'}
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] placeholder:text-[var(--dg-text-muted)]" value={formData.password}
/> onChange={(e) => setFormData({ ...formData, password: e.target.value })}
</div> className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] placeholder:text-[var(--dg-text-muted)] min-h-[50px] font-mono text-sm"
<div className="space-y-1.5"> />
<Label className="text-[var(--dg-text-secondary)]">实例ID</Label>
<Input
placeholder="如 i-bp1abc123def"
value={formData.instance_id}
onChange={(e) => setFormData({ ...formData, instance_id: e.target.value })}
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] placeholder:text-[var(--dg-text-muted)]"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label className="text-[var(--dg-text-secondary)]">到期时间</Label>
<Input
type="datetime-local"
value={formData.expire_time}
onChange={(e) => setFormData({ ...formData, expire_time: e.target.value })}
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]"
/>
</div>
<div className="space-y-1.5">
<Label className="text-[var(--dg-text-secondary)]">所在区域</Label>
<Input
placeholder="如 华东1"
value={formData.region}
onChange={(e) => setFormData({ ...formData, region: e.target.value })}
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] placeholder:text-[var(--dg-text-muted)]"
/>
</div>
</div> </div>
{/* 更多详情 - 可折叠区域 */} {/* 更多详情 - 可折叠区域 */}
@@ -852,7 +773,7 @@ export function ServerManager() {
> >
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<Server className="h-4 w-4" /> <Server className="h-4 w-4" />
更多详情(OS版本 / CPU / 内存 / 磁盘 / 状态) 更多详情(OS版本 / 运行状态 / CPU / 内存 / 磁盘 / 标签 / 描述)
</span> </span>
<ChevronDown className={cn('h-4 w-4 transition-transform', detailsOpen && 'rotate-180')} /> <ChevronDown className={cn('h-4 w-4 transition-transform', detailsOpen && 'rotate-180')} />
</Button> </Button>
@@ -919,11 +840,20 @@ export function ServerManager() {
</div> </div>
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label className="text-[var(--dg-text-secondary)]">备注</Label> <Label className="text-[var(--dg-text-secondary)]">标签</Label>
<Input
placeholder='JSON 格式,如 {"project":"web","team":"ops"}'
value={formData.tags}
onChange={(e) => setFormData({ ...formData, tags: e.target.value })}
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] placeholder:text-[var(--dg-text-muted)] font-mono text-sm"
/>
</div>
<div className="space-y-1.5">
<Label className="text-[var(--dg-text-secondary)]">描述</Label>
<Textarea <Textarea
placeholder="补充说明..." placeholder="补充说明..."
value={formData.remark} value={formData.description}
onChange={(e) => setFormData({ ...formData, remark: e.target.value })} onChange={(e) => setFormData({ ...formData, description: e.target.value })}
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] placeholder:text-[var(--dg-text-muted)] min-h-[60px]" className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] placeholder:text-[var(--dg-text-muted)] min-h-[60px]"
/> />
</div> </div>