feat: 新增 icons.region 配置项&修改 favicon 加载超时机制&修复去除硬编码

- 新增 icons.region: com | cn 配置项,允许用户选择优先使用国内源或国外源
  - com: 优先 gstatic.com,失败回退 gstatic.cn
  - cn: 优先 gstatic.cn,失败回退 gstatic.com
- 修改 favicon 加载超时判断机制
  - 自定义 faviconUrl: 5秒超时后显示回退图标
  - 自动 favicon: 每次尝试3秒超时,最多等待6秒
- 更新配置文档和默认配置示例
- 去除卡片模板中的url硬编码
Issue: #31
This commit is contained in:
rbetree
2026-01-03 16:43:50 +08:00
parent 87b4cea290
commit 3473aaebd7
7 changed files with 127 additions and 13 deletions

View File

@@ -43,6 +43,20 @@
<details> <details>
<summary>点击查看/隐藏更新日志</summary> <summary>点击查看/隐藏更新日志</summary>
### 2026/01/03
关联 Issue[#31](https://github.com/rbetree/menav/issues/31)
**1. favicon 加载优化**
- 新增 `icons.region: com | cn` 配置项,允许用户选择优先使用国内源或国外源
- `com`(默认):优先 gstatic.com失败回退 gstatic.cn
- `cn`:优先 gstatic.cn失败回退 gstatic.com
- 修改 favicon 加载超时判断机制
- 自定义 faviconUrl5秒超时后显示回退图标
- 自动 favicon每次尝试3秒超时最多等待6秒
- 避免网络慢时长时间显示加载动画
### 2026/01/02 ### 2026/01/02
关联 Issue[#30](https://github.com/rbetree/menav/issues/30) 关联 Issue[#30](https://github.com/rbetree/menav/issues/30)

View File

@@ -106,6 +106,10 @@ MeNav 配置系统采用“完全替换”策略(不合并),按以下优
- `icons.mode: favicon | manual` - `icons.mode: favicon | manual`
- `favicon`会请求第三方服务Google获取站点 favicon失败自动回退到 Font Awesome 图标 - `favicon`会请求第三方服务Google获取站点 favicon失败自动回退到 Font Awesome 图标
- `manual`:完全使用手动 Font Awesome 图标,不发起外部请求(适合内网/离线/隐私敏感场景) - `manual`:完全使用手动 Font Awesome 图标,不发起外部请求(适合内网/离线/隐私敏感场景)
- `icons.region: com | cn`(默认 `com`
- `com`:优先使用 `gstatic.com`(国际版),失败后回退到 `gstatic.cn`(中国版)
- `cn`:优先使用 `gstatic.cn`(中国版),失败后回退到 `gstatic.com`(国际版)
- 说明:如果你在中国大陆且访问 gstatic.com 较慢,建议设置为 `cn` 以提升图标加载速度
- 站点级覆盖(可选,写在 `pages/*.yml` 的每个 `sites[]` 节点上): - 站点级覆盖(可选,写在 `pages/*.yml` 的每个 `sites[]` 节点上):
- `faviconUrl`:为单个站点指定图标链接(可远程或本地相对路径;本地建议以 `assets/` 开头,构建会复制到 `dist/` 同路径),优先级最高 - `faviconUrl`:为单个站点指定图标链接(可远程或本地相对路径;本地建议以 `assets/` 开头,构建会复制到 `dist/` 同路径),优先级最高
- `forceIconMode: favicon | manual`:强制该站点使用指定模式(不设置则跟随全局 `icons.mode` - `forceIconMode: favicon | manual`:强制该站点使用指定模式(不设置则跟随全局 `icons.mode`
@@ -116,7 +120,7 @@ MeNav 配置系统采用“完全替换”策略(不合并),按以下优
- name: "Ant Design" - name: "Ant Design"
url: "https://ant.design/" url: "https://ant.design/"
icon: "fas fa-th" icon: "fas fa-th"
forceIconMode: manual # 强制使用手动图标,绕过 favicon 默认地球图标 forceIconMode: manual # 强制使用手动图标,绕过 favicon 默认"地球"图标
- name: "Example" - name: "Example"
url: "https://example.com/" url: "https://example.com/"
faviconUrl: "https://example.com/favicon.png" # 单站点自定义 favicon faviconUrl: "https://example.com/favicon.png" # 单站点自定义 favicon

View File

@@ -12,9 +12,15 @@ icons:
# 站点卡片图标模式: # 站点卡片图标模式:
# - favicon自动根据 URL 加载站点 favicon失败时回退到 Font Awesome 图标) # - favicon自动根据 URL 加载站点 favicon失败时回退到 Font Awesome 图标)
# - manual始终使用手动指定的 Font Awesome 图标(不发起外部请求) # - manual始终使用手动指定的 Font Awesome 图标(不发起外部请求)
# 隐私提示:启用 favicon 模式会请求第三方服务以获取图标,可能将站点 URL 发送给服务商(详见 README隐私说明)。 # 隐私提示:启用 favicon 模式会请求第三方服务以获取图标,可能将站点 URL 发送给服务商(详见 README"隐私说明")。
mode: favicon # 可选: favicon | manual默认 favicon mode: favicon # 可选: favicon | manual默认 favicon
# favicon 服务区域选择(仅在 mode: favicon 时生效):
# - com优先使用 gstatic.com国际版失败后回退到 gstatic.cn中国版
# - cn优先使用 gstatic.cn中国版失败后回退到 gstatic.com国际版
# 说明:如果你在中国大陆且访问 gstatic.com 较慢,建议设置为 cn 以提升图标加载速度
region: cn # 可选: com | cn默认 com
# 字体设置:全站基础字体 # 字体设置:全站基础字体
# - source: css | google | system # - source: css | google | system
# - css: 通过 cssUrl 引入第三方字体 CSS # - css: 通过 cssUrl 引入第三方字体 CSS

View File

@@ -307,6 +307,8 @@ function ensureConfigDefaults(config) {
result.icons = result.icons || {}; result.icons = result.icons || {};
// icons.mode: manual | favicon, 默认 favicon // icons.mode: manual | favicon, 默认 favicon
result.icons.mode = result.icons.mode || 'favicon'; result.icons.mode = result.icons.mode || 'favicon';
// icons.region: com | cn, 默认 com优先使用 gstatic.com失败后回退到 gstatic.cn
result.icons.region = result.icons.region || 'com';
// 站点基本信息默认值 // 站点基本信息默认值
result.site.title = result.site.title || 'MeNav导航'; result.site.title = result.site.title || 'MeNav导航';

View File

@@ -194,6 +194,48 @@ function add(a, b) {
return numA + numB; return numA + numB;
} }
/**
* 根据 icons.region 配置生成 favicon URL
* @param {string} url 站点 URL
* @param {Object} options Handlebars options 对象
* @returns {string} favicon URL
* @example {{faviconUrl url}}
*/
function faviconUrl(url, options) {
if (!url) return '';
const region = options.data.root.icons?.region || 'com';
const domain = region === 'cn' ? 't3.gstatic.cn' : 't3.gstatic.com';
try {
const encodedUrl = encodeURIComponent(String(url));
return `https://${domain}/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${encodedUrl}&size=32`;
} catch (e) {
return '';
}
}
/**
* 根据 icons.region 配置生成 favicon 回退 URL
* @param {string} url 站点 URL
* @param {Object} options Handlebars options 对象
* @returns {string} favicon 回退 URL
* @example {{faviconFallbackUrl url}}
*/
function faviconFallbackUrl(url, options) {
if (!url) return '';
const region = options.data.root.icons?.region || 'com';
const domain = region === 'cn' ? 't3.gstatic.com' : 't3.gstatic.cn';
try {
const encodedUrl = encodeURIComponent(String(url));
return `https://${domain}/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${encodedUrl}&size=32`;
} catch (e) {
return '';
}
}
// 导出所有工具类助手函数 // 导出所有工具类助手函数
module.exports = { module.exports = {
slice, slice,
@@ -205,5 +247,7 @@ module.exports = {
pick, pick,
keys, keys,
encodeURIComponent: encodeURIComponentHelper, encodeURIComponent: encodeURIComponentHelper,
add add,
faviconUrl,
faviconFallbackUrl
}; };

View File

@@ -392,14 +392,17 @@ window.MeNav = {
contentWrapper.appendChild(descEl); contentWrapper.appendChild(descEl);
let iconsMode = 'favicon'; let iconsMode = 'favicon';
let iconsRegion = 'com';
try { try {
const cfg = const cfg =
window.MeNav && typeof window.MeNav.getConfig === 'function' window.MeNav && typeof window.MeNav.getConfig === 'function'
? window.MeNav.getConfig() ? window.MeNav.getConfig()
: null; : null;
iconsMode = (cfg && (cfg.data?.icons?.mode || cfg.icons?.mode)) || 'favicon'; iconsMode = (cfg && (cfg.data?.icons?.mode || cfg.icons?.mode)) || 'favicon';
iconsRegion = (cfg && (cfg.data?.icons?.region || cfg.icons?.region)) || 'com';
} catch (e) { } catch (e) {
iconsMode = 'favicon'; iconsMode = 'favicon';
iconsRegion = 'com';
} }
const shouldUseCustomFavicon = Boolean(siteFaviconUrl); const shouldUseCustomFavicon = Boolean(siteFaviconUrl);
@@ -422,11 +425,23 @@ window.MeNav = {
favicon.src = siteFaviconUrl; favicon.src = siteFaviconUrl;
favicon.alt = `${siteName} favicon`; favicon.alt = `${siteName} favicon`;
favicon.loading = 'lazy'; favicon.loading = 'lazy';
// 超时处理5秒后如果还没加载成功显示回退图标
let loadTimeout = setTimeout(() => {
if (!favicon.classList.contains('loaded')) {
favicon.classList.add('error');
placeholder.classList.add('hidden');
fallback.classList.add('visible');
}
}, 5000);
favicon.addEventListener('load', () => { favicon.addEventListener('load', () => {
clearTimeout(loadTimeout);
favicon.classList.add('loaded'); favicon.classList.add('loaded');
placeholder.classList.add('hidden'); placeholder.classList.add('hidden');
}); });
favicon.addEventListener('error', () => { favicon.addEventListener('error', () => {
clearTimeout(loadTimeout);
favicon.classList.add('error'); favicon.classList.add('error');
placeholder.classList.add('hidden'); placeholder.classList.add('hidden');
fallback.classList.add('visible'); fallback.classList.add('visible');
@@ -437,8 +452,13 @@ window.MeNav = {
iconContainer.appendChild(fallback); iconContainer.appendChild(fallback);
iconWrapper.appendChild(iconContainer); iconWrapper.appendChild(iconContainer);
} else if (effectiveIconsMode === 'favicon' && siteUrl && /^https?:\/\//i.test(siteUrl)) { } else if (effectiveIconsMode === 'favicon' && siteUrl && /^https?:\/\//i.test(siteUrl)) {
const faviconUrlPrimary = `https://t3.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${encodeURIComponent(siteUrl)}&size=32`; // 根据 icons.region 配置决定优先使用哪个域名
const faviconUrlFallback = `https://t3.gstatic.cn/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${encodeURIComponent(siteUrl)}&size=32`; const faviconUrlPrimary = iconsRegion === 'cn'
? `https://t3.gstatic.cn/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${encodeURIComponent(siteUrl)}&size=32`
: `https://t3.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${encodeURIComponent(siteUrl)}&size=32`;
const faviconUrlFallback = iconsRegion === 'cn'
? `https://t3.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${encodeURIComponent(siteUrl)}&size=32`
: `https://t3.gstatic.cn/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${encodeURIComponent(siteUrl)}&size=32`;
const iconContainer = document.createElement('div'); const iconContainer = document.createElement('div');
iconContainer.className = 'icon-container'; iconContainer.className = 'icon-container';
@@ -457,14 +477,38 @@ window.MeNav = {
favicon.alt = `${siteName} favicon`; favicon.alt = `${siteName} favicon`;
favicon.loading = 'lazy'; favicon.loading = 'lazy';
let faviconFallbackTried = false; let faviconFallbackTried = false;
let loadTimeout = null;
// 超时处理3秒后如果还没加载成功尝试回退 URL 或显示 Font Awesome 图标
const startTimeout = () => {
if (loadTimeout) clearTimeout(loadTimeout);
loadTimeout = setTimeout(() => {
if (!favicon.classList.contains('loaded')) {
if (!faviconFallbackTried) {
faviconFallbackTried = true;
favicon.src = faviconUrlFallback;
startTimeout(); // 为 fallback URL 也设置超时
} else {
favicon.classList.add('error');
placeholder.classList.add('hidden');
fallback.classList.add('visible');
}
}
}, 3000);
};
startTimeout();
favicon.addEventListener('load', () => { favicon.addEventListener('load', () => {
if (loadTimeout) clearTimeout(loadTimeout);
favicon.classList.add('loaded'); favicon.classList.add('loaded');
placeholder.classList.add('hidden'); placeholder.classList.add('hidden');
}); });
favicon.addEventListener('error', () => { favicon.addEventListener('error', () => {
if (loadTimeout) clearTimeout(loadTimeout);
if (!faviconFallbackTried) { if (!faviconFallbackTried) {
faviconFallbackTried = true; faviconFallbackTried = true;
favicon.src = faviconUrlFallback; favicon.src = faviconUrlFallback;
startTimeout(); // 为 fallback URL 也设置超时
return; return;
} }

View File

@@ -38,11 +38,11 @@
<i class="fas fa-circle-notch fa-spin icon-placeholder" aria-hidden="true"></i> <i class="fas fa-circle-notch fa-spin icon-placeholder" aria-hidden="true"></i>
<img <img
class="favicon-icon" class="favicon-icon"
src="https://t3.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url={{encodeURIComponent url}}&size=32" src="{{faviconUrl url}}"
alt="{{name}} favicon" alt="{{name}} favicon"
loading="lazy" loading="lazy"
onload="this.classList.add('loaded'); this.previousElementSibling.classList.add('hidden');" onload="this.classList.add('loaded'); this.previousElementSibling.classList.add('hidden');"
onerror="if (!this.dataset.faviconFallbackTried) { this.dataset.faviconFallbackTried = '1'; var next = this.src.replace('t3.gstatic.com', 't3.gstatic.cn'); if (next !== this.src) { this.src = next; return; } } this.classList.add('error'); this.previousElementSibling.classList.add('hidden'); this.nextElementSibling.classList.add('visible');" onerror="if (!this.dataset.faviconFallbackTried) { this.dataset.faviconFallbackTried = '1'; this.src = '{{faviconFallbackUrl url}}'; return; } this.classList.add('error'); this.previousElementSibling.classList.add('hidden'); this.nextElementSibling.classList.add('visible');"
/> />
<i class="{{#if icon}}{{icon}}{{else}}fas fa-link{{/if}} icon-fallback" aria-hidden="true"></i> <i class="{{#if icon}}{{icon}}{{else}}fas fa-link{{/if}} icon-fallback" aria-hidden="true"></i>
</div> </div>
@@ -56,11 +56,11 @@
<i class="fas fa-circle-notch fa-spin icon-placeholder" aria-hidden="true"></i> <i class="fas fa-circle-notch fa-spin icon-placeholder" aria-hidden="true"></i>
<img <img
class="favicon-icon" class="favicon-icon"
src="https://t3.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url={{encodeURIComponent url}}&size=32" src="{{faviconUrl url}}"
alt="{{name}} favicon" alt="{{name}} favicon"
loading="lazy" loading="lazy"
onload="this.classList.add('loaded'); this.previousElementSibling.classList.add('hidden');" onload="this.classList.add('loaded'); this.previousElementSibling.classList.add('hidden');"
onerror="if (!this.dataset.faviconFallbackTried) { this.dataset.faviconFallbackTried = '1'; var next = this.src.replace('t3.gstatic.com', 't3.gstatic.cn'); if (next !== this.src) { this.src = next; return; } } this.classList.add('error'); this.previousElementSibling.classList.add('hidden'); this.nextElementSibling.classList.add('visible');" onerror="if (!this.dataset.faviconFallbackTried) { this.dataset.faviconFallbackTried = '1'; this.src = '{{faviconFallbackUrl url}}'; return; } this.classList.add('error'); this.previousElementSibling.classList.add('hidden'); this.nextElementSibling.classList.add('visible');"
/> />
<i class="{{#if icon}}{{icon}}{{else}}fas fa-link{{/if}} icon-fallback" aria-hidden="true"></i> <i class="{{#if icon}}{{icon}}{{else}}fas fa-link{{/if}} icon-fallback" aria-hidden="true"></i>
</div> </div>
@@ -156,11 +156,11 @@
<i class="fas fa-circle-notch fa-spin icon-placeholder" aria-hidden="true"></i> <i class="fas fa-circle-notch fa-spin icon-placeholder" aria-hidden="true"></i>
<img <img
class="favicon-icon" class="favicon-icon"
src="https://t3.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url={{encodeURIComponent url}}&size=32" src="{{faviconUrl url}}"
alt="{{name}} favicon" alt="{{name}} favicon"
loading="lazy" loading="lazy"
onload="this.classList.add('loaded'); this.previousElementSibling.classList.add('hidden');" onload="this.classList.add('loaded'); this.previousElementSibling.classList.add('hidden');"
onerror="if (!this.dataset.faviconFallbackTried) { this.dataset.faviconFallbackTried = '1'; var next = this.src.replace('t3.gstatic.com', 't3.gstatic.cn'); if (next !== this.src) { this.src = next; return; } } this.classList.add('error'); this.previousElementSibling.classList.add('hidden'); this.nextElementSibling.classList.add('visible');" onerror="if (!this.dataset.faviconFallbackTried) { this.dataset.faviconFallbackTried = '1'; this.src = '{{faviconFallbackUrl url}}'; return; } this.classList.add('error'); this.previousElementSibling.classList.add('hidden'); this.nextElementSibling.classList.add('visible');"
/> />
<i class="{{#if icon}}{{icon}}{{else}}fas fa-link{{/if}} icon-fallback" aria-hidden="true"></i> <i class="{{#if icon}}{{icon}}{{else}}fas fa-link{{/if}} icon-fallback" aria-hidden="true"></i>
</div> </div>
@@ -174,11 +174,11 @@
<i class="fas fa-circle-notch fa-spin icon-placeholder" aria-hidden="true"></i> <i class="fas fa-circle-notch fa-spin icon-placeholder" aria-hidden="true"></i>
<img <img
class="favicon-icon" class="favicon-icon"
src="https://t3.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url={{encodeURIComponent url}}&size=32" src="{{faviconUrl url}}"
alt="{{name}} favicon" alt="{{name}} favicon"
loading="lazy" loading="lazy"
onload="this.classList.add('loaded'); this.previousElementSibling.classList.add('hidden');" onload="this.classList.add('loaded'); this.previousElementSibling.classList.add('hidden');"
onerror="if (!this.dataset.faviconFallbackTried) { this.dataset.faviconFallbackTried = '1'; var next = this.src.replace('t3.gstatic.com', 't3.gstatic.cn'); if (next !== this.src) { this.src = next; return; } } this.classList.add('error'); this.previousElementSibling.classList.add('hidden'); this.nextElementSibling.classList.add('visible');" onerror="if (!this.dataset.faviconFallbackTried) { this.dataset.faviconFallbackTried = '1'; this.src = '{{faviconFallbackUrl url}}'; return; } this.classList.add('error'); this.previousElementSibling.classList.add('hidden'); this.nextElementSibling.classList.add('visible');"
/> />
<i class="{{#if icon}}{{icon}}{{else}}fas fa-link{{/if}} icon-fallback" aria-hidden="true"></i> <i class="{{#if icon}}{{icon}}{{else}}fas fa-link{{/if}} icon-fallback" aria-hidden="true"></i>
</div> </div>