Files
server-ops/docs/knowledge-base/search.html

768 lines
22 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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>