snapshot: 知识库搜索服务完整部署快照(含search.html/reader.html/README)

This commit is contained in:
fxy
2026-06-24 13:12:49 +08:00
parent a32204fa8c
commit 4934af318c
3 changed files with 1183 additions and 0 deletions

View File

@ -0,0 +1,767 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>知识库 · 全文搜索</title>
<style>
:root {
--bg: #0f1117;
--card: #1a1d2e;
--card-hover: #22263b;
--border: #2a2e42;
--text: #e1e4ed;
--text-dim: #7a7f99;
--text-bright: #ffffff;
--accent: #6c5ce7;
--accent-glow: rgba(108,92,231,.3);
--badge-bg: #252a41;
--badge-text: #a5a9c4;
--green: #00cec9;
--amber: #fdcb6e;
--blue: #74b9ff;
--purple: #a29bfe;
--orange: #e17055;
--radius: 12px;
--radius-sm: 8px;
}
* { margin:0; padding:0; box-sizing:border-box; }
body {
background: var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans SC", sans-serif;
min-height: 100vh;
line-height: 1.6;
}
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
.container { max-width: 800px; margin: 0 auto; padding: 24px 16px; }
/* Header */
.header {
text-align: center;
padding: 32px 0 16px;
}
.header h1 {
font-size: 28px;
font-weight: 700;
color: var(--text-bright);
letter-spacing: 1px;
}
.header .subtitle {
font-size: 13px;
color: var(--text-dim);
margin-top: 6px;
}
.header .stats { margin-top: 4px; font-size: 12px; color: var(--text-dim); }
/* Search box */
.search-wrap {
position: relative;
margin: 20px 0 12px;
}
.search-wrap .icon {
position: absolute;
left: 16px;
top: 50%;
transform: translateY(-50%);
width: 20px;
height: 20px;
color: var(--text-dim);
pointer-events: none;
}
.search-wrap input {
width: 100%;
padding: 14px 16px 14px 48px;
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text);
font-size: 16px;
outline: none;
transition: border-color .2s, box-shadow .2s;
}
.search-wrap input:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-glow);
}
.search-wrap input::placeholder { color: var(--text-dim); }
/* Filter chips */
.filters {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: 12px 0;
}
.chip {
padding: 6px 16px;
border-radius: 20px;
font-size: 13px;
font-weight: 500;
background: var(--card);
border: 1px solid var(--border);
color: var(--text-dim);
cursor: pointer;
transition: all .2s;
user-select: none;
}
.chip:hover { border-color: var(--accent); color: var(--text); background: rgba(108,92,231,.06); }
.chip.active {
background: linear-gradient(135deg, var(--accent), #8b7cf7);
border-color: var(--accent);
color: #fff;
box-shadow: 0 2px 8px var(--accent-glow);
}
/* Sort bar */
.sort-bar {
display: flex;
align-items: center;
gap: 12px;
margin: 8px 0 16px;
font-size: 13px;
color: var(--text-dim);
}
.sort-bar button {
background: none;
border: 1px solid var(--border);
color: var(--text-dim);
padding: 5px 14px;
border-radius: 6px;
cursor: pointer;
font-size: 12px;
font-weight: 500;
transition: all .2s;
}
.sort-bar button:hover { border-color: var(--accent); color: var(--text); background: rgba(108,92,231,.06); }
.sort-bar button.active { border-color: var(--accent); color: var(--accent); background: rgba(108,92,231,.1); }
.sort-bar .result-count { margin-left: auto; }
/* Loading */
.loading {
text-align: center;
padding: 60px 0;
color: var(--text-dim);
}
.spinner {
width: 32px; height: 32px;
border: 3px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin .8s linear infinite;
margin: 0 auto 12px;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* Results */
.results { margin-top: 8px; }
/* Card */
.card {
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 14px 16px;
margin-bottom: 6px;
transition: border-color .15s, background .15s;
position: relative;
}
.card:hover { border-color: var(--accent); }
.card:active { background: var(--card-hover); }
.card-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 8px;
}
.card-title {
font-size: 14px;
font-weight: 600;
color: var(--text-bright);
line-height: 1.5;
cursor: pointer;
flex: 1;
}
.card-title:hover { color: var(--accent); }
.card-badge {
font-size: 10px;
padding: 2px 9px;
border-radius: 4px;
white-space: nowrap;
flex-shrink: 0;
font-weight: 500;
background: var(--badge-bg);
color: var(--badge-text);
line-height: 1.5;
border: none;
}
.card-badge::before {
content: '';
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
margin-right: 5px;
vertical-align: middle;
position: relative;
top: -1px;
}
.badge-灵感文章::before { background: #f59e0b; }
.badge-灵感日报::before { background: #10b981; }
.badge-读书笔记::before { background: #3b82f6; }
.badge-日记::before { background: #8b5cf6; }
.badge-随手记::before { background: #06b6d4; }
.card-date {
font-size: 11px;
color: var(--text-dim);
margin-top: 4px;
}
.card-excerpt {
font-size: 13px;
color: var(--text-dim);
margin-top: 6px;
line-height: 1.6;
cursor: pointer;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
transition: all .15s;
}
.card-excerpt.open {
-webkit-line-clamp: unset;
display: block;
}
.card-excerpt:hover { color: var(--text); }
.card-excerpt mark {
background: rgba(99,102,241,.2);
color: var(--accent);
padding: 0 2px;
border-radius: 2px;
font-weight: 500;
}
/* Landing — overview dashboard */
.landing {
animation: fadeIn .35s ease;
}
.landing-welcome {
text-align: center;
padding: 40px 20px 20px;
}
.landing-welcome h2 { font-size: 22px; font-weight: 700; color: var(--text-bright); margin-bottom: 6px; }
.landing-welcome p { font-size: 14px; color: var(--text-dim); }
.landing-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 10px;
margin: 24px 0 32px;
}
.landing-card {
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 18px 16px;
text-align: center;
transition: border-color .15s;
cursor: pointer;
}
.landing-card:hover { border-color: var(--accent); }
.landing-card .count {
font-size: 28px;
font-weight: 700;
color: var(--text-bright);
line-height: 1.2;
}
.landing-card .label {
font-size: 12px;
color: var(--text-dim);
margin-top: 4px;
display: flex;
align-items: center;
justify-content: center;
gap: 5px;
}
.landing-card .label .dot {
width: 6px; height: 6px;
border-radius: 50%;
display: inline-block;
}
.landing-section {
margin-bottom: 28px;
}
.landing-section h3 {
font-size: 13px;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: .5px;
margin-bottom: 10px;
padding-bottom: 6px;
border-bottom: 1px solid var(--border);
}
.landing-recent {
list-style: none;
padding: 0;
}
.landing-recent li {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 0;
border-bottom: 1px solid rgba(0,0,0,.03);
cursor: pointer;
font-size: 13px;
transition: color .15s;
}
.landing-recent li:hover { color: var(--accent); }
.landing-recent li .recent-dot {
width: 4px; height: 4px;
border-radius: 50%;
flex-shrink: 0;
background: var(--text-muted);
}
.landing-recent li .recent-title { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.landing-recent li .recent-source { font-size: 11px; color: var(--text-muted); flex-shrink: 0; }
.landing-recent li .recent-date { font-size: 11px; color: var(--text-muted); flex-shrink: 0; }
/* No results */
.empty {
text-align: center;
padding: 60px 20px;
color: var(--text-dim);
}
.empty .big-icon { font-size: 48px; margin-bottom: 12px; opacity: .4; }
.empty h3 { font-size: 18px; color: var(--text); margin-bottom: 6px; }
.empty p { font-size: 14px; }
/* Pagination */
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 8px;
margin: 24px 0 40px;
}
.pagination button {
padding: 6px 14px;
border-radius: 6px;
border: 1px solid var(--border);
background: var(--card);
color: var(--text);
cursor: pointer;
font-size: 13px;
transition: all .15s;
}
.pagination button:hover:not(:disabled) { border-color: var(--accent); }
.pagination button.active { background: var(--accent); border-color: var(--accent); color: #fff; }
.pagination button:disabled { opacity: .3; cursor: default; }
.pagination .page-info { font-size: 13px; color: var(--text-dim); }
/* Footer */
.footer {
text-align: center;
padding: 20px;
font-size: 12px;
color: var(--text-dim);
}
/* Badge colors (dot only) */
.badge-灵感文章::before { background: #f59e0b; }
.badge-灵感日报::before { background: #10b981; }
.badge-读书笔记::before { background: #3b82f6; }
.badge-日记::before { background: #8b5cf6; }
.badge-随手记::before { background: #06b6d4; }
/* Responsive */
@media (max-width:600px) {
.container { padding: 16px 12px; }
.header h1 { font-size: 22px; }
.search-wrap input { font-size: 15px; padding: 12px 14px 12px 44px; }
.card { padding: 12px 14px; }
.card-title { font-size: 14px; }
.sort-bar { flex-wrap: wrap; }
}
/* Animations */
.card { animation: fadeIn .25s ease; }
@keyframes fadeIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>📖 知识库</h1>
<div class="subtitle">全文搜索 · 灵感 · 日记 · 读书笔记 · 随手记</div>
<div class="stats" id="stats"></div>
</div>
<div class="search-wrap">
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>
<input type="text" id="searchInput" placeholder="搜任何内容…" autofocus>
</div>
<div class="filters" id="filters">
<span class="chip active" data-source="all">全部</span>
<span class="chip" data-source="灵感文章">灵感文章</span>
<span class="chip" data-source="灵感日报">灵感日报</span>
<span class="chip" data-source="读书笔记">读书笔记</span>
<span class="chip" data-source="日记">日记</span>
<span class="chip" data-source="随手记">随手记</span>
</div>
<div class="sort-bar">
<span>排序</span>
<button class="active" data-sort="relevance">相关度</button>
<button data-sort="date">时间</button>
<span class="result-count" id="resultCount"></span>
</div>
<div id="results"><div class="loading"><div class="spinner"></div>正在搜索…</div></div>
</div>
<div class="footer">
<span>数据每日自动更新 · 北京 · Meilisearch</span>
</div>
<script>
// Config
const MEILI_URL = '/search-api/indexes/knowledge/search';
const SEARCH_KEY = '12d3464de5e0b9bb098952f1a1fe99da0b8fe9111737a71eeddb5456278f6c7c';
const PAGE_SIZE = 20;
const NAV_BASE = 'https://nav.xybkwd.top';
// State
let currentQuery = '';
let currentSource = 'all';
let currentSort = 'relevance';
let currentPage = 1;
let totalHits = 0;
let lastResults = [];
// DOM refs
const $ = id => document.getElementById(id);
const input = $('searchInput');
const resultsDiv = $('results');
const resultCount = $('resultCount');
const stats = $('stats');
const sourceColors = {
'灵感文章': ['#f59e0b', 'badge-灵感文章'],
'灵感日报': ['#10b981', 'badge-灵感日报'],
'读书笔记': ['#3b82f6', 'badge-读书笔记'],
'日记': ['#8b5cf6', 'badge-日记'],
'随手记': ['#06b6d4', 'badge-随手记'],
};
const sourceNames = ['灵感文章', '灵感日报', '读书笔记', '日记', '随手记'];
// Init
let hasLoadedOnce = false;
fetchStats();
showLanding(); // Show landing page first
// Event listeners
// Event listeners
input.addEventListener('input', debounce(() => { currentPage = 1; search(); }, 250));
input.addEventListener('keydown', e => { if (e.key === 'Escape') { input.value = ''; currentPage = 1; search(); } });
document.querySelectorAll('.chip').forEach(chip => {
chip.addEventListener('click', () => {
document.querySelectorAll('.chip').forEach(c => c.classList.remove('active'));
chip.classList.add('active');
currentSource = chip.dataset.source;
currentPage = 1;
search();
});
});
document.querySelectorAll('.sort-bar button').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.sort-bar button').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
currentSort = btn.dataset.sort;
currentPage = 1;
search();
});
});
// Functions
async function search() {
const q = input.value.trim();
currentQuery = q;
// No query → show landing instead of running empty search
if (!q && currentSource === 'all') {
showLanding();
resultCount.textContent = '';
return;
}
resultsDiv.innerHTML = '<div class="loading"><div class="spinner"></div>正在搜索…</div>';
try {
const params = { q, limit: PAGE_SIZE, offset: (currentPage - 1) * PAGE_SIZE };
// Use Meilisearch's crop & highlight for keyword-in-context display
params.attributesToCrop = ['content'];
params.cropLength = 200;
params.attributesToHighlight = ['content'];
params.highlightPreTag = '<mark>';
params.highlightPostTag = '</mark>';
// Build filter
const filters = [];
if (currentSource !== 'all') {
filters.push(`source = ${JSON.stringify(currentSource)}`);
}
if (filters.length) params.filter = filters.join(' AND ');
// Build sort
if (currentSort === 'date' || !q) {
// No query → default to date sort so latest content (incl. Memos) shows first
params.sort = ['date:desc'];
}
const resp = await fetch(MEILI_URL, {
method: 'POST',
headers: {
'Authorization': `Bearer ${SEARCH_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(params),
});
const data = await resp.json();
lastResults = data.hits || [];
totalHits = data.estimatedTotalHits || 0;
render();
} catch (e) {
resultsDiv.innerHTML = `<div class="empty"><p>搜索出错:${e.message}</p></div>`;
}
}
function render() {
if (totalHits === 0) {
if (!currentQuery) {
showLanding();
return;
}
resultsDiv.innerHTML = `
<div class="empty">
<div class="big-icon">🔍</div>
<h3>没有找到相关内容</h3>
<p>试试其他关键词,或换个分类筛选</p>
</div>`;
resultCount.textContent = '';
return;
}
let html = '<div class="results">';
lastResults.forEach(h => {
const title = h.title || '(无标题)';
const src = h.source || '其他';
const badgeClass = (sourceColors[src] || ['', ''])[1];
const url = buildUrl(h);
const date = h.date || '';
// Get highlighted excerpt
let excerptHtml = '';
if (currentQuery && h._formatted && h._formatted.content) {
excerptHtml = h._formatted.content;
if (excerptHtml.startsWith('…')) excerptHtml = excerptHtml.substring(1);
if (excerptHtml.endsWith('…')) excerptHtml = excerptHtml.substring(0, excerptHtml.length - 1);
excerptHtml = excerptHtml.trim();
excerptHtml = stripHtmlTags(excerptHtml);
} else {
let cleaned = (h.content || '').substring(0, 250);
cleaned = cleaned.replace(/^---[\s\S]*?---\n*/g, '');
cleaned = cleaned.replace(/(^|\n)#[^\n]*#/g, '');
excerptHtml = escapeHtml(cleaned.trim().substring(0, 250));
}
html += `
<div class="card">
<div class="card-head">
<div class="card-title" onclick="window.open('${url}','_blank')">${escapeHtml(title)}</div>
<span class="card-badge ${badgeClass}">${src}</span>
</div>
${date ? `<div class="card-date">${date}</div>` : ''}
<div class="card-excerpt" onclick="this.classList.toggle('open')">${excerptHtml}</div>
</div>`;
});
html += '</div>';
const totalPages = Math.ceil(totalHits / PAGE_SIZE);
if (totalPages > 1) {
html += '<div class="pagination">';
html += `<button ${currentPage <= 1 ? 'disabled' : ''} onclick="goPage(${currentPage - 1})"></button>`;
const start = Math.max(1, currentPage - 2);
const end = Math.min(totalPages, currentPage + 2);
for (let i = start; i <= end; i++) {
html += `<button class="${i === currentPage ? 'active' : ''}" onclick="goPage(${i})">${i}</button>`;
}
html += `<button ${currentPage >= totalPages ? 'disabled' : ''} onclick="goPage(${currentPage + 1})"></button>`;
html += `<span class="page-info">共 ${totalHits} 条 / ${totalPages} 页</span>`;
html += '</div>';
}
resultCount.textContent = `${totalHits} 条结果`;
resultsDiv.innerHTML = html;
}
function showLanding() {
resultsDiv.innerHTML = '<div class="loading"><div class="spinner"></div>加载中…</div>';
resultCount.textContent = '';
const facetsReq = fetch(MEILI_URL, {
method: 'POST',
headers: { 'Authorization': `Bearer ${SEARCH_KEY}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ q: '', limit: 0, facets: ['source'] }),
});
const recentReq = fetch(MEILI_URL, {
method: 'POST',
headers: { 'Authorization': `Bearer ${SEARCH_KEY}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ q: '', limit: 10, sort: ['date:desc'] }),
});
Promise.all([facetsReq, recentReq])
.then(async ([fR, rR]) => {
const fData = await fR.json();
const rData = await rR.json();
const total = fData.estimatedTotalHits || 0;
const dist = (fData.facetDistribution && fData.facetDistribution.source) || {};
const recent = rData.hits || [];
let gridHtml = sourceNames.map(s => {
const c = dist[s] || 0;
const color = sourceColors[s] ? sourceColors[s][0] : '#666';
const badgeClass = sourceColors[s] ? sourceColors[s][1] : '';
return `<div class="landing-card" onclick="filterSource('${s}')">
<div class="count">${c}</div>
<div class="label"><span class="dot" style="background:${color}"></span>${s}</div>
</div>`;
}).join('');
let recentHtml = recent.map(h => {
const dotColor = (sourceColors[h.source] || ['#666'])[0];
const title = h.title || '(无标题)';
const date = h.date || '';
return `<li onclick="searchQuery('${escapeHtml(title).replace(/'/g, "\\'")}')">
<span class="recent-dot" style="background:${dotColor}"></span>
<span class="recent-title">${escapeHtml(title)}</span>
<span class="recent-source">${h.source || ''}</span>
<span class="recent-date">${date}</span>
</li>`;
}).join('');
stats.textContent = `${total} 篇文档`;
resultsDiv.innerHTML = `
<div class="landing">
<div class="landing-welcome">
<h2>📖 知识库</h2>
<p>搜索你的灵感、日记和读书笔记 · 共 ${total} 篇文档</p>
</div>
<div class="landing-grid">${gridHtml}</div>
${recent.length ? `<div class="landing-section"><h3>最近更新</h3><ul class="landing-recent">${recentHtml}</ul></div>` : ''}
</div>`;
})
.catch(() => {
resultsDiv.innerHTML = `<div class="empty"><h3>知识库</h3><p>输入关键词开始搜索</p></div>`;
});
}
function filterSource(source) {
currentSource = source;
currentPage = 1;
document.querySelectorAll('.chip').forEach(c => {
c.classList.toggle('active', c.dataset.source === source);
});
search();
}
function searchQuery(q) {
input.value = q;
currentQuery = q;
currentPage = 1;
search();
}
async function fetchStats() {
try {
const resp = await fetch(MEILI_URL, {
method: 'POST',
headers: {
'Authorization': `Bearer ${SEARCH_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ q: '', limit: 0 }),
});
const data = await resp.json();
const total = data.estimatedTotalHits || 0;
stats.textContent = `📦 ${total} 篇文档 · 每日更新`;
} catch (e) {
stats.textContent = '📦 知识库';
}
}
function goPage(page) {
currentPage = page;
search();
window.scrollTo({ top: 0, behavior: 'smooth' });
}
function buildUrl(h) {
if (h.source === '随手记') {
if (h._memos_uid) {
return `${NAV_BASE}/reader.html?memosUid=${encodeURIComponent(h._memos_uid)}`;
}
return 'https://memo.xybkwd.top/';
}
if (h.path) {
const p = h.path.replace('/home/ubuntu/backups/tokyo/repos/', '');
if (p.startsWith('inspiration-collector/') || p.startsWith('weread-notes/')) {
const parts = p.split('/');
const repo = parts[0];
const filePath = parts.slice(1).join('/');
const rawUrl = `${NAV_BASE}/gitea-raw/fxy/${repo}/raw/branch/main/${filePath}`;
return `${NAV_BASE}/reader.html?url=${encodeURIComponent(rawUrl)}`;
}
if (p.includes('webdav-diary')) return 'https://dav.xybkwd.top/';
}
return '#';
}
function escapeHtml(s) {
const div = document.createElement('div');
div.textContent = s;
return div.innerHTML;
}
function debounce(fn, ms) {
let timer;
return (...args) => { clearTimeout(timer); timer = setTimeout(() => fn(...args), ms); };
}
function stripHtmlTags(s) {
// Strip HTML tags but preserve <mark> tags (they're our highlight markers)
// First replace <mark> and </mark> with placeholders
const OPEN_MARK = '___MARK_OPEN___';
const CLOSE_MARK = '___MARK_CLOSE___';
s = s.replace(/<mark>/g, OPEN_MARK).replace(/<\/mark>/g, CLOSE_MARK);
// Strip all other HTML tags
s = s.replace(/<[^>]*>/g, '');
// Restore <mark> tags
s = s.replace(new RegExp(OPEN_MARK, 'g'), '<mark>').replace(new RegExp(CLOSE_MARK, 'g'), '</mark>');
return s;
}
</script>
</body>
</html>