不用 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();

本期要点

  1. 场景驱动选型:页面少 + 更新慢 → 原生 JS 优于框架
  2. CSS 变量主题系统:全局设计令牌,亮色/暗色一键切换
  3. 声明式表格渲染:Table.render(tbodyId, rows, columns) 通用组件
  4. API 客户端:自动 401 拦截、JSON 序列化
  5. 零构建步骤:HTML 文件即部署

下一期预告

《RESTful API 设计:从能用到好用》——统一响应格式、错误处理三层模型、参数校验标准化。


完整代码: git checkout series-ep-04

更多推荐