Merge pull request 'Codex/design 启用新的页面风格' (#10) from codex/design into master
Reviewed-on: #10 -- [CST 2026-07-19 Sunday 16:12:13]
@@ -0,0 +1 @@
|
||||
[{"title":"网站迁移日志","href":"/posts/8f3b9d21/"},{"title":"用 Umami 分享链接给网站信息卡提供统计数据","href":"/posts/4c91a8f2/"},{"title":"把普通的一天过成自己的节奏","href":"/posts/efe1e805/"},{"title":"GPT Image2 初试","href":"/posts/3f8de22/"},{"title":"电子价签玩法","href":"/posts/8712baaa/"},{"title":"OpenGL- Cohen-Sutherland 编码裁剪算法","href":"/posts/9b2ef00a/"},{"title":"OpenGL-直线的扫描转换","href":"/posts/7207243b/"},{"title":"OpenGL-基础程序","href":"/posts/437a5198/"},{"title":"配置OpenGL环境","href":"/posts/c4477b0c/"},{"title":"opencv应用-算术运算","href":"/posts/b559997d/"},{"title":"opencv应用-基础操作","href":"/posts/e8f95ead/"},{"title":"配置opencv","href":"/posts/6e3332b/"},{"title":"自建bitwarden服务","href":"/posts/7baa41fc/"},{"title":"自建renovate-bot","href":"/posts/56f57c0b/"},{"title":"新年快乐!","href":"/posts/5785bd01/"},{"title":"添加网站左上角菜单","href":"/posts/7e921903/"},{"title":"将博客仓库转移到gitea","href":"/posts/d2c8521/"},{"title":"安装gitea","href":"/posts/34725d47/"},{"title":"添加typesense搜索","href":"/posts/f287c563/"},{"title":"自定义字体","href":"/posts/b5601a7e/"},{"title":"在侧边栏添加日历和倒计时","href":"/posts/5ed2f1e6/"},{"title":"和朋友们的故事","href":"/posts/a31d95d9/"},{"title":"Adguard和Openclash共存","href":"/posts/66e66374/"},{"title":"Adguard规则分享","href":"/posts/69b16001/"},{"title":"在Openwrt上安装AdguardHome","href":"/posts/b57500e9/"},{"title":"在Openwrt中安装OpenClash","href":"/posts/2b2fb1a7/"},{"title":"Cudy TR3000刷入Openwrt系统","href":"/posts/8802e397/"},{"title":"搭建Owncloud并集成Onlyoffice","href":"/posts/59c8572a/"},{"title":"为博客添加CMS系统","href":"/posts/3a26a97a/"},{"title":"为1Panel添加自己想要的应用","href":"/posts/bb18d851/"},{"title":"使用GitHub推送Hexo到服务器","href":"/posts/ce1ec3fe/"},{"title":"C# 基本语法","href":"/posts/e9a8e898/"},{"title":"C# 入门","href":"/posts/bc56f789/"},{"title":"自定义页脚(新)","href":"/posts/c6143ad3/"},{"title":"自定义分类条","href":"/posts/33249733/"},{"title":"自定义导航栏","href":"/posts/3e61a389/"},{"title":"利用 SiteMap 随机访问站内页面","href":"/posts/b5866b9e/"},{"title":"博客添加AI总结","href":"/posts/41b9aff7/"},{"title":"利用插件自定义页脚菜单","href":"/posts/a6ab0925/"},{"title":"英语文章分享-去奋力生活吧,就好像死亡近在咫尺","href":"/posts/f0a1a1f4/"},{"title":"自定义右键菜单","href":"/posts/8bdb35fb/"},{"title":"在侧边栏中添加欢迎信息","href":"/posts/da17d00/"},{"title":"配置说说页面","href":"/posts/ad244066/"},{"title":"部署历程","href":"/posts/6e5f5039/"}]
|
||||
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 457 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 7.5 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 269 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 64 KiB |
@@ -0,0 +1,45 @@
|
||||
(() => {
|
||||
const redirectPath = '/go/';
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
|
||||
if (window.location.pathname === redirectPath || window.location.pathname === '/go') return;
|
||||
if (urlParams.get('noExternalRedirect') === '1') return;
|
||||
|
||||
function getExternalUrl(anchor) {
|
||||
const href = anchor.getAttribute('href');
|
||||
if (!href || href.startsWith('#')) return null;
|
||||
|
||||
let targetUrl;
|
||||
try {
|
||||
targetUrl = new URL(href, window.location.href);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!['http:', 'https:'].includes(targetUrl.protocol)) return null;
|
||||
if (targetUrl.origin === window.location.origin) return null;
|
||||
|
||||
return targetUrl.href;
|
||||
}
|
||||
|
||||
document.addEventListener('click', (event) => {
|
||||
const anchor = event.target.closest?.('a[href]');
|
||||
if (!anchor) return;
|
||||
if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
|
||||
|
||||
const externalUrl = getExternalUrl(anchor);
|
||||
if (!externalUrl) return;
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
const redirectUrl = `${redirectPath}?url=${encodeURIComponent(externalUrl)}&from=${encodeURIComponent(window.location.href)}`;
|
||||
const target = anchor.getAttribute('target');
|
||||
|
||||
if (target && target !== '_self') {
|
||||
window.open(redirectUrl, target, 'noopener,noreferrer');
|
||||
return;
|
||||
}
|
||||
|
||||
window.location.href = redirectUrl;
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,104 @@
|
||||
(() => {
|
||||
if (!('serviceWorker' in navigator)) return;
|
||||
|
||||
const toast = document.querySelector('[data-pwa-toast]');
|
||||
const title = toast?.querySelector('[data-pwa-title]');
|
||||
const message = toast?.querySelector('[data-pwa-message]');
|
||||
const primary = toast?.querySelector('[data-pwa-primary]');
|
||||
const close = toast?.querySelector('[data-pwa-close]');
|
||||
let installPrompt = null;
|
||||
let waitingWorker = null;
|
||||
let toastMode = '';
|
||||
let refreshing = false;
|
||||
|
||||
const showToast = (mode, heading, detail, actionLabel) => {
|
||||
if (!toast || !title || !message || !primary) return;
|
||||
toastMode = mode;
|
||||
title.textContent = heading;
|
||||
message.textContent = detail;
|
||||
primary.textContent = actionLabel;
|
||||
primary.hidden = !actionLabel;
|
||||
toast.hidden = false;
|
||||
};
|
||||
|
||||
const hideToast = () => {
|
||||
if (toast) toast.hidden = true;
|
||||
};
|
||||
|
||||
const installDismissedRecently = () => {
|
||||
try {
|
||||
const dismissedAt = Number(localStorage.getItem('pwa-install-dismissed-at'));
|
||||
return dismissedAt > 0 && Date.now() - dismissedAt < 30 * 86400000;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('beforeinstallprompt', (event) => {
|
||||
event.preventDefault();
|
||||
installPrompt = event;
|
||||
if (!installDismissedRecently()) {
|
||||
window.setTimeout(() => {
|
||||
if (installPrompt) showToast('install', '安装 Bi\'s Blog', '添加到桌面,像应用一样打开并阅读缓存过的文章。', '立即安装');
|
||||
}, 2500);
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener('appinstalled', () => {
|
||||
installPrompt = null;
|
||||
showToast('ready', '安装完成', '博客已经添加到你的设备。', '知道了');
|
||||
});
|
||||
|
||||
primary?.addEventListener('click', async () => {
|
||||
if (toastMode === 'install' && installPrompt) {
|
||||
await installPrompt.prompt();
|
||||
await installPrompt.userChoice;
|
||||
installPrompt = null;
|
||||
hideToast();
|
||||
return;
|
||||
}
|
||||
|
||||
if (toastMode === 'update' && waitingWorker) {
|
||||
refreshing = true;
|
||||
waitingWorker.postMessage({ type: 'SKIP_WAITING' });
|
||||
return;
|
||||
}
|
||||
|
||||
hideToast();
|
||||
});
|
||||
|
||||
close?.addEventListener('click', () => {
|
||||
if (toastMode === 'install') {
|
||||
try { localStorage.setItem('pwa-install-dismissed-at', String(Date.now())); } catch {}
|
||||
}
|
||||
hideToast();
|
||||
});
|
||||
|
||||
navigator.serviceWorker.addEventListener('controllerchange', () => {
|
||||
if (refreshing) window.location.reload();
|
||||
});
|
||||
|
||||
navigator.serviceWorker.register('/sw.js', { scope: '/' }).then((registration) => {
|
||||
if (registration.waiting && navigator.serviceWorker.controller) {
|
||||
waitingWorker = registration.waiting;
|
||||
showToast('update', '发现新版本', '刷新后即可使用博客的最新内容和功能。', '立即更新');
|
||||
}
|
||||
|
||||
registration.addEventListener('updatefound', () => {
|
||||
const worker = registration.installing;
|
||||
worker?.addEventListener('statechange', () => {
|
||||
if (worker.state === 'installed' && navigator.serviceWorker.controller) {
|
||||
waitingWorker = worker;
|
||||
showToast('update', '发现新版本', '刷新后即可使用博客的最新内容和功能。', '立即更新');
|
||||
}
|
||||
});
|
||||
});
|
||||
}).catch((error) => {
|
||||
console.warn('PWA registration failed:', error);
|
||||
});
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (params.get('action') === 'search') {
|
||||
window.setTimeout(() => document.querySelector('.search-typesense-trigger')?.click(), 350);
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,93 @@
|
||||
function randomPost(trigger) {
|
||||
const source = trigger instanceof Element
|
||||
? trigger
|
||||
: document.activeElement?.closest?.('.nav-random, [onclick*="randomPost"]');
|
||||
const overlay = document.createElement('div');
|
||||
const reduceMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
overlay.className = 'random-post-transition';
|
||||
overlay.setAttribute('role', 'status');
|
||||
overlay.setAttribute('aria-live', 'polite');
|
||||
overlay.innerHTML = `
|
||||
<div class="random-post-flight" aria-hidden="true"></div>
|
||||
<div class="random-post-transition-card">
|
||||
<i class="fa-solid fa-shuffle" aria-hidden="true"></i>
|
||||
<span>\u6b63\u5728\u62bd\u53d6\u4e00\u7bc7\u6587\u7ae0</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
source?.classList.add('is-randomizing');
|
||||
source?.setAttribute('aria-busy', 'true');
|
||||
document.body.appendChild(overlay);
|
||||
requestAnimationFrame(() => overlay.classList.add('is-visible'));
|
||||
|
||||
const finish = href => {
|
||||
const delay = reduceMotion ? 80 : 1250;
|
||||
window.setTimeout(() => {
|
||||
location.href = href;
|
||||
}, delay);
|
||||
};
|
||||
|
||||
const startCardFlight = posts => {
|
||||
if (reduceMotion) return;
|
||||
|
||||
const flight = overlay.querySelector('.random-post-flight');
|
||||
if (!flight) return;
|
||||
|
||||
const candidates = posts
|
||||
.filter(post => post && typeof post.href === 'string' && post.href.startsWith('/posts/'))
|
||||
.map(post => ({
|
||||
title: typeof post.title === 'string' && post.title.trim() ? post.title.trim() : '\u672a\u547d\u540d\u6587\u7ae0',
|
||||
meta: typeof post.category === 'string' && post.category.trim() ? post.category.trim() : '\u968f\u673a\u6587\u7ae0',
|
||||
}));
|
||||
|
||||
const pool = candidates.length ? candidates : [{ title: '\u968f\u673a\u6587\u7ae0', meta: '\u6b63\u5728\u62bd\u53d6' }];
|
||||
const count = Math.min(9, Math.max(5, pool.length));
|
||||
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const post = pool[Math.floor(Math.random() * pool.length)];
|
||||
const card = document.createElement('div');
|
||||
card.className = 'random-post-flying-card';
|
||||
card.style.setProperty('--flight-delay', `${index * 0.11}s`);
|
||||
card.style.setProperty('--flight-y', `${-42 + Math.random() * 84}px`);
|
||||
card.style.setProperty('--flight-rotate', `${-8 + Math.random() * 16}deg`);
|
||||
const meta = document.createElement('small');
|
||||
const title = document.createElement('strong');
|
||||
meta.textContent = post.meta;
|
||||
title.textContent = post.title;
|
||||
card.append(meta, title);
|
||||
flight.appendChild(card);
|
||||
}
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
source?.classList.remove('is-randomizing');
|
||||
source?.removeAttribute('aria-busy');
|
||||
overlay.classList.remove('is-visible');
|
||||
window.setTimeout(() => overlay.remove(), reduceMotion ? 0 : 180);
|
||||
};
|
||||
|
||||
fetch('/api/posts.json')
|
||||
.then(res => {
|
||||
if (!res.ok) throw new Error(`Failed to load posts: ${res.status}`);
|
||||
return res.json();
|
||||
})
|
||||
.then(posts => {
|
||||
const postLinks = posts
|
||||
.map(post => post && post.href)
|
||||
.filter(href => typeof href === 'string' && href.startsWith('/posts/'));
|
||||
|
||||
if (postLinks.length === 0) {
|
||||
console.warn('No posts available for random navigation.');
|
||||
reset();
|
||||
return;
|
||||
}
|
||||
|
||||
startCardFlight(posts);
|
||||
finish(postLinks[Math.floor(Math.random() * postLinks.length)]);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Random post navigation failed:', error);
|
||||
reset();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,528 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
// ============================================================================
|
||||
// 配置区域 - 请根据实际情况修改
|
||||
// ============================================================================
|
||||
const CONFIG = window.SITE_CONFIG?.search;
|
||||
if (!CONFIG) {
|
||||
console.warn('Typesense 搜索配置未加载');
|
||||
return;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 状态管理
|
||||
// ============================================================================
|
||||
let searchInstance = null;
|
||||
let isInitialized = false;
|
||||
let isSearchOpen = false;
|
||||
let initRetryCount = 0;
|
||||
const MAX_INIT_RETRIES = CONFIG.ui.maxInitRetries || 30; // 最多重试30次 (3秒)
|
||||
|
||||
// ============================================================================
|
||||
// 错误提示函数
|
||||
// ============================================================================
|
||||
function showErrorMessage() {
|
||||
const hitsContainer = document.getElementById('hits');
|
||||
if (!hitsContainer) return;
|
||||
|
||||
hitsContainer.innerHTML =
|
||||
'<div class="ts-empty">' +
|
||||
'<div style="color: #f44336;"><i class="fas fa-exclamation-triangle" style="font-size: 3rem;"></i></div>' +
|
||||
'<div style="font-size: 1.1rem; font-weight: bold; margin: 15px 0;">搜索服务加载失败</div>' +
|
||||
'<div style="font-size: 0.9rem; color: #666; line-height: 1.8;">' +
|
||||
'<p>依赖库未能正确加载,请检查以下配置:</p>' +
|
||||
'<ol style="text-align: left; max-width: 500px; margin: 15px auto;">' +
|
||||
'<li>确认已在 <code>_config.butterfly.yml</code> 中正确引入依赖</li>' +
|
||||
'<li>检查 JS 文件加载顺序(先 instantsearch.js,再 adapter)</li>' +
|
||||
'<li>尝试更换 CDN 或使用本地文件</li>' +
|
||||
'<li>打开浏览器控制台查看详细错误信息</li>' +
|
||||
'</ol>' +
|
||||
'</div>' +
|
||||
'<div style="margin-top: 20px;">' +
|
||||
'<button onclick="location.reload()" style="padding: 10px 20px; background: #49b1f5; color: white; border: none; border-radius: 5px; cursor: pointer;">重新加载页面</button>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 1. 动态插入 HTML 结构
|
||||
// ============================================================================
|
||||
const searchHTML = `
|
||||
<div id="typesense-search-mask" class="ts-mask" style="display:none;">
|
||||
<div id="typesense-search-container" class="ts-container">
|
||||
<div class="ts-header">
|
||||
<span class="ts-title">
|
||||
<i class="fas fa-search"></i> 本站搜索
|
||||
</span>
|
||||
<span id="close-typesense" class="ts-close" aria-label="关闭搜索">×</span>
|
||||
</div>
|
||||
<div id="searchbox"></div>
|
||||
<div id="stats" class="ts-stats"></div>
|
||||
<div id="hits" class="ts-hits"></div>
|
||||
<div id="pagination" class="ts-pagination"></div>
|
||||
<div class="ts-footer">
|
||||
<small>Search powered by <strong>Typesense</strong></small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.ts-mask {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
z-index: 10000;
|
||||
backdrop-filter: blur(5px);
|
||||
-webkit-backdrop-filter: blur(5px);
|
||||
opacity: 0;
|
||||
transition: opacity 300ms ease;
|
||||
}
|
||||
.ts-mask.active { opacity: 1; }
|
||||
.ts-container {
|
||||
margin: 5% auto;
|
||||
width: 90%;
|
||||
max-width: 650px;
|
||||
background: var(--search-bg, var(--card-bg, #fff));
|
||||
padding: 25px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 15px 35px rgba(0, 0, 0, 0.3);
|
||||
position: relative;
|
||||
z-index: 10001;
|
||||
transform: translateY(-50px);
|
||||
opacity: 0;
|
||||
transition: all 300ms ease;
|
||||
}
|
||||
.ts-mask.active .ts-container {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
.ts-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
border-bottom: 2px solid var(--text-highlight-color, #49b1f5);
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
.ts-title {
|
||||
font-size: 1.2rem;
|
||||
font-weight: bold;
|
||||
color: var(--text-highlight-color, #49b1f5);
|
||||
}
|
||||
.ts-close {
|
||||
cursor: pointer;
|
||||
font-size: 28px;
|
||||
color: var(--font-color, #333);
|
||||
line-height: 1;
|
||||
transition: color 0.2s, transform 0.2s;
|
||||
}
|
||||
.ts-close:hover {
|
||||
color: var(--text-highlight-color, #49b1f5);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
.ais-SearchBox-input {
|
||||
position: relative;
|
||||
z-index: 10002;
|
||||
cursor: text;
|
||||
padding: 12px 40px 12px 15px !important;
|
||||
border-radius: 8px !important;
|
||||
border: 2px solid #eee !important;
|
||||
width: 100%;
|
||||
outline: none;
|
||||
transition: border-color 0.3s, box-shadow 0.3s;
|
||||
background: var(--card-bg, #fff);
|
||||
color: var(--font-color, #333);
|
||||
font-size: 1rem;
|
||||
}
|
||||
.ais-SearchBox-input:focus {
|
||||
border-color: var(--text-highlight-color, #49b1f5) !important;
|
||||
box-shadow: 0 0 0 3px rgba(73, 177, 245, 0.1);
|
||||
}
|
||||
.ts-stats {
|
||||
margin: 10px 0;
|
||||
font-size: 0.85rem;
|
||||
color: var(--font-color, #666);
|
||||
opacity: 0.8;
|
||||
}
|
||||
.ts-hits {
|
||||
max-height: 55vh;
|
||||
overflow-y: auto;
|
||||
margin-top: 15px;
|
||||
padding-right: 5px;
|
||||
}
|
||||
.ts-hits::-webkit-scrollbar { width: 6px; }
|
||||
.ts-hits::-webkit-scrollbar-track {
|
||||
background: var(--card-bg, #f1f1f1);
|
||||
border-radius: 10px;
|
||||
}
|
||||
.ts-hits::-webkit-scrollbar-thumb {
|
||||
background: var(--text-highlight-color, #49b1f5);
|
||||
border-radius: 10px;
|
||||
}
|
||||
.ts-empty {
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
color: var(--font-color, #999);
|
||||
}
|
||||
.ts-empty i {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 15px;
|
||||
opacity: 0.3;
|
||||
}
|
||||
.ts-empty code {
|
||||
background: #f5f5f5;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-family: monospace;
|
||||
color: #e91e63;
|
||||
}
|
||||
.ts-empty ol {
|
||||
padding-left: 20px;
|
||||
}
|
||||
.ts-empty li {
|
||||
margin: 8px 0;
|
||||
}
|
||||
.ts-result-item {
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s ease;
|
||||
margin-bottom: 10px;
|
||||
padding: 15px;
|
||||
border: 1px solid transparent;
|
||||
text-decoration: none;
|
||||
display: block;
|
||||
background: var(--card-bg, #fff);
|
||||
}
|
||||
.ts-result-item:hover {
|
||||
background: var(--text-bg-hover, rgba(73, 177, 245, 0.05));
|
||||
border-color: var(--text-highlight-color, #49b1f5);
|
||||
transform: translateX(5px);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.ts-result-title {
|
||||
font-weight: bold;
|
||||
color: var(--text-highlight-color, #49b1f5);
|
||||
font-size: 1.1rem;
|
||||
margin-bottom: 8px;
|
||||
display: block;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.ts-result-content {
|
||||
font-size: 0.9rem;
|
||||
color: var(--font-color, #666);
|
||||
line-height: 1.6;
|
||||
opacity: 0.85;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.ts-result-item mark {
|
||||
background: #ffeb3b;
|
||||
color: #000;
|
||||
padding: 2px 4px;
|
||||
border-radius: 3px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.ts-pagination {
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
}
|
||||
.ais-Pagination-list {
|
||||
display: flex;
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
gap: 5px;
|
||||
}
|
||||
.ais-Pagination-link {
|
||||
display: block;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 5px;
|
||||
color: var(--font-color, #333);
|
||||
text-decoration: none;
|
||||
transition: all 0.2s;
|
||||
background: var(--card-bg, #fff);
|
||||
}
|
||||
.ais-Pagination-link:hover {
|
||||
background: var(--text-highlight-color, #49b1f5);
|
||||
color: #fff;
|
||||
}
|
||||
.ais-Pagination-item--selected .ais-Pagination-link {
|
||||
background: var(--text-highlight-color, #49b1f5);
|
||||
color: #fff;
|
||||
}
|
||||
.ts-footer {
|
||||
text-align: right;
|
||||
margin-top: 15px;
|
||||
border-top: 1px solid var(--border-color, #eee);
|
||||
padding-top: 10px;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.ts-container {
|
||||
margin: 10px;
|
||||
width: calc(100% - 20px);
|
||||
padding: 20px 15px;
|
||||
}
|
||||
.ts-hits { max-height: 50vh; }
|
||||
}
|
||||
[data-theme="dark"] .ts-mask,
|
||||
.dark-mode .ts-mask {
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
}
|
||||
</style>
|
||||
`;
|
||||
|
||||
document.body.insertAdjacentHTML('beforeend', searchHTML);
|
||||
|
||||
const mask = document.getElementById('typesense-search-mask');
|
||||
const closeBtn = document.getElementById('close-typesense');
|
||||
const container = document.getElementById('typesense-search-container');
|
||||
|
||||
// ============================================================================
|
||||
// 搜索控制
|
||||
// ============================================================================
|
||||
function openSearch() {
|
||||
if (isSearchOpen) return;
|
||||
isSearchOpen = true;
|
||||
mask.style.display = 'block';
|
||||
void mask.offsetWidth;
|
||||
mask.classList.add('active');
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
if (!isInitialized) {
|
||||
initTypesense();
|
||||
}
|
||||
focusSearchInput();
|
||||
}
|
||||
|
||||
function closeSearch() {
|
||||
if (!isSearchOpen) return;
|
||||
isSearchOpen = false;
|
||||
mask.classList.remove('active');
|
||||
setTimeout(function() {
|
||||
mask.style.display = 'none';
|
||||
document.body.style.overflow = '';
|
||||
}, CONFIG.ui.animationDuration || 300);
|
||||
}
|
||||
|
||||
function focusSearchInput(retryCount) {
|
||||
retryCount = retryCount || 0;
|
||||
const input = document.querySelector('.ais-SearchBox-input');
|
||||
if (input) {
|
||||
input.focus();
|
||||
input.select();
|
||||
} else if (retryCount < (CONFIG.ui.maxRetries || 10)) {
|
||||
setTimeout(function() {
|
||||
focusSearchInput(retryCount + 1);
|
||||
}, CONFIG.ui.retryDelay || 100);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 事件监听
|
||||
// ============================================================================
|
||||
document.addEventListener('click', function(e) {
|
||||
if (e.target.closest('.search-typesense-trigger')) {
|
||||
e.preventDefault();
|
||||
openSearch();
|
||||
}
|
||||
});
|
||||
|
||||
closeBtn.addEventListener('click', closeSearch);
|
||||
mask.addEventListener('click', function(e) {
|
||||
if (e.target === mask) closeSearch();
|
||||
});
|
||||
container.addEventListener('click', function(e) {
|
||||
e.stopPropagation();
|
||||
});
|
||||
window.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape' && isSearchOpen) {
|
||||
closeSearch();
|
||||
}
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
|
||||
e.preventDefault();
|
||||
openSearch();
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Typesense 初始化(带重试限制)
|
||||
// ============================================================================
|
||||
function initTypesense() {
|
||||
if (isInitialized || searchInstance) {
|
||||
console.warn('Typesense 搜索已初始化');
|
||||
return;
|
||||
}
|
||||
|
||||
var instantsearchLoaded = typeof instantsearch !== 'undefined';
|
||||
var adapterLoaded = typeof TypesenseInstantSearchAdapter !== 'undefined' ||
|
||||
typeof window.TypesenseInstantSearchAdapter !== 'undefined';
|
||||
|
||||
console.log('📦 依赖库检查 (' + (initRetryCount + 1) + '/' + MAX_INIT_RETRIES + '):');
|
||||
console.log(' instantsearch.js:', instantsearchLoaded ? '✅ 已加载' : '❌ 未加载');
|
||||
console.log(' TypesenseAdapter:', adapterLoaded ? '✅ 已加载' : '❌ 未加载');
|
||||
|
||||
if (!instantsearchLoaded || !adapterLoaded) {
|
||||
initRetryCount++;
|
||||
|
||||
if (initRetryCount >= MAX_INIT_RETRIES) {
|
||||
console.error('');
|
||||
console.error('❌ Typesense 依赖库加载失败!');
|
||||
console.error('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||
console.error('');
|
||||
console.error('🔧 请检查 _config.butterfly.yml 配置:');
|
||||
console.error('');
|
||||
console.error('inject:');
|
||||
console.error(' bottom: # ⚠️ 使用 bottom 而不是 head');
|
||||
console.error(' - <script src="https://cdn.jsdelivr.net/npm/instantsearch.js@4.56.0"></script>');
|
||||
console.error(' - <script src="https://cdn.jsdelivr.net/npm/typesense-instantsearch-adapter@2.7.0/dist/typesense-instantsearch-adapter.min.js"></script>');
|
||||
console.error(' - <script src="/js/typesense-search-fixed.js"></script>');
|
||||
console.error('');
|
||||
console.error('💡 或在控制台手动检查:');
|
||||
console.error(' typeof instantsearch');
|
||||
console.error(' typeof TypesenseInstantSearchAdapter');
|
||||
console.error('');
|
||||
console.error('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||
|
||||
showErrorMessage();
|
||||
return;
|
||||
}
|
||||
|
||||
console.warn('⏳ 100ms 后重试...');
|
||||
setTimeout(initTypesense, 100);
|
||||
return;
|
||||
}
|
||||
|
||||
initRetryCount = 0;
|
||||
console.log('🚀 开始初始化 Typesense...');
|
||||
|
||||
try {
|
||||
const typesenseAdapter = new TypesenseInstantSearchAdapter({
|
||||
server: {
|
||||
apiKey: CONFIG.apiKey,
|
||||
nodes: [{
|
||||
host: CONFIG.server.host,
|
||||
port: CONFIG.server.port,
|
||||
protocol: CONFIG.server.protocol
|
||||
}],
|
||||
cacheSearchResultsForSeconds: 120
|
||||
},
|
||||
additionalSearchParameters: CONFIG.searchParams
|
||||
});
|
||||
|
||||
searchInstance = instantsearch({
|
||||
searchClient: typesenseAdapter.searchClient,
|
||||
indexName: CONFIG.indexName,
|
||||
routing: false
|
||||
});
|
||||
|
||||
searchInstance.addWidgets([
|
||||
instantsearch.widgets.searchBox({
|
||||
container: '#searchbox',
|
||||
placeholder: CONFIG.ui.placeholder || '输入关键词寻找故事...',
|
||||
autofocus: true,
|
||||
showReset: true,
|
||||
showSubmit: false,
|
||||
showLoadingIndicator: true
|
||||
}),
|
||||
instantsearch.widgets.stats({
|
||||
container: '#stats',
|
||||
templates: {
|
||||
text: function(data) {
|
||||
if (!data.query) return '';
|
||||
return '找到 <strong>' + data.nbHits + '</strong> 条结果 (' + data.processingTimeMS + 'ms)';
|
||||
}
|
||||
}
|
||||
}),
|
||||
instantsearch.widgets.hits({
|
||||
container: '#hits',
|
||||
templates: {
|
||||
empty: function(results) {
|
||||
return '<div class="ts-empty">' +
|
||||
'<div><i class="fas fa-search"></i></div>' +
|
||||
'<div>找不到与 "<strong>' + results.query + '</strong>" 相关的内容</div>' +
|
||||
'<div style="margin-top: 10px;">试试其他关键词吧 (´·ω·`)</div>' +
|
||||
'</div>';
|
||||
},
|
||||
item: function(hit) {
|
||||
// 使用 _highlightResult 获取高亮文本
|
||||
var titleHighlight = hit._highlightResult && hit._highlightResult.title
|
||||
? hit._highlightResult.title.value
|
||||
: (hit.title || '');
|
||||
|
||||
var contentHighlight = hit._highlightResult && hit._highlightResult.content
|
||||
? hit._highlightResult.content.value
|
||||
: (hit.content || '');
|
||||
|
||||
// 截取内容长度
|
||||
if (contentHighlight.length > 200) {
|
||||
contentHighlight = contentHighlight.substring(0, 200) + '...';
|
||||
}
|
||||
|
||||
return '<a href="' + hit.url + '" class="ts-result-item">' +
|
||||
'<div class="ts-result-title">' + titleHighlight + '</div>' +
|
||||
'<div class="ts-result-content">' + contentHighlight + '</div>' +
|
||||
'</a>';
|
||||
}
|
||||
}
|
||||
}),
|
||||
instantsearch.widgets.pagination({
|
||||
container: '#pagination',
|
||||
padding: 2,
|
||||
showFirst: false,
|
||||
showLast: false
|
||||
})
|
||||
]);
|
||||
|
||||
searchInstance.start();
|
||||
isInitialized = true;
|
||||
|
||||
console.log('✅ Typesense 初始化成功!');
|
||||
|
||||
searchInstance.on('render', function() {
|
||||
if (isSearchOpen) {
|
||||
const input = document.querySelector('.ais-SearchBox-input');
|
||||
if (input && document.activeElement !== input) {
|
||||
input.focus();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 初始化失败:', error);
|
||||
showErrorMessage();
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 初始化
|
||||
// ============================================================================
|
||||
function init() {
|
||||
console.log('🔍 Typesense 搜索已准备就绪');
|
||||
console.log('💡 快捷键: Ctrl/Cmd + K');
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 全局接口
|
||||
// ============================================================================
|
||||
window.TypesenseSearch = {
|
||||
open: openSearch,
|
||||
close: closeSearch,
|
||||
isOpen: function() { return isSearchOpen; },
|
||||
getInstance: function() { return searchInstance; }
|
||||
};
|
||||
|
||||
})();
|
||||
@@ -0,0 +1,363 @@
|
||||
function renderTalks() {
|
||||
const talkContainer = document.querySelector('#talk');
|
||||
if (!talkContainer) return;
|
||||
const config = window.SITE_CONFIG?.talks || {};
|
||||
talkContainer.innerHTML = '';
|
||||
const generateIconSVG = () => {
|
||||
return `<svg viewBox="0 0 512 512"xmlns="http://www.w3.org/2000/svg"class="is-badge icon"><path d="m512 268c0 17.9-4.3 34.5-12.9 49.7s-20.1 27.1-34.6 35.4c.4 2.7.6 6.9.6 12.6 0 27.1-9.1 50.1-27.1 69.1-18.1 19.1-39.9 28.6-65.4 28.6-11.4 0-22.3-2.1-32.6-6.3-8 16.4-19.5 29.6-34.6 39.7-15 10.2-31.5 15.2-49.4 15.2-18.3 0-34.9-4.9-49.7-14.9-14.9-9.9-26.3-23.2-34.3-40-10.3 4.2-21.1 6.3-32.6 6.3-25.5 0-47.4-9.5-65.7-28.6-18.3-19-27.4-42.1-27.4-69.1 0-3 .4-7.2 1.1-12.6-14.5-8.4-26-20.2-34.6-35.4-8.5-15.2-12.8-31.8-12.8-49.7 0-19 4.8-36.5 14.3-52.3s22.3-27.5 38.3-35.1c-4.2-11.4-6.3-22.9-6.3-34.3 0-27 9.1-50.1 27.4-69.1s40.2-28.6 65.7-28.6c11.4 0 22.3 2.1 32.6 6.3 8-16.4 19.5-29.6 34.6-39.7 15-10.1 31.5-15.2 49.4-15.2s34.4 5.1 49.4 15.1c15 10.1 26.6 23.3 34.6 39.7 10.3-4.2 21.1-6.3 32.6-6.3 25.5 0 47.3 9.5 65.4 28.6s27.1 42.1 27.1 69.1c0 12.6-1.9 24-5.7 34.3 16 7.6 28.8 19.3 38.3 35.1 9.5 15.9 14.3 33.4 14.3 52.4zm-266.9 77.1 105.7-158.3c2.7-4.2 3.5-8.8 2.6-13.7-1-4.9-3.5-8.8-7.7-11.4-4.2-2.7-8.8-3.6-13.7-2.9-5 .8-9 3.2-12 7.4l-93.1 140-42.9-42.8c-3.8-3.8-8.2-5.6-13.1-5.4-5 .2-9.3 2-13.1 5.4-3.4 3.4-5.1 7.7-5.1 12.9 0 5.1 1.7 9.4 5.1 12.9l58.9 58.9 2.9 2.3c3.4 2.3 6.9 3.4 10.3 3.4 6.7-.1 11.8-2.9 15.2-8.7z"fill="#1da1f2"></path></svg>`;
|
||||
}
|
||||
const waterfall = (a) => {
|
||||
function b(a, b) {
|
||||
var c = window.getComputedStyle(b);
|
||||
return parseFloat(c["margin" + a]) || 0
|
||||
}
|
||||
|
||||
function c(a) {
|
||||
return a + "px"
|
||||
}
|
||||
|
||||
function d(a) {
|
||||
return parseFloat(a.style.top)
|
||||
}
|
||||
|
||||
function e(a) {
|
||||
return parseFloat(a.style.left)
|
||||
}
|
||||
|
||||
function f(a) {
|
||||
return a.clientWidth
|
||||
}
|
||||
|
||||
function g(a) {
|
||||
return a.clientHeight
|
||||
}
|
||||
|
||||
function h(a) {
|
||||
return d(a) + g(a) + b("Bottom", a)
|
||||
}
|
||||
|
||||
function i(a) {
|
||||
return e(a) + f(a) + b("Right", a)
|
||||
}
|
||||
|
||||
function j(a) {
|
||||
a = a.sort(function (a, b) {
|
||||
return h(a) === h(b) ? e(b) - e(a) : h(b) - h(a)
|
||||
})
|
||||
}
|
||||
|
||||
function k(b) {
|
||||
f(a) != t && (b.target.removeEventListener(b.type, arguments.callee), waterfall(a))
|
||||
}
|
||||
"string" == typeof a && (a = document.querySelector(a));
|
||||
var l = [].map.call(a.children, function (a) {
|
||||
return a.style.position = "absolute", a
|
||||
});
|
||||
a.style.position = "relative";
|
||||
var m = [];
|
||||
l.length && (l[0].style.top = "0px", l[0].style.left = c(b("Left", l[0])), m.push(l[0]));
|
||||
for (var n = 1; n < l.length; n++) {
|
||||
var o = l[n - 1],
|
||||
p = l[n],
|
||||
q = i(o) + f(p) <= f(a);
|
||||
if (!q) break;
|
||||
p.style.top = o.style.top, p.style.left = c(i(o) + b("Left", p)), m.push(p)
|
||||
}
|
||||
for (; n < l.length; n++) {
|
||||
j(m);
|
||||
var p = l[n],
|
||||
r = m.pop();
|
||||
p.style.top = c(h(r) + b("Top", p)), p.style.left = c(e(r)), m.push(p)
|
||||
}
|
||||
j(m);
|
||||
var s = m[0];
|
||||
a.style.height = c(h(s) + b("Bottom", s));
|
||||
var t = f(a);
|
||||
window.addEventListener ? window.addEventListener("resize", k) : document.body.onresize = k
|
||||
};
|
||||
|
||||
const fetchAndRenderTalks = () => {
|
||||
const url = config.apiUrl;
|
||||
if (!url) return;
|
||||
const cacheKey = config.cacheKey || 'talksCache';
|
||||
const cacheTimeKey = config.cacheTimeKey || 'talksCacheTime';
|
||||
const cacheDuration = config.cacheDurationMs || 30 * 60 * 1000;
|
||||
const cachedData = localStorage.getItem(cacheKey);
|
||||
const cachedTime = localStorage.getItem(cacheTimeKey);
|
||||
const now = Date.now();
|
||||
|
||||
if (cachedData && cachedTime && (now - cachedTime < cacheDuration)) {
|
||||
renderTalksList(JSON.parse(cachedData));
|
||||
} else {
|
||||
fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ page: 1, pageSize: config.pageSize || 30 })
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (data.code === 1 && data.data && Array.isArray(data.data.items)) {
|
||||
localStorage.setItem(cacheKey, JSON.stringify(data.data.items));
|
||||
localStorage.setItem(cacheTimeKey, now.toString());
|
||||
renderTalksList(data.data.items);
|
||||
}
|
||||
})
|
||||
.catch(err => console.error('Error fetching:', err));
|
||||
}
|
||||
};
|
||||
|
||||
const renderTalksList = (list) => {
|
||||
list.map(formatTalk).forEach(item => talkContainer.appendChild(generateTalkElement(item)));
|
||||
waterfall('#talk');
|
||||
};
|
||||
|
||||
const withImageParams = (url) => {
|
||||
if (!url) return '';
|
||||
try {
|
||||
const parsedUrl = new URL(url, window.location.origin);
|
||||
if (!parsedUrl.searchParams.has('fmt')) parsedUrl.searchParams.set('fmt', 'webp');
|
||||
if (!parsedUrl.searchParams.has('q')) parsedUrl.searchParams.set('q', '75');
|
||||
return parsedUrl.toString();
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
};
|
||||
|
||||
const getImageUrl = (image) => {
|
||||
if (!image) return '';
|
||||
if (typeof image === 'string') return image;
|
||||
return image.image_url || image.url || image.src || image.file?.url || '';
|
||||
};
|
||||
|
||||
const getTalkImages = (item) => {
|
||||
if (Array.isArray(item.images) && item.images.length > 0) {
|
||||
return item.images.map(getImageUrl).filter(Boolean);
|
||||
}
|
||||
|
||||
if (Array.isArray(item.echo_files) && item.echo_files.length > 0) {
|
||||
return item.echo_files
|
||||
.map((echoFile) => getImageUrl(echoFile.file || echoFile))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
return [];
|
||||
};
|
||||
|
||||
const formatTalk = (item) => {
|
||||
const date = formatTime(item.created_at);
|
||||
let content = item.content || '';
|
||||
content = content.replace(/\[(.*?)\]\((.*?)\)/g, `<a href="$2" target="_blank" rel="nofollow noopener">@$1</a>`)
|
||||
.replace(/- \[ \]/g, '⚪')
|
||||
.replace(/- \[x\]/g, '⚫')
|
||||
.replace(/\n/g, '<br>');
|
||||
content = `<div class="talk_content_text">${content}</div>`;
|
||||
|
||||
// 图片
|
||||
const images = getTalkImages(item);
|
||||
if (images.length > 0) {
|
||||
const imgDiv = document.createElement('div');
|
||||
imgDiv.className = 'zone_imgbox';
|
||||
images.forEach(url => {
|
||||
const imageUrl = withImageParams(url);
|
||||
const link = document.createElement('a');
|
||||
link.href = imageUrl;
|
||||
link.setAttribute('data-fancybox', 'gallery');
|
||||
link.className = 'fancybox';
|
||||
const imgTag = document.createElement('img');
|
||||
imgTag.src = imageUrl;
|
||||
imgTag.loading = 'lazy';
|
||||
imgTag.alt = '';
|
||||
link.appendChild(imgTag);
|
||||
imgDiv.appendChild(link);
|
||||
});
|
||||
content += imgDiv.outerHTML;
|
||||
}
|
||||
|
||||
// 外链 / GitHub 项目
|
||||
// 外链 / GitHub 项目
|
||||
if (['WEBSITE', 'GITHUBPROJ'].includes(item.extension_type)) {
|
||||
let siteUrl = '', title = '';
|
||||
let extensionBack = "https://pic.biss.click/image/1971bdc1-4349-4bb9-b683-20404f5da7d7.webp";
|
||||
|
||||
// 解析 extension 字段
|
||||
try {
|
||||
const extObj = typeof item.extension === 'string' ? JSON.parse(item.extension) : item.extension;
|
||||
siteUrl = extObj.site || extObj.url || item.extension;
|
||||
title = extObj.title || siteUrl;
|
||||
} catch {
|
||||
siteUrl = item.extension;
|
||||
title = siteUrl;
|
||||
}
|
||||
|
||||
// 特殊处理 GitHub 项目
|
||||
if (item.extension_type === 'GITHUBPROJ') {
|
||||
extensionBack = "https://pic.biss.click/image/ed410d4e-d3f8-4b26-8840-50dd58f7dc4e.webp";
|
||||
|
||||
// 提取 GitHub 项目名
|
||||
const match = siteUrl.match(/^https?:\/\/github\.com\/[^/]+\/([^/?#]+)/i);
|
||||
if (match) {
|
||||
title = match[1]; // 获取仓库名
|
||||
} else {
|
||||
// fallback:从最后一个路径段提取
|
||||
try {
|
||||
const parts = new URL(siteUrl).pathname.split('/').filter(Boolean);
|
||||
title = parts.pop() || siteUrl;
|
||||
} catch {
|
||||
// 如果 URL 无效则保留原始
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 输出 HTML 结构
|
||||
content += `
|
||||
<div class="shuoshuo-external-link">
|
||||
<a class="external-link" href="${siteUrl}" target="_blank" rel="nofollow noopener">
|
||||
<div class="external-link-left" style="background-image:url(${extensionBack})"></div>
|
||||
<div class="external-link-right">
|
||||
<div class="external-link-title">${title}</div>
|
||||
<div>点击跳转<i class="fa-solid fa-angle-right"></i></div>
|
||||
</div>
|
||||
</a>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
|
||||
// 音乐
|
||||
if (item.extension_type === 'MUSIC' && item.extension) {
|
||||
const link = item.extension;
|
||||
let server = '';
|
||||
if (link.includes('music.163.com')) server = 'netease';
|
||||
else if (link.includes('y.qq.com')) server = 'tencent';
|
||||
const idMatch = link.match(/id=(\d+)/);
|
||||
const id = idMatch ? idMatch[1] : '';
|
||||
if (server && id) {
|
||||
content += `<meting-js server="${server}" type="song" id="${id}" api="https://meting.qjqq.cn/?server=:server&type=:type&id=:id&auth=:auth&r=:r"></meting-js>`;
|
||||
}
|
||||
}
|
||||
|
||||
// 视频
|
||||
if (item.extension_type === 'VIDEO' && item.extension) {
|
||||
const video = item.extension;
|
||||
if (video.startsWith('BV')) {
|
||||
const bilibiliUrl = `https://www.bilibili.com/blackboard/html5mobileplayer.html?bvid=${video}&as_wide=1&high_quality=1&danmaku=0`;
|
||||
content += `
|
||||
<div style="position: relative; padding: 30% 45%; margin-top: 10px;">
|
||||
<iframe style="position:absolute;width:100%;height:100%;left:0;top:0;border-radius:12px;"
|
||||
src="${bilibiliUrl}"
|
||||
frameborder="no"
|
||||
allowfullscreen="true"
|
||||
loading="lazy"></iframe>
|
||||
</div>`;
|
||||
} else {
|
||||
const youtubeUrl = `https://www.youtube.com/embed/${video}`;
|
||||
content += `
|
||||
<div style="position: relative; padding: 30% 45%; margin-top: 10px;">
|
||||
<iframe style="position:absolute;width:100%;height:100%;left:0;top:0;border-radius:12px;"
|
||||
src="${youtubeUrl}"
|
||||
title="YouTube video player"
|
||||
frameborder="0"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
allowfullscreen></iframe>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
content,
|
||||
user: item.username || '匿名',
|
||||
avatar: config.fallbackAvatar || window.SITE_CONFIG?.author?.avatar || '',
|
||||
date,
|
||||
location: '',
|
||||
tags: Array.isArray(item.tags) && item.tags.length ? item.tags.map(t => t.name) : ['无标签'],
|
||||
text: content.replace(/\[(.*?)\]\((.*?)\)/g, '[链接]')
|
||||
};
|
||||
};
|
||||
|
||||
const generateTalkElement = (item) => {
|
||||
const talkItem = document.createElement('div');
|
||||
talkItem.className = 'talk_item';
|
||||
|
||||
const talkMeta = document.createElement('div');
|
||||
talkMeta.className = 'talk_meta';
|
||||
const avatar = document.createElement('img');
|
||||
avatar.className = 'no-lightbox avatar';
|
||||
avatar.src = item.avatar;
|
||||
|
||||
const info = document.createElement('div');
|
||||
info.className = 'info';
|
||||
const nick = document.createElement('span');
|
||||
nick.className = 'talk_nick';
|
||||
nick.innerHTML = `${item.user} ${generateIconSVG()}`;
|
||||
const date = document.createElement('span');
|
||||
date.className = 'talk_date';
|
||||
date.textContent = item.date;
|
||||
info.appendChild(nick);
|
||||
info.appendChild(date);
|
||||
talkMeta.appendChild(avatar);
|
||||
talkMeta.appendChild(info);
|
||||
|
||||
const talkContent = document.createElement('div');
|
||||
talkContent.className = 'talk_content';
|
||||
talkContent.innerHTML = item.content;
|
||||
|
||||
const talkBottom = document.createElement('div');
|
||||
talkBottom.className = 'talk_bottom';
|
||||
const tags = document.createElement('div');
|
||||
const tag = document.createElement('span');
|
||||
tag.className = 'talk_tag';
|
||||
tag.textContent = `🏷️${item.tags}`;
|
||||
//const loc = document.createElement('span');
|
||||
//loc.className = 'location_tag';
|
||||
//loc.textContent = `🌍${item.location}`;
|
||||
tags.appendChild(tag);
|
||||
//tags.appendChild(loc);
|
||||
|
||||
const commentLink = document.createElement('a');
|
||||
commentLink.href = 'javascript:;';
|
||||
commentLink.onclick = () => goComment(item.text);
|
||||
const icon = document.createElement('span');
|
||||
icon.className = 'icon';
|
||||
icon.innerHTML = '<i class="fa-solid fa-message fa-fw"></i>';
|
||||
commentLink.appendChild(icon);
|
||||
|
||||
talkBottom.appendChild(tags);
|
||||
talkBottom.appendChild(commentLink);
|
||||
|
||||
talkItem.appendChild(talkMeta);
|
||||
talkItem.appendChild(talkContent);
|
||||
talkItem.appendChild(talkBottom);
|
||||
|
||||
return talkItem;
|
||||
};
|
||||
|
||||
const goComment = (e) => {
|
||||
const match = e.match(/<div class="talk_content_text">([\s\S]*?)<\/div>/);
|
||||
const textContent = match ? match[1] : "";
|
||||
const textarea = document.querySelector("#twikoo .el-textarea__inner");
|
||||
textarea.value = `> ${textContent}\n\n`;
|
||||
textarea.focus();
|
||||
btf.snackbarShow("已为您引用该说说,不删除空格效果更佳");
|
||||
};
|
||||
|
||||
const formatTime = (time) => {
|
||||
const timestamp = Number(time);
|
||||
const d = Number.isFinite(timestamp)
|
||||
? new Date(timestamp < 100000000000 ? timestamp * 1000 : timestamp)
|
||||
: new Date(time);
|
||||
const pad = (n) => n.toString().padStart(2, '0');
|
||||
if (Number.isNaN(d.getTime())) return '';
|
||||
return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
};
|
||||
|
||||
fetchAndRenderTalks();
|
||||
}
|
||||
|
||||
renderTalks();
|
||||
|
||||
// function whenDOMReady() {
|
||||
// const talkContainer = document.querySelector('#talk');
|
||||
// talkContainer.innerHTML = '';
|
||||
// fetchAndRenderTalks();
|
||||
// }
|
||||
// whenDOMReady();
|
||||
// document.addEventListener("pjax:complete", whenDOMReady);
|
||||
@@ -0,0 +1,103 @@
|
||||
let talkTimer = null;
|
||||
|
||||
const cacheKey = 'talksCache';
|
||||
const cacheTimeKey = 'talksCacheTime';
|
||||
const cacheDuration = 30 * 60 * 1000; // 缓存有效期 30分钟
|
||||
|
||||
function indexTalk() {
|
||||
if (talkTimer) {
|
||||
clearInterval(talkTimer);
|
||||
talkTimer = null;
|
||||
}
|
||||
|
||||
if (!document.getElementById('bber-talk')) return;
|
||||
const config = window.SITE_CONFIG?.talks || {};
|
||||
const talksCacheKey = config.cacheKey || cacheKey;
|
||||
const talksCacheTimeKey = config.cacheTimeKey || cacheTimeKey;
|
||||
const talksCacheDuration = config.cacheDurationMs || cacheDuration;
|
||||
|
||||
function toText(ls) {
|
||||
return ls.map(item => {
|
||||
let c = item.content || '';
|
||||
|
||||
const hasImg = /\!\[.*?\]\(.*?\)/.test(c);
|
||||
const hasLink = /\[.*?\]\(.*?\)/.test(c);
|
||||
|
||||
c = c
|
||||
.replace(/#(.*?)\s/g, '')
|
||||
.replace(/\{.*?\}/g, '')
|
||||
.replace(/\!\[.*?\]\(.*?\)/g, '<i class="fa-solid fa-image"></i>')
|
||||
.replace(/\[.*?\]\(.*?\)/g, '<i class="fa-solid fa-link"></i>');
|
||||
|
||||
const icons = [];
|
||||
|
||||
if (item.images?.length && !hasImg) icons.push('fa-solid fa-image');
|
||||
if (item.extension_type === 'VIDEO') icons.push('fa-solid fa-video');
|
||||
if (item.extension_type === 'MUSIC') icons.push('fa-solid fa-music');
|
||||
if (item.extension_type === 'WEBSITE' && !hasLink) icons.push('fa-solid fa-link');
|
||||
if (item.extension_type === 'GITHUBPROJ' && !hasLink) icons.push('fab fa-github');
|
||||
|
||||
if (icons.length) c += ' ' + icons.map(i => `<i class="${i}"></i>`).join(' ');
|
||||
return c;
|
||||
});
|
||||
}
|
||||
|
||||
// 渲染与轮播
|
||||
function talk(ls) {
|
||||
let html = '';
|
||||
ls.forEach((item, i) => {
|
||||
html += `<li class="item item-${i + 1}">${item}</li>`;
|
||||
});
|
||||
|
||||
let box = document.querySelector("#bber-talk .talk-list");
|
||||
if (!box) return;
|
||||
|
||||
box.innerHTML = html;
|
||||
|
||||
talkTimer = setInterval(() => {
|
||||
if (box.children.length > 0) {
|
||||
box.appendChild(box.children[0]);
|
||||
}
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
const cachedData = localStorage.getItem(talksCacheKey);
|
||||
const cachedTime = localStorage.getItem(talksCacheTimeKey);
|
||||
const currentTime = new Date().getTime();
|
||||
|
||||
// 判断缓存是否有效
|
||||
if (cachedData && cachedTime && (currentTime - cachedTime < talksCacheDuration)) {
|
||||
const data = toText(JSON.parse(cachedData));
|
||||
talk(data.slice(0, config.homeLimit || 6)); // 使用缓存渲染数据
|
||||
} else {
|
||||
if (!config.apiUrl) return;
|
||||
|
||||
fetch(config.apiUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ page: 1, pageSize: config.pageSize || 30 })
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
// 适配新版结构:code=1 且 data.items 存在
|
||||
if (data.code === 1 && data.data && Array.isArray(data.data.items)) {
|
||||
localStorage.setItem(talksCacheKey, JSON.stringify(data.data.items));
|
||||
localStorage.setItem(talksCacheTimeKey, currentTime.toString());
|
||||
|
||||
const formattedData = toText(data.data.items);
|
||||
talk(formattedData.slice(0, config.homeLimit || 6));
|
||||
} else {
|
||||
console.warn('Unexpected API response format:', data);
|
||||
}
|
||||
})
|
||||
.catch(error => console.error('Error fetching data:', error));
|
||||
}
|
||||
}
|
||||
|
||||
// pjax 支持
|
||||
function whenDOMReady() {
|
||||
indexTalk();
|
||||
}
|
||||
|
||||
whenDOMReady();
|
||||
document.addEventListener("pjax:complete", whenDOMReady);
|
||||
@@ -0,0 +1,204 @@
|
||||
// weather.js
|
||||
|
||||
(function () {
|
||||
|
||||
const WEATHER_ID = "aside-weather";
|
||||
const LOCATION_WAIT_MS = 3000;
|
||||
const LOCATION_POLL_INTERVAL_MS = 100;
|
||||
const WEATHER_KEY = "70201debab4245379e376d116b05f8a2";
|
||||
const WEATHER_HOST = "https://ny5vxmbbkd.re.qweatherapi.com";
|
||||
const WEATHER_LANG = "zh";
|
||||
|
||||
function debugLog(label, payload) {
|
||||
console.log(`[weather.js] ${label}`, payload);
|
||||
}
|
||||
|
||||
function maskSecret(secret) {
|
||||
if (!secret || secret.length < 10) return secret;
|
||||
return `${secret.slice(0, 4)}***${secret.slice(-4)}`;
|
||||
}
|
||||
|
||||
function readExistingLocation() {
|
||||
return window.ipLocation?.result?.location || null;
|
||||
}
|
||||
|
||||
function waitForExistingLocation(timeout = LOCATION_WAIT_MS) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const existingLocation = readExistingLocation();
|
||||
if (existingLocation) {
|
||||
resolve(existingLocation);
|
||||
return;
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
const timer = window.setInterval(() => {
|
||||
const location = readExistingLocation();
|
||||
if (location) {
|
||||
window.clearInterval(timer);
|
||||
resolve(location);
|
||||
return;
|
||||
}
|
||||
|
||||
if (Date.now() - startTime >= timeout) {
|
||||
window.clearInterval(timer);
|
||||
reject(new Error("等待 ipLocation 超时"));
|
||||
}
|
||||
}, LOCATION_POLL_INTERVAL_MS);
|
||||
});
|
||||
}
|
||||
|
||||
function requestLocationFallback() {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!window.$?.ajax) {
|
||||
reject(new Error("jQuery 未加载,无法执行定位兜底请求"));
|
||||
return;
|
||||
}
|
||||
|
||||
$.ajax({
|
||||
type: 'get',
|
||||
url: 'https://apis.map.qq.com/ws/location/v1/ip',
|
||||
data: {
|
||||
key: '你的key',
|
||||
output: 'jsonp',
|
||||
callback: '?',
|
||||
},
|
||||
dataType: 'jsonp',
|
||||
success: function (res) {
|
||||
debugLog("定位兜底返回", res);
|
||||
if (res && res.status === 0 && res.result?.location) {
|
||||
window.ipLocation = res;
|
||||
resolve(res.result.location);
|
||||
} else {
|
||||
reject(new Error("定位失败"));
|
||||
}
|
||||
},
|
||||
error: function () {
|
||||
reject(new Error("定位请求失败"));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function getLocation() {
|
||||
return waitForExistingLocation()
|
||||
.then(location => {
|
||||
debugLog("使用 txmap.js 定位", location);
|
||||
return location;
|
||||
})
|
||||
.catch(error => {
|
||||
debugLog("等待 txmap.js 定位失败,转兜底请求", error);
|
||||
return requestLocationFallback();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
function getWeather(lng, lat) {
|
||||
const location = `${lng.toFixed(2)},${lat.toFixed(2)}`;
|
||||
const params = new URLSearchParams({
|
||||
location,
|
||||
lang: WEATHER_LANG,
|
||||
key: WEATHER_KEY
|
||||
});
|
||||
const requestUrl = `${WEATHER_HOST}/v7/weather/now?${params.toString()}`;
|
||||
|
||||
debugLog("天气请求参数", {
|
||||
host: WEATHER_HOST,
|
||||
location,
|
||||
lang: WEATHER_LANG,
|
||||
key: maskSecret(WEATHER_KEY)
|
||||
});
|
||||
|
||||
return fetch(requestUrl, {
|
||||
headers: {
|
||||
Accept: "application/json"
|
||||
}
|
||||
}).then(async res => {
|
||||
const responseText = await res.text();
|
||||
let data;
|
||||
|
||||
try {
|
||||
data = JSON.parse(responseText);
|
||||
} catch (parseError) {
|
||||
debugLog("天气接口返回非 JSON", {
|
||||
status: res.status,
|
||||
statusText: res.statusText,
|
||||
body: responseText
|
||||
});
|
||||
throw new Error(`天气接口返回非 JSON,HTTP ${res.status}`);
|
||||
}
|
||||
|
||||
debugLog("天气接口响应", {
|
||||
status: res.status,
|
||||
statusText: res.statusText,
|
||||
body: data
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const detail = data?.error?.detail || data?.msg || `HTTP ${res.status}`;
|
||||
throw new Error(`天气接口请求失败:${detail}`);
|
||||
}
|
||||
|
||||
return data;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
function renderWeather(data) {
|
||||
const el = document.getElementById(WEATHER_ID);
|
||||
if (!el) return;
|
||||
|
||||
if (data.code !== "200") {
|
||||
debugLog("天气接口业务错误", data);
|
||||
el.innerHTML = `天气获取失败:${data.code || "未知错误"}`;
|
||||
return;
|
||||
}
|
||||
|
||||
const now = data.now;
|
||||
|
||||
const emojiMap = {
|
||||
"晴": "☀️",
|
||||
"多云": "⛅",
|
||||
"阴": "☁️",
|
||||
"雨": "🌧️",
|
||||
"雪": "❄️",
|
||||
"雷": "⛈️"
|
||||
};
|
||||
|
||||
const emoji = emojiMap[now.text] || "🌡️";
|
||||
const weatherSentence = `当前天气${now.text},气温${now.temp}℃,体感${now.feelsLike}℃,${now.windDir}${now.windScale}级,湿度${now.humidity}%,能见度${now.vis}km。`;
|
||||
|
||||
el.innerHTML = `
|
||||
<div class="aside-weather-card">
|
||||
<div class="weather-top">
|
||||
</div>
|
||||
<div class="weather-info">
|
||||
${weatherSentence}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
function initWeather() {
|
||||
const el = document.getElementById(WEATHER_ID);
|
||||
if (!el) return;
|
||||
|
||||
el.innerHTML = "🌥️ 获取天气中...";
|
||||
|
||||
getLocation()
|
||||
.then(loc => getWeather(loc.lng, loc.lat))
|
||||
.then(renderWeather)
|
||||
.catch(err => {
|
||||
console.error("[weather.js] 天气加载失败", err);
|
||||
el.innerHTML = `🌧️ 天气加载失败:${err.message || "未知错误"}`;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// 🚀 初次加载
|
||||
window.addEventListener("load", initWeather);
|
||||
|
||||
// 🔄 pjax
|
||||
document.addEventListener("pjax:complete", initWeather);
|
||||
|
||||
})();
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"id": "/",
|
||||
"name": "Bi's Blog",
|
||||
"short_name": "Bi's Blog",
|
||||
"description": "好奇心转化为代码的空间。",
|
||||
"lang": "zh-CN",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"display_override": ["window-controls-overlay", "standalone", "minimal-ui"],
|
||||
"theme_color": "#0f766e",
|
||||
"background_color": "#f7f5ef",
|
||||
"categories": ["blog", "education", "technology"],
|
||||
"icons": [
|
||||
{
|
||||
"src": "/images/pwa-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/images/pwa-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/images/pwa-maskable-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
],
|
||||
"shortcuts": [
|
||||
{
|
||||
"name": "搜索文章",
|
||||
"short_name": "搜索",
|
||||
"url": "/?action=search",
|
||||
"icons": [{ "src": "/images/pwa-192x192.png", "sizes": "192x192" }]
|
||||
},
|
||||
{
|
||||
"name": "随机文章",
|
||||
"short_name": "随机文章",
|
||||
"url": "/go/",
|
||||
"icons": [{ "src": "/images/pwa-192x192.png", "sizes": "192x192" }]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#0f766e" />
|
||||
<title>暂时离线 | Bi's Blog</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
body { min-height: 100vh; margin: 0; display: grid; place-items: center; padding: 24px; color: #27313a; font-family: system-ui, "Microsoft YaHei", sans-serif; background: linear-gradient(145deg, #f7f5ef, #e5f5ef); }
|
||||
main { width: min(520px, 100%); border: 1px solid rgba(255,255,255,.8); border-radius: 18px; padding: 40px; text-align: center; background: rgba(255,253,248,.82); box-shadow: 0 24px 70px rgba(44,55,63,.16); }
|
||||
.icon { display: grid; width: 72px; height: 72px; margin: 0 auto 22px; place-items: center; border-radius: 20px; color: white; font-size: 30px; font-weight: 700; background: #0f766e; }
|
||||
h1 { margin: 0; font-size: clamp(1.8rem, 6vw, 2.6rem); }
|
||||
p { margin: 14px 0 26px; color: #686f78; line-height: 1.8; }
|
||||
a, button { display: inline-flex; min-height: 42px; align-items: center; justify-content: center; border: 0; border-radius: 999px; padding: 9px 18px; color: white; font: inherit; font-weight: 700; text-decoration: none; background: #0f766e; cursor: pointer; }
|
||||
a { margin-left: 8px; color: #0f766e; background: #dff5ed; }
|
||||
@media (prefers-color-scheme: dark) { body { color: #eef4f6; background: #0b1014; } main { border-color: rgba(181,198,207,.2); background: #141b21; } p { color: #b6c3c9; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<div class="icon" aria-hidden="true">Bi</div>
|
||||
<h1>网络暂时走丢了</h1>
|
||||
<p>已经读过的文章仍可从浏览器历史或返回按钮打开。网络恢复后,再来继续探索吧。</p>
|
||||
<button type="button" onclick="location.reload()">重新连接</button>
|
||||
<a href="/">返回首页</a>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,261 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<xsl:stylesheet version="1.0"
|
||||
xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
|
||||
xmlns:atom="http://www.w3.org/2005/Atom">
|
||||
<xsl:output method="html" encoding="UTF-8" indent="yes" doctype-system="about:legacy-compat"/>
|
||||
|
||||
<xsl:template match="/">
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
<title><xsl:value-of select="/rss/channel/title"/> RSS</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
--bg: #f6f4ee;
|
||||
--surface: #fffdf8;
|
||||
--text: #20242a;
|
||||
--muted: #65707a;
|
||||
--line: #dfd8ca;
|
||||
--accent: #0f766e;
|
||||
--accent-soft: #d9f3ee;
|
||||
--shadow: 0 18px 48px rgba(45, 39, 28, 0.12);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #0f171a;
|
||||
--surface: #162225;
|
||||
--text: #e9f0ee;
|
||||
--muted: #a7b4b1;
|
||||
--line: rgba(174, 205, 197, 0.18);
|
||||
--accent: #72ded2;
|
||||
--accent-soft: rgba(114, 222, 210, 0.14);
|
||||
--shadow: 0 18px 48px rgba(0, 0, 0, 0.28);
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
color: var(--text);
|
||||
font-family: ui-serif, "Songti SC", "Noto Serif CJK SC", Georgia, serif;
|
||||
line-height: 1.75;
|
||||
background:
|
||||
linear-gradient(rgba(246, 244, 238, 0.88), rgba(246, 244, 238, 0.98)),
|
||||
url("/images/background.png") center top / cover fixed,
|
||||
var(--bg);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body {
|
||||
background:
|
||||
linear-gradient(rgba(8, 13, 16, 0.88), rgba(10, 17, 20, 0.96)),
|
||||
url("/images/background.png") center top / cover fixed,
|
||||
var(--bg);
|
||||
}
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.wrap {
|
||||
width: min(1040px, calc(100% - 32px));
|
||||
margin: 0 auto;
|
||||
padding: 48px 0 56px;
|
||||
}
|
||||
|
||||
.hero {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
padding: clamp(28px, 5vw, 52px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.62);
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--surface) 86%, transparent);
|
||||
box-shadow: var(--shadow);
|
||||
backdrop-filter: blur(18px) saturate(150%);
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
width: fit-content;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
color: var(--accent);
|
||||
font: 700 0.86rem/1.2 ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.badge svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
h1 {
|
||||
max-width: 12ch;
|
||||
margin: 0;
|
||||
font-size: clamp(2.3rem, 6vw, 4.8rem);
|
||||
line-height: 1.05;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.description {
|
||||
max-width: 680px;
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: clamp(1rem, 2vw, 1.18rem);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
color: var(--muted);
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 0.94rem;
|
||||
}
|
||||
|
||||
.button {
|
||||
display: inline-flex;
|
||||
min-height: 42px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 8px 14px;
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 999px;
|
||||
color: #fff;
|
||||
background: var(--accent);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.button.secondary {
|
||||
color: var(--accent);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.feed {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.item {
|
||||
padding: clamp(20px, 4vw, 30px);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--surface) 92%, transparent);
|
||||
box-shadow: 0 10px 30px rgba(45, 39, 28, 0.08);
|
||||
}
|
||||
|
||||
.item h2 {
|
||||
margin: 0;
|
||||
font-size: clamp(1.25rem, 3vw, 1.75rem);
|
||||
line-height: 1.35;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.item h2 a:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.meta {
|
||||
margin: 8px 0 0;
|
||||
color: var(--muted);
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.summary {
|
||||
margin: 14px 0 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.summary * {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.summary a {
|
||||
color: var(--accent);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
|
||||
.read {
|
||||
display: inline-flex;
|
||||
margin-top: 14px;
|
||||
color: var(--accent);
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.wrap {
|
||||
width: min(100% - 20px, 1040px);
|
||||
padding-top: 20px;
|
||||
}
|
||||
|
||||
.hero {
|
||||
padding: 24px 18px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="wrap">
|
||||
<section class="hero">
|
||||
<div class="badge">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M6.18 17.82a2.18 2.18 0 1 1 0 4.36 2.18 2.18 0 0 1 0-4.36ZM4 9.5c5.8 0 10.5 4.7 10.5 10.5h-3A7.5 7.5 0 0 0 4 12.5v-3Zm0-6C13.11 3.5 20.5 10.89 20.5 20h-3C17.5 12.55 11.45 6.5 4 6.5v-3Z"/>
|
||||
</svg>
|
||||
RSS Feed
|
||||
</div>
|
||||
<h1><xsl:value-of select="/rss/channel/title"/></h1>
|
||||
<p class="description"><xsl:value-of select="/rss/channel/description"/></p>
|
||||
<div class="actions">
|
||||
<a class="button" href="{/rss/channel/link}">返回博客</a>
|
||||
<a class="button secondary" href="/rss.xml">订阅源地址</a>
|
||||
<span>把这个地址添加到 RSS 阅读器,就能持续收到新文章。</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="feed" aria-label="文章列表">
|
||||
<xsl:for-each select="/rss/channel/item">
|
||||
<article class="item">
|
||||
<h2>
|
||||
<a href="{link}">
|
||||
<xsl:value-of select="title"/>
|
||||
</a>
|
||||
</h2>
|
||||
<p class="meta"><xsl:value-of select="pubDate"/></p>
|
||||
<div class="summary">
|
||||
<xsl:value-of select="description" disable-output-escaping="yes"/>
|
||||
</div>
|
||||
<a class="read" href="{link}">阅读全文</a>
|
||||
</article>
|
||||
</xsl:for-each>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
</xsl:template>
|
||||
</xsl:stylesheet>
|
||||
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?><sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"><sitemap><loc>https://blog.biss.click/sitemap-0.xml</loc></sitemap></sitemapindex>
|
||||
@@ -0,0 +1,110 @@
|
||||
const CACHE_VERSION = '2026-07-17-v1';
|
||||
const CACHE_PREFIX = 'bis-blog-';
|
||||
const CORE_CACHE = `${CACHE_PREFIX}core-${CACHE_VERSION}`;
|
||||
const PAGE_CACHE = `${CACHE_PREFIX}pages-${CACHE_VERSION}`;
|
||||
const ASSET_CACHE = `${CACHE_PREFIX}assets-${CACHE_VERSION}`;
|
||||
const IMAGE_CACHE = `${CACHE_PREFIX}images-${CACHE_VERSION}`;
|
||||
|
||||
const CORE_ASSETS = [
|
||||
'/',
|
||||
'/offline.html',
|
||||
'/manifest.webmanifest',
|
||||
'/images/Bi.ico',
|
||||
'/images/pwa-192x192.png',
|
||||
'/images/pwa-512x512.png',
|
||||
'/images/pwa-maskable-512x512.png',
|
||||
'/images/apple-touch-icon.png',
|
||||
];
|
||||
|
||||
self.addEventListener('install', (event) => {
|
||||
event.waitUntil(
|
||||
caches.open(CORE_CACHE)
|
||||
.then((cache) => cache.addAll(CORE_ASSETS))
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil((async () => {
|
||||
const cacheNames = await caches.keys();
|
||||
await Promise.all(
|
||||
cacheNames
|
||||
.filter((name) => name.startsWith(CACHE_PREFIX) && ![CORE_CACHE, PAGE_CACHE, ASSET_CACHE, IMAGE_CACHE].includes(name))
|
||||
.map((name) => caches.delete(name))
|
||||
);
|
||||
await self.clients.claim();
|
||||
const clients = await self.clients.matchAll({ type: 'window' });
|
||||
clients.forEach((client) => client.postMessage({ type: 'OFFLINE_READY' }));
|
||||
})());
|
||||
});
|
||||
|
||||
self.addEventListener('message', (event) => {
|
||||
if (event.data?.type === 'SKIP_WAITING') self.skipWaiting();
|
||||
});
|
||||
|
||||
async function trimCache(cacheName, maxEntries) {
|
||||
const cache = await caches.open(cacheName);
|
||||
const keys = await cache.keys();
|
||||
if (keys.length > maxEntries) {
|
||||
await Promise.all(keys.slice(0, keys.length - maxEntries).map((key) => cache.delete(key)));
|
||||
}
|
||||
}
|
||||
|
||||
async function networkFirst(request) {
|
||||
try {
|
||||
const response = await fetch(request);
|
||||
if (response.ok) {
|
||||
const cache = await caches.open(PAGE_CACHE);
|
||||
await cache.put(request, response.clone());
|
||||
void trimCache(PAGE_CACHE, 40);
|
||||
}
|
||||
return response;
|
||||
} catch {
|
||||
return (await caches.match(request)) || (await caches.match('/offline.html'));
|
||||
}
|
||||
}
|
||||
|
||||
async function cacheFirst(request, cacheName, maxEntries) {
|
||||
const cached = await caches.match(request);
|
||||
if (cached) return cached;
|
||||
|
||||
const response = await fetch(request);
|
||||
if (response.ok || response.type === 'opaque') {
|
||||
const cache = await caches.open(cacheName);
|
||||
await cache.put(request, response.clone());
|
||||
void trimCache(cacheName, maxEntries);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
async function staleWhileRevalidate(request) {
|
||||
const cache = await caches.open(ASSET_CACHE);
|
||||
const cached = await cache.match(request);
|
||||
const network = fetch(request).then((response) => {
|
||||
if (response.ok) cache.put(request, response.clone());
|
||||
return response;
|
||||
}).catch(() => cached);
|
||||
return cached || network;
|
||||
}
|
||||
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const { request } = event;
|
||||
if (request.method !== 'GET') return;
|
||||
|
||||
const url = new URL(request.url);
|
||||
if (request.mode === 'navigate') {
|
||||
event.respondWith(networkFirst(request));
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.destination === 'image') {
|
||||
event.respondWith(cacheFirst(request, IMAGE_CACHE, 100));
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
url.origin === self.location.origin &&
|
||||
['style', 'script', 'font', 'worker'].includes(request.destination)
|
||||
) {
|
||||
event.respondWith(staleWhileRevalidate(request));
|
||||
}
|
||||
});
|
||||