数据库界面设计实战:表格、筛选与编辑功能怎么做

资讯1天前发布 jdkjadmin
0 00
数据库界面设计实战:表格、筛选与编辑功能怎么做

数据库界面设计实战:表格、筛选与编辑功能怎么做

后台管理系统里最常见的页面之一,是一张可以搜索、筛选、分页和编辑数据的表格。它看起来只是“把数据库记录显示出来”,真正实现时却会碰到不少边界:筛选条件如何组合、排序字段能不能直接拼进 SQL、用户输入怎样避免注入、编辑失败时如何恢复界面,以及浏览器是否有权修改某条记录。

本文用一个“客户列表”作为例子,采用 Node.js、Express、SQLite 和原生 JavaScript,实现分页查询、关键词搜索、状态筛选、排序与编辑。示例可以本地运行,适合规模不大、以管理端为主的工具型应用。它不是完整的生产级身份认证方案;上线时仍须接入认证、授权、CSRF 防护、日志与部署配置。

一、先明确页面适用场景和数据边界

表格适合查看具有稳定字段结构、需要比较多条记录或执行重复管理操作的数据,例如客户、订单、库存和任务列表。它不适合替代所有数据浏览方式:字段很多、内容长短差异大时,应该挑选关键列,把详情放进详情页或侧栏;数据有层级关系时,树形视图可能更清楚;数百万条数据也不能一次性传到浏览器再筛选。

在写代码前,应先约定以下行为:

  • 一行代表什么:示例中一行代表一位客户,使用数据库主键作为稳定标识,不用行号或当前排序位置作为身份。
  • 每次最多返回多少条:采用服务端分页,并限制单页上限,避免客户端无意中请求过大的结果集。
  • 哪些字段可查询、排序、修改:这些字段由服务端明确允许,不能因为前端隐藏了某个控件,就认为后端可以放任访问。
  • 筛选和编辑的语义:本例的关键词搜索匹配姓名或邮箱;编辑只允许修改姓名、邮箱和状态。其他字段必须通过独立规则处理。

这些约定不仅影响交互,也决定 API 的边界。前端负责提供方便的操作;服务端负责验证输入、限制查询范围并执行授权。

二、初始化项目和数据库

示例使用 Node.js 18 或更新版本。创建项目并安装依赖:

mkdir database-ui-demo
cd database-ui-demo
npm init -y
npm install express better-sqlite3

创建 server.js。首次启动时,程序会创建本地 SQLite 数据库和示例记录。为便于阅读,数据库操作采用 better-sqlite3 的同步 API;如果应用存在高并发或长耗时查询,应评估异步驱动、连接池及部署环境,而不是照搬这个小示例。

const express = require('express');
const Database = require('better-sqlite3');
const path = require('path');

const app = express();
const db = new Database('app.db');

app.use(express.json({ limit: '20kb' }));
app.use(express.static(path.join(__dirname, 'public')));

db.exec(`
  CREATE TABLE IF NOT EXISTS customers (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    name TEXT NOT NULL,
    email TEXT NOT NULL UNIQUE,
    status TEXT NOT NULL DEFAULT 'active'
      CHECK (status IN ('active', 'inactive')),
    updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
  )
`);

const count = db.prepare('SELECT COUNT(*) AS n FROM customers').get().n;
if (count === 0) {
  const insert = db.prepare(
    'INSERT INTO customers (name, email, status) VALUES (?, ?, ?)'
  );
  const seed = db.transaction(() => {
    insert.run('林晓', '[email protected]', 'active');
    insert.run('周宁', '[email protected]', 'inactive');
    insert.run('陈雨', '[email protected]', 'active');
  });
  seed();
}

const allowedSort = new Set(['id', 'name', 'email', 'status', 'updated_at']);
const allowedStatus = new Set(['active', 'inactive']);

function parsePositiveInt(value, fallback, max) {
  const number = Number(value);
  if (!Number.isInteger(number) || number < 1) return fallback;
  return Math.min(number, max);
}

function validEmail(value) {
  return typeof value === 'string' &&
    value.length <= 254 &&
    /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(value);
}

