导航网站源码二次开发:添加分类、搜索和网站图标

站长资讯6小时前发布 jdkjadmin
0 00
导航网站源码二次开发:添加分类、搜索和网站图标

导航网站源码二次开发:添加分类、搜索和网站图标

导航网站通常从一组链接开始:访客按分类浏览,或输入关键词查找目标站点。给现有源码添加这些功能,难点不只在界面上,还包括如何组织数据、避免用户输入被当作 HTML 执行、怎样处理图标失效,以及如何在不引入额外依赖的情况下运行和维护。

本文用原生 HTML、CSS 和 JavaScript 实现一个可运行的静态示例:分类由数据生成,搜索支持标题、描述和网址,网站卡片显示指定图标并在加载失败时回退;同时设置浏览器标签页使用的站点 favicon。示例适合站点数量不大、内容由开发者维护、部署为静态文件的导航页。它不是带权限、后台管理和数据库的完整内容系统。

一、先界定需求和边界

动手改代码前,先确认要添加的“图标”指什么:

  • 浏览器 favicon:浏览器标签页或收藏夹中代表导航站本身的小图标,通过页面的 link rel="icon" 指定。
  • 导航卡片图标:每个链接站点自己的图标,通常是卡片中的小图像。这些图标由导航站点加载,和本网站的 favicon 是两件事。

本文会分别设置两者。卡片图标使用数据中明确提供的地址;对于未配置图标或图标加载失败的站点,显示文字回退图标。不要假定任意网站都开放了可稳定访问的图标接口,也不要为了显示图标而默认抓取目标站点内容。

静态方案的边界也要明确:分类和站点数据写在 JavaScript 文件中,修改后需要重新发布;搜索在浏览器本地执行;没有多用户编辑、审核、账号权限或服务器端搜索。若需要这些能力,应另行设计后端、存储、访问控制和输入校验,不能仅靠前端隐藏按钮或过滤界面代替权限控制。

二、准备项目文件

新建一个目录,放入以下三个文件。图标目录用于存放本地 SVG,例如 icons/default.svg;示例也会在没有本地站点图标时使用文字回退,因此该文件并非运行必需。

nav-site/
├── index.html
├── style.css
├── app.js
├── favicon.svg
└── icons/
    └── default.svg

以下示例不依赖构建工具或第三方库。将代码分别保存为对应文件即可。生产环境建议通过 HTTPS 静态托管,并使用本地 HTTP 服务测试;直接双击打开文件时,浏览器对模块、路径和本地资源的处理可能与网站环境不同。

三、页面结构与浏览器 favicon

创建 index.html。分类按钮和卡片容器先留空,由 JavaScript 根据数据生成;搜索框使用原生表单控件,保留键盘操作和标签语义。favicon 的路径相对于网站根目录,若部署在子目录,应相应调整路径。

<!doctype html>
<html lang="zh-CN">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <meta name="description" content="按分类浏览常用网站,或搜索站点名称与说明。">
  <title>常用网站导航</title>
  <link rel="icon" href="./favicon.svg" type="image/svg+xml">
  <link rel="stylesheet" href="./style.css">
  <script src="./app.js" defer></script>
</head>
<body>
  <header class="page-header">
    <h1>常用网站导航</h1>
    <p>按分类浏览,或输入名称、说明和网址进行搜索。</p>
  </header>

  <main>
    <form id="search-form" role="search">
      <label for="search-input">搜索网站</label>
      <input
        id="search-input"
        type="search"
        placeholder="例如:文档、设计、example.com"
        autocomplete="off"
      >
      <button type="submit">搜索</button>
    </form>

    <nav id="category-list" aria-label="网站分类"></nav>
    <p id="result-status" aria-live="polite"></p>
    <section id="site-list" class="site-grid" aria-label="网站列表"></section>
  </main>
</body>
</html>

新建 favicon.svg。这是浏览器标签页图标,不是卡片上某个链接站点的图标。SVG 能否作为 favicon 取决于浏览器支持和服务器返回的内容类型;若需兼容旧环境,可额外提供 PNG 或 ICO,并配置相应的 link 标签。

<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
  <rect width="64" height="64" rx="14" fill="#2563eb"/>
  <path d="M16 20h32v6H16zm0 12h32v6H16zm0 12h22v6H16z" fill="#fff"/>
</svg>

四、定义站点数据和分类

每个站点使用固定字段:唯一 id、名称 title、说明 description、分类 category、跳转地址 url 和可选图标地址 icon。分类名称要与站点数据中的分类值一致,否则该站点不会出现在按分类筛选的结果中。

新建 app.js,先放入数据。示例域名仅用于说明字段结构,实际使用时请替换成你核实过的站点地址。网址必须包含协议,例如 https://example.com/。

