snapshot: 知识库搜索服务完整部署快照(含search.html/reader.html/README)
This commit is contained in:
51
docs/knowledge-base/README.md
Normal file
51
docs/knowledge-base/README.md
Normal 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/ → 反代到北京 7700(Meilisearch API)
|
||||||
|
└── /gitea-raw/ → 反代到 Tokyo Gitea(raw 文件 + 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)
|
||||||
365
docs/knowledge-base/reader.html
Normal file
365
docs/knowledge-base/reader.html
Normal 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>
|
||||||
767
docs/knowledge-base/search.html
Normal file
767
docs/knowledge-base/search.html
Normal 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>
|
||||||
Reference in New Issue
Block a user