app.get('/api/customers', (req, res) => {
  const page = parsePositiveInt(req.query.page, 1, 1000000);
  const pageSize = parsePositiveInt(req.query.pageSize, 10, 100);

  const q = typeof req.query.q === 'string'
    ? req.query.q.trim().slice(0, 100)
    : '';

  const status = typeof req.query.status === 'string'
    ? req.query.status
    : '';

  if (status && !allowedStatus.has(status)) {
    return res.status(400).json({ error: '无效的状态筛选值' });
  }

  const requestedSort = String(req.query.sort || 'id');
  const sort = allowedSort.has(requestedSort) ? requestedSort : 'id';
  const direction =
    String(req.query.direction || 'desc').toLowerCase() === 'asc'
      ? 'ASC'
      : 'DESC';

  const where = [];
  const params = {};

  if (status) {
    where.push('status = @status');
    params.status = status;
  }

  if (q) {
    // 将 LIKE 的通配符当作普通字符,避免用户输入 % 或 _ 后扩大匹配范围。
    const escaped = q.replace(/[\\\\%_]/g, '\\\\$&');
    where.push("(name LIKE @q ESCAPE '\\\\' OR email LIKE @q ESCAPE '\\\\')");
    params.q = `%${escaped}%`;
  }

  const whereSql = where.length ? `WHERE ${where.join(' AND ')}` : '';
  const total = db.prepare(
    `SELECT COUNT(*) AS total FROM customers ${whereSql}`
  ).get(params).total;

  const offset = (page - 1) * pageSize;
  const rows = db.prepare(`
    SELECT id, name, email, status, updated_at
    FROM customers
    ${whereSql}
    ORDER BY ${sort} ${direction}, id DESC
    LIMIT @limit OFFSET @offset
  `).all({ ...params, limit: pageSize, offset });

  res.json({ rows, total, page, pageSize });
});

app.patch('/api/customers/:id', (req, res) => {
  const id = Number(req.params.id);
  if (!Number.isSafeInteger(id) || id < 1) {
    return res.status(400).json({ error: '无效的客户编号' });
  }

  const { name, email, status } = req.body || {};

  if (typeof name !== 'string' || name.trim().length < 1 ||
      name.trim().length > 80) {
    return res.status(400).json({ error: '姓名长度须为 1 到 80 个字符' });
  }
  if (!validEmail(email)) {
    return res.status(400).json({ error: '邮箱格式或长度无效' });
  }
  if (!allowedStatus.has(status)) {
    return res.status(400).json({ error: '无效的状态值' });
  }

  try {
    const result = db.prepare(`
      UPDATE customers
      SET name = ?, email = ?, status = ?, updated_at = CURRENT_TIMESTAMP
      WHERE id = ?
    `).run(name.trim(), email.trim(), status, id);

    if (result.changes === 0) {
      return res.status(404).json({ error: '记录不存在' });
    }

    const row = db.prepare(`
      SELECT id, name, email, status, updated_at
      FROM customers WHERE id = ?
    `).get(id);

    res.json(row);
  } catch (error) {
    if (error.code === 'SQLITE_CONSTRAINT_UNIQUE') {
      return res.status(409).json({ error: '邮箱已被其他客户使用' });
    }
    console.error(error);
    res.status(500).json({ error: '服务器处理失败' });
  }
});

app.listen(3000, () => {
  console.log('打开 http://localhost:3000');
});

运行服务:

node server.js

这里的分页参数有明确上限:页码最多为 1,000,000,单页条数最多为 100;超出单页上限时会被截到 100,而不是返回更多数据。页码不是正整数时回退到默认值 1。关键词最长取 100 个字符,姓名上限为 80 个字符,邮箱上限为 254 个字符。它们是此示例的应用规则,不是 HTTP、SQLite 或所有邮箱规范强制规定的通用上限。实际项目应依据字段定义、业务要求和数据库能力设定,并在接口文档中保持一致。

三、查询接口为什么这样写

SQL 中的值使用占位符传递,例如 status = @status 和 LIMIT @limit。这能把数据值与 SQL 语句结构分开,是防止 SQL 注入的重要做法。不要把关键词、邮箱或状态直接拼到 SQL 字符串里。

