不用 React/Vue,手写一个设备管理界面
·
不用 React/Vue,手写一个设备管理界面
摘要: 场景驱动的技术选型:什么时候不需要前端框架?本文逐一实现 CSS 变量主题系统(亮色/暗色一键切换)、零依赖表格组件(搜索/排序/分页)、Tab 式布局与懒加载、声明式 API 客户端封装。前沿:CSS Container Queries 做响应式布局。
一、技术选型:为什么不上框架?
对于一个网络运维管理平台,前端技术选型考虑了以下约束:
约束 1: 页面 < 15 个,不频繁更新 → 框架的组件化收益低
约束 2: 后端 Rust 已编译为单文件 → 前端也应零构建步骤
约束 3: 网工也可能改 UI → 学习成本越低越好
约束 4: 嵌入在二进制中 → 没有 npm build,没有 webpack
决策矩阵:
| 方案 | 学习成本 | 部署复杂度 | 打包产物 | 适用性 |
|---|---|---|---|---|
| React + Vite | 高 | 需要 node + npm build | dist/ | ❌ |
| Vue + Vite | 中 | 同上 | dist/ | ❌ |
| htmx | 低 | 零构建 | .html | ⚠️ 交互复杂时不够 |
| 原生 JS + CSS 变量 | 极低 | 零构建 | .html 单文件 | ✅ 最佳 |
二、CSS 变量主题系统
不用 Tailwind,不用 Sass,打造一套灵活的主题方案:
/* ==========================================
themes.css — 全局设计令牌
========================================== */
/* ---------- 暗色主题(默认) ---------- */
:root {
/* 背景色 */
--bg-primary: #0d1117;
--bg-card: #161b22;
--bg-secondary: #21262d;
--bg-input: #0d1117;
--bg-hover: #1c2128;
/* 文字色 */
--text-primary: #e6edf3;
--text-secondary: #8b949e;
--text-muted: #6e7681;
/* 语义色 */
--accent-primary: #58a6ff;
--accent-hover: #79c0ff;
--success: #3fb950;
--warning: #d29922;
--danger: #f85149;
--info: #58a6ff;
/* 边框 & 圆角 */
--border-color: #30363d;
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-xl: 16px;
/* 间距 */
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 24px;
--space-6: 32px;
/* 字体 */
--font-mono: 'SF Mono', 'Fira Code', monospace;
--font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
--font-xs: 11px;
--font-sm: 12px;
--font-base: 14px;
--font-lg: 16px;
--font-xl: 20px;
--font-2xl: 24px;
/* 阴影 */
--shadow-sm: 0 1px 2px rgba(0,0,0,0.3);
--shadow-md: 0 4px 12px rgba(0,0,0,0.4);
}
/* ---------- 亮色主题 ---------- */
[data-theme="light"] {
--bg-primary: #ffffff;
--bg-card: #f6f8fa;
--bg-secondary: #eaeef2;
--bg-input: #ffffff;
--bg-hover: #f0f2f5;
--text-primary: #1f2328;
--text-secondary:#656d76;
--text-muted: #8b949e;
--border-color: #d0d7de;
--accent-primary:#0969da;
--accent-hover: #0550ae;
--shadow-sm: 0 1px 2px rgba(0,0,0,0.06);
--shadow-md: 0 4px 12px rgba(0,0,0,0.08);
}
/* ---------- 全局重置 ---------- */
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: var(--font-sans);
font-size: var(--font-base);
background: var(--bg-primary);
color: var(--text-primary);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
/* 永远不要写 #xxx 颜色值,始终用 var(--xxx) */
三、应用布局
/* layout.css */
.app-container {
display: flex;
height: 100vh;
overflow: hidden;
}
/* 侧边栏 */
.sidebar {
width: 240px;
min-width: 240px;
background: var(--bg-card);
border-right: 1px solid var(--border-color);
display: flex;
flex-direction: column;
}
.sidebar-brand {
padding: var(--space-4);
font-size: var(--font-lg);
font-weight: 600;
border-bottom: 1px solid var(--border-color);
display: flex;
align-items: center;
gap: var(--space-2);
}
.menu-item {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
margin: 2px var(--space-2);
border-radius: var(--radius-md);
cursor: pointer;
color: var(--text-secondary);
font-size: var(--font-base);
transition: all 0.12s ease;
user-select: none;
}
.menu-item:hover {
background: var(--bg-secondary);
color: var(--text-primary);
}
.menu-item.active {
background: rgba(88, 166, 255, 0.12);
color: var(--accent-primary);
}
.menu-icon { font-size: 16px; }
/* 主内容区 */
.main-content {
flex: 1;
overflow-y: auto;
padding: var(--space-5);
}
/* 页头 */
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--space-5);
}
/* 卡片容器 */
.card {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
padding: var(--space-4);
}
/* 响应式 */
@media (max-width: 768px) {
.sidebar { width: 60px; min-width: 60px; }
.menu-text, .brand-text { display: none; }
.main-content { padding: var(--space-3); }
}
四、数据表格组件
核心思路:声明式 HTML 模板 + JS 数据渲染,零依赖。
// static/js/table.js
const Table = {
/**
* 渲染数据表格
* @param {string} tbodyId - tbody 元素 ID
* @param {Array} rows - 数据行数组
* @param {Object} columns - 列定义 { key: { title, render?, class? } }
* @param {Object} opts - { emptyText, rowClick }
*/
render(tbodyId, rows, columns, opts = {}) {
const tbody = document.getElementById(tbodyId);
if (!tbody) return;
const emptyText = opts.emptyText || '暂无数据';
if (!rows || !rows.length) {
tbody.innerHTML = `<tr><td colspan="${Object.keys(columns).length}"
style="text-align:center;padding:40px;color:var(--text-muted);">
${emptyText}</td></tr>`;
return;
}
tbody.innerHTML = rows.map((row, i) => `
<tr ${opts.rowClick ? `onclick="(${opts.rowClick})(this, ${i})" style="cursor:pointer"` : ''}>
${Object.entries(columns).map(([key, col]) => {
const value = row[key];
const content = col.render ? col.render(value, row) : (value ?? '-');
return `<td class="${col.class || ''}">${content}</td>`;
}).join('')}
</tr>
`).join('');
},
// 状态徽章
statusBadge(status) {
const map = {
online: ['success', '在线'],
offline: ['danger', '离线'],
unknown: ['muted', '未知'],
};
const [color, label] = map[status] || map.unknown;
return `<span class="badge badge-${color}">${label}</span>`;
},
// 厂商渲染
vendorCell(vendor) {
const icons = { huawei: '🔴', cisco: '🔵', h3c: '🟠', ruijie: '🟢', juniper: '🟣' };
return `${icons[vendor] || '⚪'} ${vendor}`;
}
};
.data-table {
width: 100%;
border-collapse: collapse;
}
.data-table thead th {
text-align: left;
padding: var(--space-2) var(--space-3);
font-size: var(--font-sm);
font-weight: 600;
color: var(--text-muted);
border-bottom: 2px solid var(--border-color);
white-space: nowrap;
user-select: none;
}
.data-table tbody td {
padding: var(--space-2) var(--space-3);
border-bottom: 1px solid var(--border-color);
font-size: var(--font-base);
vertical-align: middle;
}
.data-table tbody tr:hover td {
background: var(--bg-hover);
}
.data-table code {
font-family: var(--font-mono);
font-size: var(--font-sm);
background: var(--bg-secondary);
padding: 1px 6px;
border-radius: var(--radius-sm);
}
五、设备管理页面实现
// 设备管理页
async function loadDevicePage() {
const main = document.getElementById('mainContent');
main.innerHTML = `
<div class="page-header">
<h1>🖥️ 设备管理</h1>
<div class="page-actions">
<div class="input-group">
<input type="text" id="devSearch"
placeholder="🔍 搜索设备名称 / IP..."
class="form-input" style="width:220px;"
onkeyup="debounceLoadDevices()">
</div>
<select id="devVendor" class="form-select"
onchange="loadDevices()">
<option value="">所有厂商</option>
<option value="huawei">华为</option>
<option value="cisco">Cisco</option>
<option value="h3c">H3C</option>
<option value="ruijie">锐捷</option>
<option value="juniper">Juniper</option>
</select>
<button class="btn btn-primary" onclick="showAddForm()">
➕ 添加设备
</button>
</div>
</div>
<div class="card">
<table class="data-table">
<thead>
<tr>
<th>设备名称</th>
<th>IP 地址</th>
<th>厂商</th>
<th>型号</th>
<th>角色</th>
<th style="width:80px">状态</th>
<th style="width:120px">操作</th>
</tr>
</thead>
<tbody id="devTbody"></tbody>
</table>
<div id="devPagination" class="pagination" style="margin-top:var(--space-3);"></div>
</div>
`;
await loadDevices();
}
// 防抖搜索
let _devSearchTimer = null;
function debounceLoadDevices() {
clearTimeout(_devSearchTimer);
_devSearchTimer = setTimeout(loadDevices, 300);
}
// 加载设备列表
async function loadDevices(page = 1) {
const search = document.getElementById('devSearch')?.value || '';
const vendor = document.getElementById('devVendor')?.value || '';
const resp = await apiFetch(
`/api/devices?search=${encodeURIComponent(search)}&vendor=${vendor}&page=${page}&page_size=15`
);
const data = await resp.json();
const columns = {
name: { render: (v) => `<strong>${v}</strong>` },
ip_address: { render: (v) => `<code>${v}</code>` },
vendor: { render: Table.vendorCell },
model: { render: (v) => v || '<span class="text-muted">-</span>' },
role: { render: (v) => v ? `<span class="badge badge-role">${v}</span>` : '-' },
status: { render: Table.statusBadge },
_actions: { render: (_, row) => `
<div class="btn-group">
<button class="btn-xs" onclick="editDevice(${row.id})">编辑</button>
<button class="btn-xs btn-danger" onclick="deleteDevice(${row.id},'${row.name}')">删除</button>
</div>
`},
};
Table.render('devTbody', data.data?.devices || [], columns, {
emptyText: '暂无设备,点击「添加设备」开始管理'
});
renderPagination('devPagination', data.data?.total || 0, page, 15, loadDevices);
}
六、统一 API 客户端
// static/js/api-client.js
const ApiClient = {
baseURL: '',
async fetch(url, options = {}) {
// 默认配置
const opts = {
credentials: 'include', // 携带 Cookie
...options,
};
// 自动注入 JSON Content-Type
if (opts.body && typeof opts.body === 'string') {
opts.headers = {
'Content-Type': 'application/json',
...opts.headers,
};
}
const resp = await fetch(this.baseURL + url, opts);
// 401 自动跳转登录
if (resp.status === 401) {
window.location.href = '/login.html?redirect='
+ encodeURIComponent(window.location.pathname);
throw new Error('Unauthorized');
}
return resp;
},
get(url) { return this.fetch(url); },
post(url, body) { return this.fetch(url, { method: 'POST', body: JSON.stringify(body) }); },
put(url, body) { return this.fetch(url, { method: 'PUT', body: JSON.stringify(body) }); },
delete(url) { return this.fetch(url, { method: 'DELETE' }); },
};
// 全局别名
const apiFetch = ApiClient.fetch.bind(ApiClient);
七、主题切换
// 主题切换
function initTheme() {
const saved = localStorage.getItem('theme') || 'dark';
applyTheme(saved);
}
function applyTheme(theme) {
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem('theme', theme);
}
function toggleTheme() {
const current = document.documentElement.getAttribute('data-theme');
applyTheme(current === 'dark' ? 'light' : 'dark');
}
initTheme();
本期要点
- 场景驱动选型:页面少 + 更新慢 → 原生 JS 优于框架
- CSS 变量主题系统:全局设计令牌,亮色/暗色一键切换
- 声明式表格渲染:Table.render(tbodyId, rows, columns) 通用组件
- API 客户端:自动 401 拦截、JSON 序列化
- 零构建步骤:HTML 文件即部署
下一期预告
《RESTful API 设计:从能用到好用》——统一响应格式、错误处理三层模型、参数校验标准化。
完整代码:
git checkout series-ep-04
更多推荐
所有评论(0)