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,51 @@
# 知识库服务 · 部署快照
> 备份时间2026-06-24 13:11
> 状态:最终验收通过 ✅
## 架构
```
东京服务器nav.xybkwd.top
├── search.html → 搜索页面(带 landing dashboard
├── reader.html → Markdown 阅读器YAML strip + marked 渲染)
├── /search-api/ → 反代到北京 7700Meilisearch API
└── /gitea-raw/ → 反代到 Tokyo Gitearaw 文件 + API Token
北京服务器81.70.93.78
├── Meilisearch → 端口 7700仅东京 IP 可访问
├── indexer.py → 索引器Gitea 仓库 + WebDAV + Memos
├── backup_from_tokyo.sh → 每日备份脚本(凌晨 3:00
└── cron → 索引更新(凌晨 4:10
```
## 数据源285 条)
| 来源 | 条数 | 数据获取方式 |
|:---|:---:|:---|
| 灵感文章 | 68 | Gitea backup → indexer.py |
| 灵感日报 | 70 | Gitea backup → indexer.py |
| 读书笔记 | 108 | Gitea backup → indexer.py |
| 日记 | 3 | WebDAV → backup → indexer.py |
| 随手记 | 105 | SQLite DB → backup → indexer.py |
## 认证链路
```
浏览器 → nav.xybkwd.top/search/* → Caddy Cookie 认证sess
→ /search-api/* → 北京 7700含 API Key
→ /gitea-raw/* → Tokyo Gitea含 API Token
→ reader.html → 渲染展示
```
## 入口
- 导航页 → 知识库卡片 → `nav.xybkwd.top/search.html`
- 直接访问:`nav.xybkwd.top/search.html`
## 关键配置
- **Meilisearch Master Key**`m42-s8k-xybkwd-search-2026`
- **搜索前端 Key**`12d3464de5e0b9bb098952f1a1fe99da0b8fe9111737a71eeddb5456278f6c7c`
- **索引定时**:北京 cron `10 4 * * *`(凌晨 4:10
- **备份定时**:北京 cron `0 3 * * *`(凌晨 3:00

View File

@ -0,0 +1,365 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>阅读 · 知识库</title>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<style>
:root {
--bg: #fafafa;
--surface: #fff;
--text: #1a1a2e;
--text-secondary: #6b7280;
--text-muted: #9ca3af;
--border: #e5e7eb;
--accent: #6366f1;
--accent-hover: #4f46e5;
--quote-bar: #d1d5db;
--quote-bg: #f3f4f6;
--code-bg: #1e293b;
--code-text: #e2e8f0;
--shadow: 0 1px 3px rgba(0,0,0,.06), 0 1px 2px rgba(0,0,0,.04);
--radius: 8px;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0f172a;
--surface: #1e293b;
--text: #e2e8f0;
--text-secondary: #94a3b8;
--text-muted: #64748b;
--border: #334155;
--accent: #818cf8;
--accent-hover: #6366f1;
--quote-bar: #475569;
--quote-bg: #1e293b;
--code-bg: #0f172a;
--code-text: #e2e8f0;
--shadow: 0 1px 3px rgba(0,0,0,.2), 0 1px 2px rgba(0,0,0,.15);
}
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, "Noto Serif SC", "Source Han Serif SC", "Songti SC", Georgia, serif;
background: var(--bg);
color: var(--text);
line-height: 1.9;
font-size: 17px;
-webkit-font-smoothing: antialiased;
}
/* Top bar */
.top-bar {
position: sticky;
top: 0;
z-index: 10;
background: rgba(250,250,250,.85);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border-bottom: 1px solid var(--border);
padding: 12px 24px;
display: flex;
align-items: center;
gap: 12px;
}
@media (prefers-color-scheme: dark) {
.top-bar { background: rgba(15,23,42,.85); }
}
.back-btn {
display: inline-flex;
align-items: center;
gap: 6px;
background: var(--surface);
border: 1px solid var(--border);
color: var(--text-secondary);
padding: 6px 14px;
border-radius: var(--radius);
cursor: pointer;
font-size: 13px;
font-family: -apple-system, "PingFang SC", sans-serif;
transition: all .2s;
box-shadow: var(--shadow);
}
.back-btn:hover {
border-color: var(--accent);
color: var(--accent);
box-shadow: 0 2px 8px rgba(99,102,241,.15);
}
.back-btn svg { width: 14px; height: 14px; flex-shrink: 0; }
.top-bar .doc-title {
font-family: -apple-system, "PingFang SC", sans-serif;
font-size: 14px;
color: var(--text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
}
/* Content area */
#content {
max-width: 720px;
margin: 0 auto;
padding: 48px 32px 100px;
animation: fadeUp .35s ease;
}
@keyframes fadeUp {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
}
/* Typography */
#content h1 { font-size: 1.9em; font-weight: 700; margin: 0 0 .3em; line-height: 1.3; letter-spacing: -.02em; color: var(--text); }
#content h2 { font-size: 1.45em; font-weight: 600; margin: 1.8em 0 .5em; padding-bottom: .3em; border-bottom: 1px solid var(--border); line-height: 1.35; color: var(--text); }
#content h3 { font-size: 1.2em; font-weight: 600; margin: 1.6em 0 .4em; line-height: 1.4; color: var(--text); }
#content h4 { font-size: 1.05em; font-weight: 600; margin: 1.4em 0 .3em; color: var(--text); }
#content p { margin: .9em 0; }
#content p:first-child { margin-top: 0; }
#content a { color: var(--accent); text-decoration: none; border-bottom: 1px solid transparent; transition: border-color .2s; }
#content a:hover { border-bottom-color: var(--accent); }
#content blockquote {
position: relative;
margin: 1.2em 0;
padding: 14px 20px;
border-left: 3px solid var(--accent);
background: var(--quote-bg);
border-radius: 0 var(--radius) var(--radius) 0;
color: var(--text-secondary);
font-style: italic;
}
#content blockquote p { margin: .4em 0; }
/* Meta blockquote (本文共…字, 自动生成等) */
#content blockquote.meta-note {
border-left-color: var(--border);
background: transparent;
color: var(--text-muted);
font-size: 13px;
padding: 8px 16px;
margin: 0 0 1.5em;
font-style: normal;
font-family: -apple-system, "PingFang SC", sans-serif;
}
#content code {
font-family: "JetBrains Mono", "Fira Code", "Cascadia Code", "Consolas", monospace;
font-size: .88em;
background: rgba(99,102,241,.08);
color: var(--accent);
padding: 2px 7px;
border-radius: 4px;
}
#content pre {
margin: 1.2em 0;
background: var(--code-bg);
border-radius: var(--radius);
overflow-x: auto;
box-shadow: 0 2px 12px rgba(0,0,0,.15);
}
#content pre code {
display: block;
padding: 20px;
background: transparent;
color: var(--code-text);
font-size: .85em;
line-height: 1.7;
tab-size: 2;
}
#content img {
max-width: 100%;
border-radius: var(--radius);
margin: 1.5em 0;
box-shadow: var(--shadow);
}
#content table {
border-collapse: collapse;
width: 100%;
margin: 1.5em 0;
font-size: .92em;
font-family: -apple-system, "PingFang SC", sans-serif;
}
#content th, #content td {
border: 1px solid var(--border);
padding: 10px 14px;
text-align: left;
line-height: 1.6;
}
#content th {
background: var(--quote-bg);
font-weight: 600;
white-space: nowrap;
}
#content tr:nth-child(even) { background: rgba(0,0,0,.015); }
@media (prefers-color-scheme: dark) {
#content tr:nth-child(even) { background: rgba(255,255,255,.02); }
}
#content hr {
border: none;
border-top: 1px solid var(--border);
margin: 2.5em 0;
}
#content ul, #content ol {
padding-left: 1.5em;
margin: .7em 0;
}
#content li { margin: .3em 0; }
#content li > ul, #content li > ol { margin: .2em 0; }
#content strong { font-weight: 700; color: var(--text); }
#content em { font-style: italic; }
/* Loading state */
.loading {
text-align: center;
padding: 80px 20px;
color: var(--text-muted);
}
.loading .spinner {
display: inline-block;
width: 28px; height: 28px;
border: 3px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin .7s linear infinite;
margin-bottom: 16px;
}
@keyframes spin { to { transform: rotate(360deg); } }
.loading p { font-family: -apple-system, "PingFang SC", sans-serif; font-size: 14px; }
/* Error */
.error {
text-align: center;
padding: 80px 20px;
color: #ef4444;
font-family: -apple-system, "PingFang SC", sans-serif;
}
.error p { font-size: 14px; margin-top: 8px; color: var(--text-muted); }
/* Responsive */
@media (max-width: 600px) {
#content { padding: 32px 20px 80px; font-size: 16px; }
.top-bar { padding: 10px 16px; }
#content h1 { font-size: 1.6em; }
#content pre { font-size: .85em; }
#content table { font-size: .85em; }
#content th, #content td { padding: 6px 10px; }
.top-bar .doc-title { display: none; }
}
/* Scrollbar */
#content ::-webkit-scrollbar { height: 6px; width: 6px; }
#content ::-webkit-scrollbar-track { background: transparent; }
#content ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
#content ::-webkit-scrollbar-thumb:hover { background: var(--text-muted); }
</style>
</head>
<body>
<div class="top-bar">
<button class="back-btn" onclick="goBack()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M19 12H5"/><path d="M12 19l-7-7 7-7"/></svg>
返回
</button>
<span class="doc-title" id="docTitle"></span>
</div>
<div id="content">
<div class="loading" id="loading">
<div class="spinner"></div>
<p>加载中...</p>
</div>
</div>
<script>
const params = new URLSearchParams(location.search);
const url = params.get('url');
const isMemos = params.get('memos');
const memosUid = params.get('memosUid');
const MEILI_URL = '/search-api/indexes/knowledge/search';
const SEARCH_KEY = '12d3464de5e0b9bb098952f1a1fe99da0b8fe9111737a71eeddb5456278f6c7c';
// Back button: always go back to search
function goBack() {
window.location.href = '/search.html';
}
// Extract a readable title from the URL for the top bar
function guessTitle(u) {
if (!u) return '';
// Remove query params, get last path segment
const segments = u.split('?')[0].split('/');
const last = segments[segments.length - 1] || '';
// Remove .md extension
return last.replace(/\.md$/i, '').replace(/[_-]/g, ' ') || '阅读';
}
if (!url && !memosUid) {
document.getElementById('content').innerHTML = '<div class="error"><div style="font-size:40px;margin-bottom:8px;">📄</div><strong>缺少参数</strong><p>未指定要阅读的内容</p></div>';
} else if (memosUid) {
// Load Memos content from Meilisearch by _memos_uid
document.title = '随手记 · 知识库';
document.getElementById('docTitle').textContent = '随手记';
fetch(MEILI_URL, {
method: 'POST',
headers: { 'Authorization': `Bearer ${SEARCH_KEY}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ filter: `_memos_uid = ${memosUid}`, limit: 1 })
})
.then(r => r.json())
.then(d => {
const h = d.hits && d.hits[0];
if (!h) throw new Error('未找到');
const md = h.content || '';
const html = marked.parse(md, { breaks: true, gfm: true });
document.title = (h.title || '随手记') + ' · 知识库';
document.getElementById('docTitle').textContent = h.title || '随手记';
document.getElementById('loading').style.display = 'none';
document.getElementById('content').innerHTML = html;
})
.catch(err => {
document.getElementById('loading').innerHTML =
'<div class="error"><div style="font-size:40px;margin-bottom:8px;">⚠️</div><strong>' + err.message + '</strong><p>内容加载失败</p></div>';
});
} else {
// Standard Markdown file from gitea-raw proxy
const guessed = guessTitle(url);
document.title = guessed + ' · 知识库';
document.getElementById('docTitle').textContent = guessed;
fetch(url)
.then(r => {
if (!r.ok) throw new Error('加载失败 (' + r.status + ')');
return r.text();
})
.then(raw => {
// Strip YAML frontmatter (--- ... --- block at the start)
let md = raw.replace(/^---[\s\S]*?---\n*/, '');
// Strip auto-generated word-count blockquote (本文共...字)
md = md.replace(/^> 本文共[^\n]*\n*/m, '');
// Strip auto-generated AI analysis headings
md = md.replace(/^## AI 分析(自动生成,请勿编辑)\n*/m, '');
// Strip leading blank lines
md = md.replace(/^\n+/, '');
const html = marked.parse(md, { breaks: true, gfm: true });
document.getElementById('loading').style.display = 'none';
document.getElementById('content').innerHTML = html;
// Downstyle the first blockquote if it's meta info (has 本文共 / 字数 / 阅读)
const firstQuote = document.querySelector('#content blockquote');
if (firstQuote && /本文共|字数|阅读|分钟/.test(firstQuote.textContent)) {
firstQuote.className = 'meta-note';
}
})
.catch(err => {
document.getElementById('loading').innerHTML =
'<div class="error"><div style="font-size:40px;margin-bottom:8px;">⚠️</div><strong>' + err.message + '</strong><p>内容可能已被移动或删除</p></div>';
});
}
</script>
</body>
</html>

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>