排序列名和升降序是另一种情况:它们属于 SQL 结构,不能像普通值那样通过参数占位符安全地替换。因此示例将排序字段限制在 allowedSort 白名单中,并把排序方向转换为固定的 ASC 或 DESC。即使前端只显示几个排序选项,服务端仍需要检查。

关键词中的 % 和 _ 在 SQL 的 LIKE 模式里具有通配含义。示例先转义反斜线和这两个字符,再使用 ESCAPE 声明转义符。这样用户输入的百分号不会自动变成“任意长度字符”的匹配规则。参数化查询解决的是注入风险,不会自动改变 LIKE 的匹配语义。

分页偏移量由经过限制的正整数计算而来,但大页码仍可能带来昂贵的 OFFSET 扫描。数据量较大、用户经常翻到后页时,可改用基于稳定排序键的游标分页;同时应检查查询计划,并为常用筛选、排序条件建立合适索引。索引设计要结合真实查询和数据分布,不能保证加索引就一定变快。

四、制作表格、筛选和编辑界面

创建目录 public,并在其中新建 index.html。页面将筛选条件放在表格上方,编辑表单内联显示在对应行。行内编辑适合字段少、上下文重要、单次只修改一条记录的场景;字段较多、需要复杂校验或编辑流程较长时,独立页面或对话框更合适。

<!doctype html>
<html lang="zh-CN">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>客户管理</title>
<style>
  body { font: 16px/1.5 system-ui, sans-serif; margin: 2rem auto; max-width: 1000px; padding: 0 1rem; }
  form.filters { display: flex; flex-wrap: wrap; gap: .75rem; margin: 1rem 0; }
  input, select, button { font: inherit; padding: .4rem .6rem; }
  table { border-collapse: collapse; width: 100%; }
  th, td { border-bottom: 1px solid #ddd; padding: .65rem; text-align: left; }
  th button { border: 0; background: transparent; cursor: pointer; }
  .error { color: #a00; min-height: 1.5em; }
  .pager { display: flex; gap: 1rem; align-items: center; margin-top: 1rem; }
  .edit-form { display: flex; flex-wrap: wrap; gap: .4rem; }
</style>

<h1>客户管理</h1>

<form class="filters" id="filters">
  <label>关键词
    <input id="q" maxlength="100" type="search" placeholder="姓名或邮箱">
  </label>
  <label>状态
    <select id="status">
      <option value="">全部</option>
      <option value="active">启用</option>
      <option value="inactive">停用</option>
    </select>
  </label>
  <button type="submit">筛选</button>
</form>

<p id="message" class="error" role="status" aria-live="polite"></p>
<table>
  <thead>
    <tr>
      <th><button data-sort="id">编号</button></th>
      <th><button data-sort="name">姓名</button></th>
      <th><button data-sort="email">邮箱</button></th>
      <th><button data-sort="status">状态</button></th>
      <th>更新时间</th>
      <th>操作</th>
    </tr>
  </thead>
  <tbody id="rows"></tbody>
</table>

<div class="pager">
  <button id="prev">上一页</button>
  <span id="pageInfo"></span>
  <button id="next">下一页</button>
  <label>每页
    <select id="pageSize">
      <option>10</option>
      <option>25</option>
      <option>50</option>
    </select> 条
  </label>
</div>

<script>
const state = {
  page: 1, pageSize: 10, q: '', status: '',
  sort: 'id', direction: 'desc', total: 0
};

const rowsEl = document.querySelector('#rows');
const messageEl = document.querySelector('#message');

function cell(text) {
  const td = document.createElement('td');
  td.textContent = text == null ? '' : String(text);
  return td;
}

async function loadRows() {
  messageEl.textContent = '';
  const params = new URLSearchParams({
    page: state.page,
    pageSize: state.pageSize,
    q: state.q,
    status: state.status,
    sort: state.sort,
    direction: state.direction
  });

  try {
    const response = await fetch('/api/customers?' + params);
    const data = await response.json();
    if (!response.ok) throw new Error(data.error || '读取失败');

    state.total = data.total;
    rowsEl.replaceChildren();

    for (const row of data.rows) {
      const tr = document.createElement('tr');
      tr.append(
        cell(row.id),
        cell(row.name),
        cell(row.email),
        cell(row.status === 'active' ? '启用' : '停用'),
        cell(row.updated_at)
      );

      const action = document.createElement('td');
      const editButton = document.createElement('button');
      editButton.type = 'button';
      editButton.textContent = '编辑';
      editButton.addEventListener('click', () => showEditor(tr, row));
      action.append(editButton);
      tr.append(action);
      rowsEl.append(tr);
    }

    const pages = Math.ceil(state.total / state.pageSize);
    document.querySelector('#pageInfo').textContent =
      `第 ${state.page} 页,共 ${pages} 页;${state.total} 条`;
    document.querySelector('#prev').disabled = state.page <= 1;
    document.querySelector('#next').disabled =
      state.page >= Math.max(1, pages);
  } catch (error) {
    messageEl.textContent = error.message;
  }
}

function showEditor(tr, row) {
  tr.replaceChildren();
  const form = document.createElement('form');
  form.className = 'edit-form';

  const name = document.createElement('input');
  name.name = 'name';
  name.value = row.name;
  name.maxLength = 80;
  name.required = true;
  name.setAttribute('aria-label', '姓名');

  const email = document.createElement('input');
  email.name = 'email';
  email.type = 'email';
  email.value = row.email;
  email.maxLength = 254;
  email.required = true;
  email.setAttribute('aria-label', '邮箱');

  const status = document.createElement('select');
  status.name = 'status';
  for (const [value, label] of [
    ['active', '启用'], ['inactive', '停用']
  ]) {
    const option = document.createElement('option');
    option.value = value;
    option.textContent = label;
    option.selected = row.status === value;
    status.append(option);
  }

  const save = document.createElement('button');
  save.type = 'submit';
  save.textContent = '保存';

  const cancel = document.createElement('button');
  cancel.type = 'button';
  cancel.textContent = '取消';
  cancel.addEventListener('click', loadRows);

  form.append(name, email, status, save, cancel);
  const td = document.createElement('td');
  td.colSpan = 6;
  td.append(form);
  tr.append(td);

  form.addEventListener('submit', async event => {
    event.preventDefault();
    messageEl.textContent = '';
    save.disabled = true;

    try {
      const response = await fetch(`/api/customers/${row.id}`, {
        method: 'PATCH',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          name: name.value,
          email: email.value,
          status: status.value
        })
      });
      const result = await response.json();
      if (!response.ok) throw new Error(result.error || '保存失败');
      await loadRows();
    } catch (error) {
      messageEl.textContent = error.message;
      save.disabled = false;
    }
  });
}