const sites = [
  {
    id: "docs",
    title: "示例文档",
    description: "查阅产品说明与开发文档。",
    category: "开发",
    url: "https://example.com/docs",
    icon: ""
  },
  {
    id: "design",
    title: "示例设计资源",
    description: "浏览设计工具与灵感资源。",
    category: "设计",
    url: "https://example.org/",
    icon: ""
  },
  {
    id: "status",
    title: "示例状态页",
    description: "查看服务运行状态。",
    category: "开发",
    url: "https://status.example.net/",
    icon: ""
  }
];

const categories = ["全部", "开发", "设计"];

数据最好由可信的维护者编辑和审查。若改成从 API 或用户提交内容中读取,不能因为字段“看起来像网址”就直接写入链接属性;仍要在使用位置验证协议,并由服务端实施相应的认证、授权和数据校验。

五、实现分类、搜索和安全渲染

分类筛选与搜索采用“同时满足”的逻辑:选定分类后,搜索只在该分类结果中继续筛选。搜索不区分大小写,按名称、说明、分类和网址匹配。代码不把数据拼进 innerHTML,而是创建节点并使用 textContent 设置文本,避免站点标题或说明中的字符被当作 HTML 执行。

网址验证采用浏览器的 URL 解析器,并仅接受 http:、https: 协议。这个检查的用途是限制导航和图标资源地址的协议,不代表目标网站安全、可信或一定可访问;它也不等于服务端防护。卡片用新标签页打开时设置 rel="noopener noreferrer",降低新页面通过 window.opener 操作原页面的风险。

const categoryList = document.querySelector("#category-list");
const siteList = document.querySelector("#site-list");
const searchForm = document.querySelector("#search-form");
const searchInput = document.querySelector("#search-input");
const resultStatus = document.querySelector("#result-status");

let activeCategory = "全部";

function allowedWebUrl(value) {
  try {
    const parsed = new URL(value);
    if (parsed.protocol === "https:" || parsed.protocol === "http:") {
      return parsed.href;
    }
  } catch {
    // 无法解析的地址按无效处理。
  }
  return null;
}

function makeCategoryButtons() {
  categoryList.replaceChildren();

  for (const category of categories) {
    const button = document.createElement("button");
    button.type = "button";
    button.textContent = category;
    button.setAttribute("aria-pressed", String(category === activeCategory));

    button.addEventListener("click", () => {
      activeCategory = category;
      makeCategoryButtons();
      renderSites();
    });

    categoryList.append(button);
  }
}

function makeSiteCard(site) {
  const article = document.createElement("article");
  article.className = "site-card";

  const heading = document.createElement("h2");
  const link = document.createElement("a");
  const safeUrl = allowedWebUrl(site.url);

  link.textContent = site.title;
  link.href = safeUrl || "#";
  link.target = "_blank";
  link.rel = "noopener noreferrer";

  if (!safeUrl) {
    link.removeAttribute("target");
    link.removeAttribute("href");
    link.setAttribute("aria-disabled", "true");
  }

  const iconUrl = site.icon ? allowedWebUrl(site.icon) : null;

  if (iconUrl) {
    const image = document.createElement("img");
    image.className = "site-icon";
    image.src = iconUrl;
    image.alt = "";
    image.width = 40;
    image.height = 40;
    image.loading = "lazy";
    image.addEventListener("error", () => {
      image.replaceWith(makeTextIcon(site.title));
    }, { once: true });
    heading.append(image);
  } else {
    heading.append(makeTextIcon(site.title));
  }

  heading.append(link);

  const description = document.createElement("p");
  description.textContent = site.description;

  const category = document.createElement("span");
  category.className = "site-category";
  category.textContent = site.category;

  article.append(heading, description, category);
  return article;
}

function makeTextIcon(title) {
  const icon = document.createElement("span");
  icon.className = "text-icon";
  icon.setAttribute("aria-hidden", "true");
  icon.textContent = Array.from(title || "?")[0] || "?";
  return icon;
}

function renderSites() {
  const query = searchInput.value.trim().toLocaleLowerCase();

  const filtered = sites.filter((site) => {
    const matchesCategory =
      activeCategory === "全部" || site.category === activeCategory;

    const searchableText = [
      site.title,
      site.description,
      site.category,
      site.url
    ].join(" ").toLocaleLowerCase();

    return matchesCategory && searchableText.includes(query);
  });

  siteList.replaceChildren(...filtered.map(makeSiteCard));
  resultStatus.textContent = `找到 ${filtered.length} 个网站`;
}

searchForm.addEventListener("submit", (event) => {
  event.preventDefault();
  renderSites();
});

searchInput.addEventListener("input", renderSites);