document.querySelector('#filters').addEventListener('submit', event => {
  event.preventDefault();
  state.q = document.querySelector('#q').value.trim();
  state.status = document.querySelector('#status').value;
  state.page = 1;
  loadRows();
});

document.querySelectorAll('[data-sort]').forEach(button => {
  button.addEventListener('click', () => {
    const key = button.dataset.sort;
    state.direction = state.sort === key && state.direction === 'asc'
      ? 'desc' : 'asc';
    state.sort = key;
    state.page = 1;
    loadRows();
  });
});

document.querySelector('#prev').addEventListener('click', () => {
  if (state.page > 1) state.page--;
  loadRows();
});
document.querySelector('#next').addEventListener('click', () => {
  state.page++;
  loadRows();
});
document.querySelector('#pageSize').addEventListener('change', event => {
  state.pageSize = Number(event.target.value);
  state.page = 1;
  loadRows();
});

loadRows();
</script>
</html>

五、交互设计中的几个关键细节

筛选后回到第一页

用户从第 8 页切换到一个只有 2 页的筛选结果时,如果仍请求第 8 页,页面可能显示空表,让人误以为没有数据。因此改变关键词、状态或每页条数时,都将页码重置为 1。示例没有做输入即搜索,而是通过提交按钮触发查询;若要做即时搜索,应加入防抖,并处理请求竞态,避免较早发出的慢请求覆盖较新的结果。

排序必须有稳定次序

只按姓名排序时,多个同名记录的相对位置可能不稳定。示例额外按 id DESC 排序作为次级键,使结果在同一排序值下有确定顺序。排序状态也应清楚反映在界面上;正式页面可以标注当前字段与方向,并提供键盘可操作的按钮,而不是只依赖视觉样式。

编辑失败时保留上下文

网络错误、邮箱冲突或后端校验失败时,示例不会清空输入,而是在页面上显示错误,并重新启用保存按钮。真实界面还应将错误关联到具体字段,明确指出可修复原因。成功后重新加载列表,保证服务端返回的数据(包括更新时间)成为显示依据,而不是假定浏览器提交的内容已经成功写入。

使用文本节点显示用户数据

示例通过 textContent 设置单元格内容、通过表单控件的 value 设置编辑值,没有把数据库字符串拼入 innerHTML。数据库里的内容也可能包含攻击者输入的脚本片段;从数据库读取并不意味着它可信。需要动态生成 HTML 时,必须使用可靠的输出编码或安全的模板机制。

六、必须理解的安全和一致性边界

  • 参数化查询不等于授权:示例的 PATCH 接口没有认证和权限检查,任何能访问服务的客户端都可能修改记录。上线前必须在服务端验证用户身份,并检查其是否有权操作对应客户;不能只在界面隐藏按钮。
  • 需要考虑 CSRF:如果使用浏览器自动携带的 Cookie 认证,状态变更接口应根据架构采取 CSRF 防护,例如合适的 SameSite Cookie 配置和 CSRF token。仅检查 JSON 请求头或依赖跨域策略,不应被当成通用替代方案。若采用其他认证方式,也要按实际威胁模型评估。
  • 输入校验不是完整业务规则:示例的邮箱正则只是简化格式检查,不证明地址真实存在,也不涵盖所有合法邮箱形式。若业务需要邮箱验证,应通过确认流程完成。数据库的唯一约束仍然重要,因为两个并发请求可能同时通过应用层预检查。
  • 错误信息要适度:向用户返回可处理的 400、404 和 409 错误有助于交互;内部异常记录在服务器端,不应将堆栈、SQL 或环境信息原样发送给浏览器。示例把非预期异常记入控制台,生产环境应使用有访问控制和保留策略的日志系统。
  • 更新冲突需要业务决策:本例直接更新整条允许修改的字段,没有处理“多人同时编辑”的冲突。重要数据可增加版本号或更新时间条件,在更新时检查旧版本;若版本不匹配,返回冲突并提示重新加载。单独展示 updated_at 并不能自动防止覆盖。

七、常见错误与改进方向

  • 一次性加载所有记录:会增加数据库、网络和浏览器的负担,也可能暴露本不需要返回的数据。采用服务端分页,并只返回列表展示所需字段。
  • 把排序字段直接插进 SQL:值参数化不能保护动态列名。动态 SQL 结构必须来自服务端白名单,或使用数据库驱动提供的安全标识符处理方式。
  • 只做前端校验:客户端校验改善使用体验,但请求可以被跳过或伪造。服务端必须独立验证类型、长度、枚举值和权限;数据库约束可作为额外防线。
  • 用展示位置代替记录 ID:分页、排序或筛选变化都会改变位置。更新操作必须使用稳定主键,并在服务端再次确认目标存在且允许修改。
  • 把空结果和请求失败混为一谈:“没有匹配记录”是成功查询但零条结果;超时、权限不足和服务器异常是请求失败。界面应分别表达,不能都显示为空白表格。
  • 忽略特殊字符和长度成本:参数化查询可以防注入,但不能阻止用户输入过长内容、触发昂贵的模糊匹配或制造大量请求。应设置合理长度、频率限制,结合索引、超时和监控评估查询成本。

八、上线前检查清单

上线前至少确认:分页上限和字段长度与业务一致;列表查询只返回必要列;排序字段有服务端白名单;编辑接口校验字段、唯一约束和对象级权限;动态文本没有被当作 HTML 执行;请求错误、空结果和冲突有明确反馈;认证、CSRF(适用时)、日志、备份和数据库迁移方案已经纳入部署设计。

数据库界面并不只是表格组件的拼装。可靠的实现需要让查询范围可控、编辑边界明确,并让用户知道当前数据状态。先把这些规则做清楚,再考虑批量操作、列配置、导出和复杂筛选,通常比一开始追求功能数量更稳妥。

© 版权声明

相关文章