makeCategoryButtons();
renderSites();

上面的无效网址处理会让标题保留为不可点击文本。不要把 javascript:、data: 等协议直接交给 href 或 img.src;当前函数会拒绝它们。示例允许 HTTP 和 HTTPS,因此如需强制站点链接使用 HTTPS,可将条件收紧为仅接受 https:,但也要确认目标站点确实支持 HTTPS。不能将协议限制描述为对恶意站点的全面安全保证。

搜索框的输入只参与字符串匹配,不执行代码,也没有被插入 HTML。搜索结果显示数量,空结果时会显示“找到 0 个网站”;如果希望提供专门的空状态,可以在 renderSites 中根据 filtered.length 追加提示节点。不要用正则表达式拼接未经处理的搜索词来构造匹配规则,简单搜索使用 includes 更直接。

六、添加基础样式

新建 style.css。样式不影响数据和筛选逻辑;你可以按现有源码的视觉规范调整颜色、间距和断点。图标尺寸固定,能减少图片加载前后布局跳动。按钮状态使用 aria-pressed 标记,样式和状态保持一致。

* {
  box-sizing: border-box;
}

body {
  margin: 0;
  color: #172033;
  background: #f5f7fb;
  font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
}

.page-header,
main {
  width: min(960px, calc(100% - 32px));
  margin-inline: auto;
}

.page-header {
  padding: 40px 0 20px;
}

.page-header h1 {
  margin: 0 0 8px;
}

.page-header p {
  margin: 0;
  color: #5b6474;
}

#search-form {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 10px;
  margin: 16px 0;
}

#search-form label {
  flex-basis: 100%;
  font-weight: 600;
}

#search-input {
  flex: 1 1 240px;
  min-width: 0;
  padding: 10px 12px;
}

button,
#search-input {
  border: 1px solid #cbd2df;
  border-radius: 8px;
  font: inherit;
}

button {
  padding: 9px 13px;
  color: #172033;
  background: #fff;
  cursor: pointer;
}

button[aria-pressed="true"] {
  color: #fff;
  border-color: #2563eb;
  background: #2563eb;
}

#category-list {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  margin: 18px 0;
}

#result-status {
  min-height: 1.5em;
  color: #5b6474;
}

.site-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 250px), 1fr));
  gap: 14px;
  padding-bottom: 40px;
}

.site-card {
  padding: 16px;
  border: 1px solid #e1e6ef;
  border-radius: 12px;
  background: #fff;
}

.site-card h2 {
  display: flex;
  align-items: center;
  gap: 10px;
  margin: 0 0 10px;
  font-size: 1.05rem;
}

.site-card a {
  color: #1d4ed8;
  overflow-wrap: anywhere;
}

.site-card p {
  min-height: 3em;
  color: #5b6474;
  line-height: 1.5;
}

.site-icon,
.text-icon {
  flex: 0 0 40px;
  width: 40px;
  height: 40px;
  border-radius: 9px;
}

.site-icon {
  object-fit: contain;
  background: #f1f4f9;
}

.text-icon {
  display: grid;
  place-items: center;
  color: #1d4ed8;
  background: #e8efff;
  font-weight: 700;
}

.site-category {
  color: #5b6474;
  font-size: 0.9rem;
}

七、配置卡片图标的几种方式

优先使用自有静态文件。将图标放在项目目录中,数据使用相对路径,例如 icon: "./icons/docs.svg"。确认服务器会按路径提供该文件,并且部署到子目录时相对路径仍然正确。维护者应检查图标文件来源,避免把未知 SVG 当作可信内容;SVG 作为图片加载与把 SVG 内联到 HTML 的风险模型不同,但来源仍应可控。

使用远程图标时明确写入 HTTPS 地址。远程图片可能因网络故障、站点策略、图标被删除或防盗链而加载失败,因此需要回退方案。图片请求通常会让浏览器连接到图标所在的第三方主机,可能暴露访客的网络信息或浏览器请求信息;对隐私敏感的网站,可将经审核的图标托管在自己的域名下。浏览器加载图片通常不要求目标服务器开放 JavaScript 的跨域读取权限,但这并不意味着图片一定能显示,也不意味着可以读取其内容。

不建议无条件自动拼接 favicon 地址。“目标域名加固定路径”的做法依赖目标站点的部署约定,不保证存在;直接访问图标服务还可能引入第三方请求、可用性和隐私问题。若确需自动发现或代理图标,应单独处理重定向、响应类型、缓存、请求频率和目标地址限制,尤其避免服务器端代理接受任意用户 URL 而形成 SSRF 风险。本文的静态示例不包含抓取或代理功能。

八、运行和验证

在项目目录启动本地静态服务器。使用 Python 的一个可选方式是:

python -m http.server 8000

然后在浏览器打开 http://localhost:8000/。也可以使用 Node.js 生态中任意可信的静态文件服务器。测试时至少检查以下情况:

  • 点击“开发”“设计”和“全部”,确认分类结果正确,按钮的选中状态同步更新。
  • 输入站点名称、说明、分类和网址中的关键词,确认能搜索到对应卡片;清空搜索后应恢复当前分类下的全部结果。
  • 把一个图标字段设为有效的本地路径,再设为不存在的路径,分别确认图片和文字回退状态。
  • 把某个 url 改为缺少协议、格式错误或 javascript: 地址,确认卡片不生成可执行的危险链接。
  • 检查浏览器开发者工具的控制台和网络面板,排查脚本路径、样式路径、图标 404 和资源加载错误。
  • 修改 favicon 后可能仍看到旧图标,这是浏览器缓存常见现象。可清理站点缓存或使用新文件名验证;不要仅凭标签页暂时未变化就判断配置无效。

九、常见错误及处理方式

1. 分类按钮有了,卡片却筛选不出来

多数情况下是分类字符串不一致,例如按钮使用“开发工具”,数据写成“开发”。分类应集中定义,并在维护数据时采用同一套值。若站点来自外部数据源,可在导入阶段检查分类是否合法;不要依赖界面上恰好没有显示某个分类来代替数据校验。

2. 搜索只能匹配名称

检查构造搜索文本时是否包含说明、分类和网址。本文把这些字段合并后统一转成小写,再用 includes 匹配。对于多语言搜索、拼音、错别字容错或大量数据的全文检索,这种实现并不充分;可以再设计索引或采用合适的搜索服务,但不要把普通子串匹配宣传成语义搜索。

3. 用模板字符串拼接卡片 HTML

例如把来自 API 的标题直接拼进 innerHTML,可能让输入被解释为标记或事件属性。本文使用 textContent 写入站点名称和说明。若确实需要渲染受限富文本,应选择经过维护的消毒方案并定义允许标签,不要自己用几个字符串替换来模拟 HTML 清理。

4. 图标加载失败后反复触发错误

错误事件中应替换为不会再次请求失效地址的内容。本文用文字节点替代图片,并通过一次性事件监听避免重复处理。回退元素使用空的 alt,因为它仅作装饰;如果图标本身承载了重要信息,则应提供合适的替代文本。

5. 站点跳转地址能打开,但图标地址被拒绝

跳转地址与图标地址都经过 HTTP/HTTPS 协议校验,但浏览器仍可能因网络、服务器响应、证书、访问策略或资源不存在而无法加载图标。协议校验只解决允许哪类 URL 的问题,不负责验证内容正确性、站点信誉、证书有效性或业务可用性。

6. 新标签页打开后未设置安全属性

使用 target="_blank" 时保留 rel="noopener noreferrer"。其中 noopener 用于阻止新页面通过 window.opener 操作来源页面;noreferrer 会抑制引用来源信息,并在常见浏览器实现中也具有不提供 opener 的效果。若业务需要发送来源信息,应理解相应隐私影响,并另行评估属性组合。

7. 在生产站点加入用户自定义链接,却只做前端校验

前端检查便于改善使用体验,但任何用户都可以绕过前端直接向接口提交请求。若将来加入编辑后台,必须在服务端验证并保存数据,按用户身份检查授权,限制可接受的字段和协议,并记录必要的审计信息。输出到页面时仍应进行与上下文匹配的安全处理。本文代码不提供这些后台能力。

十、二次开发建议

当站点数量增长时,可以把数据单独放在 JSON 文件中,由前端读取,并在构建或发布前校验必填字段、重复 ID、无效网址和未知分类。将数据外置只是组织方式的改变,不会自动提供权限、安全校验或后台编辑。若站点数据需要非技术人员维护,可考虑内容管理系统或自建服务,但应把数据编辑、发布审核和访客展示分开设计。

也可以增加“最近添加”、标签或收藏等功能,但建议先把数据结构和筛选逻辑写清楚。收藏若保存在浏览器本地,只对当前浏览器生效,清除站点数据后可能丢失,也不会自动同步到其他设备;若要跨设备同步,就需要账号和服务端存储,并应说明数据保留与隐私规则。

这套实现的核心取舍是保持简单:分类和搜索在客户端完成,站点内容由可信维护者提供,卡片仅接受明确的 HTTP/HTTPS 地址,文本按纯文本渲染,图标加载失败时有回退。了解这些边界之后,再根据实际访问量、编辑需求和安全要求决定是否引入框架、搜索服务或后台,而不是为了一个筛选按钮就把整套站点复杂化。

© 版权声明

相关文章