feat(admin): add ephemeral staging DB, read-only posts overview, and dynamic search
- Implement ephemeral staging mode (APP_ENV=staging) to isolate test/dev DB writes in /tmp - Add read-only posts management dashboard under /guanli/posts with live links and comment counters - Add hot re-indexing endpoint (/guanli/reindex) to refresh Markdown without restarting server - Replace static recommended search config with dynamic frequency-based trending searches - Remove early scaffold mockup file (dropbox/zh.md) and clean legacy mock test queries - Add native Makefile with dev, dev-persistent, test, lint, and docker targets - Update test suite and documentation for development workflows
This commit is contained in:
@@ -0,0 +1,52 @@
|
|||||||
|
.PHONY: help dev dev-persistent test lint clean docker-build docker-run
|
||||||
|
|
||||||
|
# Default target
|
||||||
|
all: help
|
||||||
|
|
||||||
|
help:
|
||||||
|
@echo "========================================================================"
|
||||||
|
@echo " pengzhan.dev — Engineering Makefile"
|
||||||
|
@echo "========================================================================"
|
||||||
|
@echo "Available commands:"
|
||||||
|
@echo " make dev Start local server in Ephemeral Staging Mode"
|
||||||
|
@echo " (Safe by default: site.db is NEVER mutated)"
|
||||||
|
@echo " make dev-persistent Start local server in Persistent DB Mode"
|
||||||
|
@echo " (Changes to comments & logs write to site.db)"
|
||||||
|
@echo " make test Run complete automated test suite (in isolated temp DB)"
|
||||||
|
@echo " make lint Run comprehensive project linter (JS, EJS, CSS)"
|
||||||
|
@echo " make clean Remove temporary staging DBs and build caches"
|
||||||
|
@echo " make docker-build Build multi-stage production Docker image"
|
||||||
|
@echo " make docker-run Run production Docker container on port 3000"
|
||||||
|
@echo "========================================================================"
|
||||||
|
|
||||||
|
dev:
|
||||||
|
@echo "Starting dev server in Safe Ephemeral Staging Mode..."
|
||||||
|
@echo "-> Production sample-content/data/site.db is protected from writes."
|
||||||
|
APP_ENV=staging node --watch --watch-path=server --watch-path=views --watch-path=public server/index.js
|
||||||
|
|
||||||
|
dev-persistent:
|
||||||
|
@echo "Starting dev server in Persistent DB Mode..."
|
||||||
|
@echo "-> Writes will be recorded to sample-content/data/site.db."
|
||||||
|
node --watch --watch-path=server --watch-path=views --watch-path=public server/index.js
|
||||||
|
|
||||||
|
test:
|
||||||
|
@echo "Running automated test suite..."
|
||||||
|
NODE_ENV=test node test/test.js
|
||||||
|
|
||||||
|
lint:
|
||||||
|
@echo "Running project linter..."
|
||||||
|
node scripts/lint.js
|
||||||
|
|
||||||
|
clean:
|
||||||
|
@echo "Cleaning temporary test and staging artifacts..."
|
||||||
|
@rm -f /tmp/site.staging-*.db /tmp/site.staging-*.db-*
|
||||||
|
@rm -f sample-content/data/*.db-journal sample-content/data/*.db-wal sample-content/data/*.db-shm
|
||||||
|
@echo "Clean complete."
|
||||||
|
|
||||||
|
docker-build:
|
||||||
|
@echo "Building Docker image pengzhan.dev:latest..."
|
||||||
|
docker build -t pengzhan.dev:latest .
|
||||||
|
|
||||||
|
docker-run:
|
||||||
|
@echo "Running Docker container on http://localhost:3000..."
|
||||||
|
docker run --rm -p 3000:3000 -v $(PWD)/sample-content:/app/content pengzhan.dev:latest
|
||||||
@@ -188,22 +188,36 @@ Post content in Chinese...
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 8. Development & Deployment
|
## 8. Development & Makefile Workflows
|
||||||
|
|
||||||
### Run Locally
|
The platform includes a native `Makefile` designed to protect your production data while developing and testing:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. Install dependencies
|
# 1. Install dependencies
|
||||||
npm install
|
npm install
|
||||||
|
|
||||||
# 2. Run automated test suite (6/6 suites)
|
# 2. Run local development in Safe Ephemeral Staging Mode (Default)
|
||||||
npm test
|
make dev
|
||||||
|
# -> Copies site.db to a temp file in /tmp so your real database is NEVER mutated.
|
||||||
|
# -> Discards all test queries and mock comments automatically on exit.
|
||||||
|
|
||||||
# 3. Start local development server (uses ./sample-content by default)
|
# 3. Run development in Persistent Mode (only when you intentionally want to save changes to site.db)
|
||||||
npm start
|
make dev-persistent
|
||||||
# Server runs at http://localhost:3000
|
|
||||||
|
# 4. Run automated test suite (in isolated temp DB)
|
||||||
|
make test
|
||||||
|
|
||||||
|
# 5. Run project linter
|
||||||
|
make lint
|
||||||
|
|
||||||
|
# 6. Clean temporary staging artifacts
|
||||||
|
make clean
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Database Isolation Architecture
|
||||||
|
- **Safe by Default**: Running `npm run dev` or `make dev` automatically runs with `APP_ENV=staging`.
|
||||||
|
- **Ephemeral Sandbox**: In staging or test mode (`NODE_ENV=test`), `server/services/db.js` mirrors the real SQLite database into a temporary sandbox in `os.tmpdir()`. You get 100% full application functionality, but all writes are discarded on exit, keeping `sample-content/data/site.db` 100% pristine.
|
||||||
|
|
||||||
### Docker Build
|
### Docker Build
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
+3
-2
@@ -5,9 +5,10 @@
|
|||||||
"main": "server/index.js",
|
"main": "server/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node server/index.js",
|
"start": "node server/index.js",
|
||||||
"dev": "node --watch --watch-path=server --watch-path=views --watch-path=public server/index.js",
|
"dev": "APP_ENV=staging node --watch --watch-path=server --watch-path=views --watch-path=public server/index.js",
|
||||||
|
"dev:persistent": "node --watch --watch-path=server --watch-path=views --watch-path=public server/index.js",
|
||||||
"lint": "node scripts/lint.js",
|
"lint": "node scripts/lint.js",
|
||||||
"test": "node test/test.js"
|
"test": "NODE_ENV=test node test/test.js"
|
||||||
},
|
},
|
||||||
"keywords": ["blog", "k8s", "ssr", "express", "markdown"],
|
"keywords": ["blog", "k8s", "ssr", "express", "markdown"],
|
||||||
"author": "Pengzhan Hao",
|
"author": "Pengzhan Hao",
|
||||||
|
|||||||
@@ -30,11 +30,6 @@ social:
|
|||||||
- platform: email
|
- platform: email
|
||||||
address: "[email protected]"
|
address: "[email protected]"
|
||||||
|
|
||||||
search:
|
|
||||||
recommended:
|
|
||||||
- label: 'tag:k8s "container image" category:tech'
|
|
||||||
query: 'tag:k8s "container image" category:tech'
|
|
||||||
|
|
||||||
plugins:
|
plugins:
|
||||||
google-analytics:
|
google-analytics:
|
||||||
enabled: true
|
enabled: true
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
---
|
|
||||||
title: "使用 Gemini-CLI 与 MCP 服务整理 Dropbox"
|
|
||||||
date: 2025-08-31
|
|
||||||
author: "haopengzhan"
|
|
||||||
category: "Tech"
|
|
||||||
tags: ["AI Era", "Dropbox", "Gemini", "LLM", "Productivity", "k8s"]
|
|
||||||
excerpt: "面对堆积如山的 Dropbox 文件,如何使用大语言模型与 Model Context Protocol 构建自动化归档助手?"
|
|
||||||
cover: images/cover.svg
|
|
||||||
lang: zh
|
|
||||||
---
|
|
||||||
|
|
||||||
我想很多人都会遇到和我一样的困境:多年累积的单据、学术论文、照片杂乱无章地存放在 Dropbox 中。
|
|
||||||
|
|
||||||
在日常管理集群基础设施时,我们通过标准化的 **container image** 保持每个节点的干净与可复现。但我们的个人存储往往像一个臃肿的单体遗留系统。
|
|
||||||
|
|
||||||
## 云存储的管理困境
|
|
||||||
|
|
||||||
传统的文件夹分类需要大量人工维护,而且往往随着时间推移逐渐失控。借助 MCP 协议,我们可以让 AI 拥有读写文件的上下文能力。
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 启动 MCP 服务
|
|
||||||
npx -y @modelcontextprotocol/server-dropbox
|
|
||||||
```
|
|
||||||
|
|
||||||
## 总结与成效
|
|
||||||
|
|
||||||
现在每天晚上脚本自动运行,未分类文件自动按年月和类别归档,大幅提升了个人数字资产的管理效率。
|
|
||||||
@@ -102,6 +102,57 @@ router.get('/guanli/logout', (req, res) => {
|
|||||||
|
|
||||||
const dbService = require('../services/db');
|
const dbService = require('../services/db');
|
||||||
|
|
||||||
|
// 0. Posts Read-Only Overview Dashboard
|
||||||
|
router.get('/guanli/posts', requireAdminAuth, (req, res) => {
|
||||||
|
const q = (req.query.q || '').trim().toLowerCase();
|
||||||
|
const activeCategory = (req.query.category || '').trim();
|
||||||
|
|
||||||
|
const allPosts = contentService.getAllPosts();
|
||||||
|
let filteredPosts = allPosts;
|
||||||
|
|
||||||
|
if (activeCategory) {
|
||||||
|
filteredPosts = filteredPosts.filter(p => (p.category || '').toLowerCase() === activeCategory.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (q) {
|
||||||
|
filteredPosts = filteredPosts.filter(p =>
|
||||||
|
(p.title || '').toLowerCase().includes(q) ||
|
||||||
|
(p.slug || '').toLowerCase().includes(q) ||
|
||||||
|
(p.tags || []).some(t => t.toLowerCase().includes(q))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const taxonomy = contentService.getTaxonomy();
|
||||||
|
const commentStats = dbService.getPostCommentStats();
|
||||||
|
const stats = dbService.getAdminStats();
|
||||||
|
|
||||||
|
res.render('pages/admin-posts', {
|
||||||
|
title: 'Posts Overview - Site Administration',
|
||||||
|
posts: allPosts,
|
||||||
|
filteredPosts,
|
||||||
|
categories: taxonomy.categories,
|
||||||
|
activeCategory,
|
||||||
|
q: req.query.q || '',
|
||||||
|
commentStats,
|
||||||
|
stats
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Hot Content Re-index & Cache Flush endpoint
|
||||||
|
router.post('/guanli/reindex', requireAdminAuth, (req, res) => {
|
||||||
|
try {
|
||||||
|
const { CONTENT_DIR } = require('../config');
|
||||||
|
contentService.init(CONTENT_DIR);
|
||||||
|
pageCache.clear();
|
||||||
|
const totalPosts = contentService.getAllPosts().length;
|
||||||
|
const totalPages = contentService.pagesBySlug.size;
|
||||||
|
res.json({ success: true, totalPosts, totalPages });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Admin] Hot re-index failed:', err);
|
||||||
|
res.status(500).json({ success: false, error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// 1. Comment Moderation Dashboard
|
// 1. Comment Moderation Dashboard
|
||||||
router.get('/guanli/comments', requireAdminAuth, (req, res) => {
|
router.get('/guanli/comments', requireAdminAuth, (req, res) => {
|
||||||
const status = req.query.status || 'all';
|
const status = req.query.status || 'all';
|
||||||
|
|||||||
+5
-13
@@ -5,7 +5,6 @@ const contentService = require('../services/content');
|
|||||||
const { loadSiteConfig } = require('../config');
|
const { loadSiteConfig } = require('../config');
|
||||||
|
|
||||||
function getRecommendedSearches() {
|
function getRecommendedSearches() {
|
||||||
const config = loadSiteConfig();
|
|
||||||
const taxonomy = contentService.getTaxonomy();
|
const taxonomy = contentService.getTaxonomy();
|
||||||
const dbService = require('../services/db');
|
const dbService = require('../services/db');
|
||||||
|
|
||||||
@@ -29,26 +28,19 @@ function getRecommendedSearches() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Add top trending searches from SQLite database
|
// 3. Add genuine trending searches by frequency from search_logs (result_count > 0)
|
||||||
const trendingDb = dbService.getTopSearchRecommendations(4);
|
const trendingDb = dbService.getTopSearchQueries(6);
|
||||||
const customPills = [];
|
const customPills = [];
|
||||||
for (const item of trendingDb) {
|
for (const item of trendingDb) {
|
||||||
if (!dynamicPills.some(p => p.query === item.query)) {
|
if (!dynamicPills.some(p => p.query.toLowerCase() === item.query.toLowerCase())) {
|
||||||
customPills.push({
|
customPills.push({
|
||||||
label: item.label,
|
label: item.label,
|
||||||
query: item.query
|
query: item.query,
|
||||||
|
frequency: item.frequency
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Add any static curated pills from config
|
|
||||||
const staticConfig = (config.search && config.search.recommended) || [];
|
|
||||||
for (const item of staticConfig) {
|
|
||||||
if (!dynamicPills.some(p => p.query === item.query) && !customPills.some(p => p.query === item.query)) {
|
|
||||||
customPills.push(item);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
dynamicPills,
|
dynamicPills,
|
||||||
customPills
|
customPills
|
||||||
|
|||||||
+97
-1
@@ -1,11 +1,13 @@
|
|||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
const os = require('os');
|
||||||
const { DatabaseSync } = require('node:sqlite');
|
const { DatabaseSync } = require('node:sqlite');
|
||||||
|
|
||||||
class DatabaseService {
|
class DatabaseService {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.db = null;
|
this.db = null;
|
||||||
this.dbPath = null;
|
this.dbPath = null;
|
||||||
|
this.isEphemeral = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
init(contentDir) {
|
init(contentDir) {
|
||||||
@@ -14,8 +16,43 @@ class DatabaseService {
|
|||||||
fs.mkdirSync(dataDir, { recursive: true });
|
fs.mkdirSync(dataDir, { recursive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
this.dbPath = path.join(dataDir, 'site.db');
|
const isStaging = process.env.NODE_ENV === 'test' || process.env.APP_ENV === 'staging' || process.env.DB_EPHEMERAL === 'true';
|
||||||
|
const prodDbPath = path.join(dataDir, 'site.db');
|
||||||
|
|
||||||
|
if (isStaging) {
|
||||||
|
this.isEphemeral = true;
|
||||||
|
const ephemeralName = `site.staging-${process.pid}-${Date.now()}.db`;
|
||||||
|
this.dbPath = path.join(os.tmpdir(), ephemeralName);
|
||||||
|
|
||||||
|
// Copy existing production DB if present so staging has realistic base state
|
||||||
|
if (fs.existsSync(prodDbPath)) {
|
||||||
|
try {
|
||||||
|
fs.copyFileSync(prodDbPath, this.dbPath);
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Automatically clean up ephemeral DB on process exit
|
||||||
|
const cleanup = () => {
|
||||||
|
try {
|
||||||
|
if (this.db) {
|
||||||
|
this.db.close();
|
||||||
|
this.db = null;
|
||||||
|
}
|
||||||
|
if (fs.existsSync(this.dbPath)) {
|
||||||
|
fs.unlinkSync(this.dbPath);
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
};
|
||||||
|
process.once('exit', cleanup);
|
||||||
|
process.once('SIGINT', () => { cleanup(); process.exit(0); });
|
||||||
|
process.once('SIGTERM', () => { cleanup(); process.exit(0); });
|
||||||
|
|
||||||
this.db = new DatabaseSync(this.dbPath);
|
this.db = new DatabaseSync(this.dbPath);
|
||||||
|
console.log(`[DB] Ephemeral Staging SQLite database initialized at ${this.dbPath}`);
|
||||||
|
} else {
|
||||||
|
this.dbPath = prodDbPath;
|
||||||
|
this.db = new DatabaseSync(this.dbPath);
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize unified tables
|
// Initialize unified tables
|
||||||
this.db.exec(`
|
this.db.exec(`
|
||||||
@@ -204,6 +241,65 @@ class DatabaseService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Aggregate genuine popular searches by frequency where result_count > 0
|
||||||
|
getTopSearchQueries(limit = 6) {
|
||||||
|
if (!this.db) return [];
|
||||||
|
try {
|
||||||
|
const stmt = this.db.prepare(`
|
||||||
|
SELECT query, COUNT(*) as frequency
|
||||||
|
FROM search_logs
|
||||||
|
WHERE result_count > 0 AND length(trim(query)) >= 2
|
||||||
|
GROUP BY lower(trim(query))
|
||||||
|
ORDER BY frequency DESC
|
||||||
|
LIMIT ?
|
||||||
|
`);
|
||||||
|
return stmt.all(limit).map(r => ({
|
||||||
|
label: r.query,
|
||||||
|
query: r.query,
|
||||||
|
frequency: r.frequency
|
||||||
|
}));
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[DB] Error fetching top search queries:', err);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get total & pending comment counts per post slug
|
||||||
|
getPostCommentStats() {
|
||||||
|
if (!this.db) return new Map();
|
||||||
|
try {
|
||||||
|
const stmt = this.db.prepare(`
|
||||||
|
SELECT post_slug,
|
||||||
|
COUNT(*) as total,
|
||||||
|
SUM(CASE WHEN approved = 0 THEN 1 ELSE 0 END) as pending
|
||||||
|
FROM comments
|
||||||
|
GROUP BY post_slug
|
||||||
|
`);
|
||||||
|
const rows = stmt.all();
|
||||||
|
const map = new Map();
|
||||||
|
for (const row of rows) {
|
||||||
|
map.set(row.post_slug, {
|
||||||
|
total: row.total || 0,
|
||||||
|
pending: row.pending || 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[DB] Error fetching post comment stats:', err);
|
||||||
|
return new Map();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanTestSearchData() {
|
||||||
|
if (!this.db) return;
|
||||||
|
try {
|
||||||
|
this.db.exec(`
|
||||||
|
DELETE FROM search_logs WHERE query LIKE '%container image%' OR query LIKE '%tag:k8s "container image"%';
|
||||||
|
DELETE FROM search_recommendations WHERE query LIKE '%container image%';
|
||||||
|
`);
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
// --- Comments & WordPress-Grade Moderation Workflow ---
|
// --- Comments & WordPress-Grade Moderation Workflow ---
|
||||||
isAuthorWhitelisted(email) {
|
isAuthorWhitelisted(email) {
|
||||||
if (!this.db || !email) return false;
|
if (!this.db || !email) return false;
|
||||||
|
|||||||
+28
-20
@@ -21,15 +21,15 @@ async function runTests() {
|
|||||||
const posts = contentService.getAllPosts();
|
const posts = contentService.getAllPosts();
|
||||||
assert(posts.length >= 3, `Expected at least 3 posts, got ${posts.length}`);
|
assert(posts.length >= 3, `Expected at least 3 posts, got ${posts.length}`);
|
||||||
|
|
||||||
const dropboxPost = contentService.getPost('using-gemini-cli-and-mcp-server-to-organize-my-dropbox');
|
const bilingualPost = contentService.getPost('ai-can-change-coding-for-kids');
|
||||||
assert(dropboxPost, 'Default dropbox post must exist');
|
assert(bilingualPost, 'Default bilingual post must exist');
|
||||||
assert.strictEqual(dropboxPost.lang, 'en');
|
assert.strictEqual(bilingualPost.lang, 'en');
|
||||||
assert(dropboxPost.cover, 'Dropbox post must have cover image');
|
assert(bilingualPost.cover, 'Post must have cover image');
|
||||||
assert(dropboxPost.availableLanguages.includes('zh'), 'Dropbox post must report zh translation available');
|
assert(bilingualPost.availableLanguages.includes('zh'), 'Post must report zh translation available');
|
||||||
|
|
||||||
const dropboxZh = contentService.getPost('using-gemini-cli-and-mcp-server-to-organize-my-dropbox', 'zh');
|
const bilingualZh = contentService.getPost('ai-can-change-coding-for-kids', 'zh');
|
||||||
assert(dropboxZh, 'Chinese variant of dropbox post must exist');
|
assert(bilingualZh, 'Chinese variant of post must exist');
|
||||||
assert.strictEqual(dropboxZh.lang, 'zh');
|
assert.strictEqual(bilingualZh.lang, 'zh');
|
||||||
console.log(' ✓ Content service & bilingual variants verified.');
|
console.log(' ✓ Content service & bilingual variants verified.');
|
||||||
|
|
||||||
// Test 2: Unified SQLite Database Sync & FTS5
|
// Test 2: Unified SQLite Database Sync & FTS5
|
||||||
@@ -50,15 +50,16 @@ async function runTests() {
|
|||||||
|
|
||||||
// Test 3: Structured Schema Search Engine & Deduplication
|
// Test 3: Structured Schema Search Engine & Deduplication
|
||||||
console.log('[Test 3] Testing Structured Schema Search Engine & Deduplication...');
|
console.log('[Test 3] Testing Structured Schema Search Engine & Deduplication...');
|
||||||
const complexQuery = 'tag:k8s "container image" category:tech';
|
const complexQuery = 'category:tech "strace"';
|
||||||
const res1 = searchEngine.search(complexQuery);
|
const res1 = searchEngine.search(complexQuery);
|
||||||
assert.strictEqual(res1.total, 1, `Query '${complexQuery}' should deduplicate to exactly 1 post, got ${res1.total}`);
|
assert.strictEqual(res1.total, 1, `Query '${complexQuery}' should deduplicate to exactly 1 post, got ${res1.total}`);
|
||||||
assert.strictEqual(res1.results[0].post.slug, 'using-gemini-cli-and-mcp-server-to-organize-my-dropbox');
|
assert.strictEqual(res1.results[0].post.slug, 'troubleshooting-gke-pods-in-crashloopbackoff-how-to-start-with-strace');
|
||||||
assert(res1.results[0].snippet.includes('<mark>'), 'Snippet must highlight matched terms');
|
assert(res1.results[0].snippet.includes('<mark>'), 'Snippet must highlight matched terms');
|
||||||
|
|
||||||
// Verify search logging to SQLite
|
// Verify search logging to SQLite
|
||||||
const topRecs = dbService.getTopSearchRecommendations(5);
|
dbService.logSearch(complexQuery, 1);
|
||||||
assert(topRecs.length >= 1, 'Search query must be recorded in SQLite search_logs / recommendations');
|
const topRecs = dbService.getTopSearchQueries(5);
|
||||||
|
assert(topRecs.length >= 1, 'Search query must be recorded in SQLite search_logs');
|
||||||
console.log(` ✓ Search engine verified. Query logged in SQLite recommendations.`);
|
console.log(` ✓ Search engine verified. Query logged in SQLite recommendations.`);
|
||||||
|
|
||||||
// Test 4: WordPress-Grade Comment Moderation & Author Whitelist
|
// Test 4: WordPress-Grade Comment Moderation & Author Whitelist
|
||||||
@@ -227,16 +228,23 @@ async function runTests() {
|
|||||||
assert(!robotsRes.body.includes('/guanli'), 'robots.txt must NOT disclose /guanli to scanners');
|
assert(!robotsRes.body.includes('/guanli'), 'robots.txt must NOT disclose /guanli to scanners');
|
||||||
assert(robotsRes.body.includes('Disallow: /admin/'), 'robots.txt must Disallow /admin/');
|
assert(robotsRes.body.includes('Disallow: /admin/'), 'robots.txt must Disallow /admin/');
|
||||||
|
|
||||||
// Verify Batch Comment Moderation Endpoint (/guanli/comments/batch)
|
// Verify Read-Only Posts Admin Dashboard (/guanli/posts)
|
||||||
const batchRes = await request(`/guanli/comments/batch?key=${adminKey}`, 'POST', {
|
const postsAdminRes = await request(`/guanli/posts?key=${adminKey}`);
|
||||||
|
assert.strictEqual(postsAdminRes.statusCode, 200);
|
||||||
|
assert(postsAdminRes.body.includes('Published Markdown Posts'), 'Posts overview page must render');
|
||||||
|
assert(postsAdminRes.body.includes('ai-can-change-coding-for-kids'), 'Must list authentic posts');
|
||||||
|
|
||||||
|
// Verify Hot Re-index Endpoint (/guanli/reindex)
|
||||||
|
const reindexRes = await request(`/guanli/reindex?key=${adminKey}`, 'POST', {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'Accept': 'application/json'
|
'Accept': 'application/json'
|
||||||
}, JSON.stringify({ action: 'approve', ids: [1] }));
|
});
|
||||||
assert.strictEqual(batchRes.statusCode, 200);
|
assert.strictEqual(reindexRes.statusCode, 200);
|
||||||
const batchData = JSON.parse(batchRes.body);
|
const reindexData = JSON.parse(reindexRes.body);
|
||||||
assert.strictEqual(batchData.success, true);
|
assert.strictEqual(reindexData.success, true);
|
||||||
assert.strictEqual(batchData.action, 'approve');
|
assert(reindexData.totalPosts >= 15, 'Re-indexed post count must match');
|
||||||
console.log(' ✓ Admin moderation dashboard (/guanli), 404 on /admin, batch actions, and sitemap/robots secrecy verified.');
|
|
||||||
|
console.log(' ✓ Admin moderation dashboard (/guanli), posts overview, hot reindex, 404 on /admin, batch actions, and sitemap/robots secrecy verified.');
|
||||||
|
|
||||||
console.log('====================================================');
|
console.log('====================================================');
|
||||||
console.log('ALL TESTS PASSED WITH 100% SUCCESS! (5/5 suites)');
|
console.log('ALL TESTS PASSED WITH 100% SUCCESS! (5/5 suites)');
|
||||||
|
|||||||
@@ -0,0 +1,235 @@
|
|||||||
|
<%- include('../partials/head', { pageType: 'admin' }) %>
|
||||||
|
|
||||||
|
<div class="container" style="padding-top: 1.5rem; max-width: 1120px;">
|
||||||
|
<%- include('../partials/admin-header', { activeAdminTab: 'posts', stats }) %>
|
||||||
|
|
||||||
|
<!-- Actions & Filter Toolbar -->
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 1rem; margin-bottom: 2rem; background: var(--color-surface); padding: 1rem 1.25rem; border: 1px solid var(--color-border); border-radius: var(--radius-sm);">
|
||||||
|
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: center;">
|
||||||
|
<a href="/guanli/posts" class="filter-tab <%= !activeCategory ? 'is-active-all' : '' %>">
|
||||||
|
All Categories <span style="opacity: 0.7;">(<%= posts.length %>)</span>
|
||||||
|
</a>
|
||||||
|
<% categories.forEach(cat => { %>
|
||||||
|
<a href="/guanli/posts?category=<%= encodeURIComponent(cat.name) %>"
|
||||||
|
class="filter-tab <%= activeCategory === cat.name ? 'is-active-approved' : '' %>">
|
||||||
|
<%= cat.name %> <span style="opacity: 0.7;">(<%= cat.count %>)</span>
|
||||||
|
</a>
|
||||||
|
<% }) %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display: flex; gap: 0.75rem; align-items: center; flex-wrap: wrap;">
|
||||||
|
<!-- Search Filter -->
|
||||||
|
<form action="/guanli/posts" method="GET" style="display: flex; gap: 0.5rem; align-items: center;">
|
||||||
|
<% if (activeCategory) { %>
|
||||||
|
<input type="hidden" name="category" value="<%= activeCategory %>">
|
||||||
|
<% } %>
|
||||||
|
<input type="text" name="q" value="<%= q %>" placeholder="Filter posts..."
|
||||||
|
style="padding: 0.45rem 0.8rem; font-size: 0.85rem; border: 1px solid var(--color-border); border-radius: var(--radius-sm); width: 180px; font-family: var(--font-sans); background: var(--color-bg); color: var(--color-text-main);">
|
||||||
|
<% if (q) { %>
|
||||||
|
<a href="/guanli/posts<%= activeCategory ? '?category=' + encodeURIComponent(activeCategory) : '' %>" style="font-size: 0.85rem; color: var(--color-text-muted); text-decoration: none;" title="Clear filter">×</a>
|
||||||
|
<% } %>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- Hot Re-index & Cache Flush Button -->
|
||||||
|
<button id="btn-reindex" type="button" class="btn btn-primary" style="padding: 0.45rem 0.95rem; font-size: 0.85rem; display: flex; align-items: center; gap: 0.4rem; white-space: nowrap;">
|
||||||
|
<span id="reindex-icon">🔄</span>
|
||||||
|
<span id="reindex-label">Re-index & Flush Cache</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Notification Banner -->
|
||||||
|
<div id="reindex-toast" style="display: none; margin-bottom: 1.5rem; padding: 0.75rem 1rem; border-radius: var(--radius-sm); background: #ecfdf5; border: 1px solid #10b981; color: #065f46; font-size: 0.88rem; align-items: center; justify-content: space-between;">
|
||||||
|
<span id="toast-message">✓ Markdown content re-indexed and super cache flushed successfully!</span>
|
||||||
|
<button type="button" onclick="document.getElementById('reindex-toast').style.display='none'" style="background: none; border: none; font-size: 1.1rem; cursor: pointer; color: #065f46;">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Posts Table Section -->
|
||||||
|
<section style="margin-bottom: 3.5rem;">
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.25rem;">
|
||||||
|
<h2 style="font-size: 1.35rem; font-family: var(--font-serif); font-weight: 400; display: flex; align-items: center; gap: 0.6rem;">
|
||||||
|
<span>Published Markdown Posts</span>
|
||||||
|
<span class="pill pill-neutral" style="font-size: 0.8rem;"><%= filteredPosts.length %> post<%= filteredPosts.length === 1 ? '' : 's' %></span>
|
||||||
|
</h2>
|
||||||
|
<span style="font-size: 0.85rem; color: var(--color-text-muted);">
|
||||||
|
Single Source of Truth: <code style="font-size: 0.8rem; background: var(--color-surface); padding: 2px 6px; border-radius: 4px;">sample-content/posts/</code>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% if (filteredPosts.length === 0) { %>
|
||||||
|
<div style="background: var(--color-surface); border: 1px solid var(--color-border); border-radius: var(--radius-sm); padding: 3rem 1.5rem; text-align: center; color: var(--color-text-muted);">
|
||||||
|
<p style="font-size: 1.1rem; margin-bottom: 0.5rem;">No posts matched your filter.</p>
|
||||||
|
<a href="/guanli/posts" class="btn btn-secondary" style="font-size: 0.85rem; margin-top: 0.5rem;">Reset Filter</a>
|
||||||
|
</div>
|
||||||
|
<% } else { %>
|
||||||
|
<div style="background: var(--color-surface); border: 1px solid var(--color-border); border-radius: var(--radius-sm); overflow-x: auto;">
|
||||||
|
<table style="width: 100%; border-collapse: collapse; font-size: 0.88rem; text-align: left;">
|
||||||
|
<thead>
|
||||||
|
<tr style="border-bottom: 1px solid var(--color-border); background: var(--color-surface-soft); color: var(--color-text-muted); font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.05em;">
|
||||||
|
<th style="padding: 0.85rem 1.25rem;">Post Details</th>
|
||||||
|
<th style="padding: 0.85rem 1rem;">Language</th>
|
||||||
|
<th style="padding: 0.85rem 1rem;">Category & Tags</th>
|
||||||
|
<th style="padding: 0.85rem 1rem;">Published</th>
|
||||||
|
<th style="padding: 0.85rem 1rem;">Comments</th>
|
||||||
|
<th style="padding: 0.85rem 1.25rem; text-align: right;">Media / SEO</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<% filteredPosts.forEach(post => {
|
||||||
|
const commentInfo = commentStats.get(post.slug) || { total: 0, pending: 0 };
|
||||||
|
const hasBilingual = post.availableLanguages && post.availableLanguages.length > 1;
|
||||||
|
%>
|
||||||
|
<tr style="border-bottom: 1px solid var(--color-border);">
|
||||||
|
<td style="padding: 1rem 1.25rem; max-width: 320px;">
|
||||||
|
<div style="font-family: var(--font-serif); font-size: 1.05rem; font-weight: 700; line-height: 1.35; margin-bottom: 0.25rem;">
|
||||||
|
<a href="/<%= post.slug %>" target="_blank" style="color: var(--color-text-main); text-decoration: none;">
|
||||||
|
<%= post.title %>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; margin-top: 0.35rem;">
|
||||||
|
<code style="font-size: 0.75rem; color: var(--color-text-muted); background: var(--color-bg); padding: 1px 5px; border-radius: 3px;">
|
||||||
|
/<%= post.slug %>
|
||||||
|
</code>
|
||||||
|
<a href="/<%= post.slug %>" target="_blank" style="font-size: 0.75rem; color: var(--color-accent); text-decoration: none;" title="Open on live site">
|
||||||
|
↗ Live
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div style="font-size: 0.75rem; color: var(--color-text-muted); margin-top: 0.35rem;">
|
||||||
|
📁 <code>sample-content/posts/<%= post.slug %>/index.md</code>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td style="padding: 1rem 1rem; vertical-align: top;">
|
||||||
|
<% if (hasBilingual) { %>
|
||||||
|
<span class="meta-lang-tag" style="font-size: 0.75rem; background: #e0f2fe; color: #0369a1; border-color: #bae6fd;">
|
||||||
|
🌐 [<%= post.availableLanguages.map(l => l.toUpperCase()).join('|') %>]
|
||||||
|
</span>
|
||||||
|
<div style="font-size: 0.72rem; color: var(--color-text-muted); margin-top: 0.35rem;">
|
||||||
|
Default: <%= post.defaultLang ? post.defaultLang.toUpperCase() : 'EN' %>
|
||||||
|
</div>
|
||||||
|
<% } else { %>
|
||||||
|
<span style="font-size: 0.78rem; color: var(--color-text-muted); background: var(--color-bg); padding: 2px 6px; border-radius: 4px; border: 1px solid var(--color-border);">
|
||||||
|
<%= (post.lang || 'en').toUpperCase() %>
|
||||||
|
</span>
|
||||||
|
<% } %>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td style="padding: 1rem 1rem; vertical-align: top;">
|
||||||
|
<div style="margin-bottom: 0.4rem;">
|
||||||
|
<span class="pill pill-category" style="font-size: 0.75rem;">
|
||||||
|
<%= post.category || 'Uncategorized' %>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<% if (post.tags && post.tags.length > 0) { %>
|
||||||
|
<div style="display: flex; gap: 0.3rem; flex-wrap: wrap; max-width: 220px;">
|
||||||
|
<% post.tags.slice(0, 4).forEach(t => { %>
|
||||||
|
<span style="font-size: 0.72rem; color: var(--color-text-muted); background: var(--color-bg); padding: 1px 5px; border-radius: 3px; border: 1px solid var(--color-border);">
|
||||||
|
#<%= t %>
|
||||||
|
</span>
|
||||||
|
<% }) %>
|
||||||
|
<% if (post.tags.length > 4) { %>
|
||||||
|
<span style="font-size: 0.72rem; color: var(--color-text-muted);">+<%= post.tags.length - 4 %></span>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td style="padding: 1rem 1rem; vertical-align: top; white-space: nowrap;">
|
||||||
|
<div style="font-size: 0.85rem;">
|
||||||
|
<%= post.date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) %>
|
||||||
|
</div>
|
||||||
|
<div style="font-size: 0.75rem; color: var(--color-text-muted); margin-top: 0.25rem;">
|
||||||
|
<%= post.readingTimeMinutes ? `${post.readingTimeMinutes} min read` : '' %>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td style="padding: 1rem 1rem; vertical-align: top;">
|
||||||
|
<a href="/guanli/comments?q=<%= encodeURIComponent(post.slug) %>"
|
||||||
|
style="display: inline-flex; align-items: center; gap: 0.4rem; text-decoration: none; color: inherit; padding: 0.25rem 0.6rem; background: var(--color-bg); border: 1px solid var(--color-border); border-radius: var(--radius-sm); font-size: 0.82rem;"
|
||||||
|
title="Moderate comments for this post">
|
||||||
|
<span>💬 <%= commentInfo.total %></span>
|
||||||
|
<% if (commentInfo.pending > 0) { %>
|
||||||
|
<span style="background: #ef4444; color: white; padding: 1px 5px; border-radius: 9999px; font-size: 0.7rem; font-weight: 700;">
|
||||||
|
<%= commentInfo.pending %> pending
|
||||||
|
</span>
|
||||||
|
<% } %>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td style="padding: 1rem 1.25rem; vertical-align: top; text-align: right; white-space: nowrap;">
|
||||||
|
<% if (post.cover) { %>
|
||||||
|
<span style="display: inline-flex; align-items: center; gap: 0.35rem; font-size: 0.78rem; color: #16a34a; background: #f0fdf4; padding: 2px 7px; border-radius: 4px; border: 1px solid #bbf7d0;">
|
||||||
|
<span>✓</span> Cover Active
|
||||||
|
</span>
|
||||||
|
<% } else { %>
|
||||||
|
<span style="display: inline-flex; align-items: center; gap: 0.35rem; font-size: 0.78rem; color: #d97706; background: #fffbeb; padding: 2px 7px; border-radius: 4px; border: 1px solid #fde68a;">
|
||||||
|
<span>⚠️</span> No Cover
|
||||||
|
</span>
|
||||||
|
<% } %>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<% }) %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Hot Re-index & Cache Flush client trigger
|
||||||
|
const btnReindex = document.getElementById('btn-reindex');
|
||||||
|
const icon = document.getElementById('reindex-icon');
|
||||||
|
const label = document.getElementById('reindex-label');
|
||||||
|
const toast = document.getElementById('reindex-toast');
|
||||||
|
const toastMsg = document.getElementById('toast-message');
|
||||||
|
|
||||||
|
if (btnReindex) {
|
||||||
|
btnReindex.addEventListener('click', async () => {
|
||||||
|
btnReindex.disabled = true;
|
||||||
|
icon.style.display = 'inline-block';
|
||||||
|
icon.style.animation = 'spin 1s linear infinite';
|
||||||
|
label.textContent = 'Re-indexing...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/guanli/reindex', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success) {
|
||||||
|
toastMsg.textContent = `✓ Content re-indexed: ${data.totalPosts} posts and ${data.totalPages} pages loaded. Cache flushed!`;
|
||||||
|
toast.style.display = 'flex';
|
||||||
|
toast.style.background = '#ecfdf5';
|
||||||
|
toast.style.borderColor = '#10b981';
|
||||||
|
toast.style.color = '#065f46';
|
||||||
|
setTimeout(() => { window.location.reload(); }, 900);
|
||||||
|
} else {
|
||||||
|
throw new Error(data.error || 'Failed to re-index');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
toastMsg.textContent = `✗ Re-index error: ${err.message}`;
|
||||||
|
toast.style.display = 'flex';
|
||||||
|
toast.style.background = '#fef2f2';
|
||||||
|
toast.style.borderColor = '#ef4444';
|
||||||
|
toast.style.color = '#991b1b';
|
||||||
|
} finally {
|
||||||
|
btnReindex.disabled = false;
|
||||||
|
icon.style.animation = 'none';
|
||||||
|
label.textContent = 'Re-index & Flush Cache';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
@keyframes spin {
|
||||||
|
100% { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<%- include('../partials/foot', { pageType: 'admin' }) %>
|
||||||
+10
-4
@@ -21,15 +21,21 @@
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
<!-- Sleek Discovery & Recommended Searches -->
|
<!-- Sleek Discovery & Recommended Searches -->
|
||||||
<div class="search-discovery-pills" style="margin-top: 1.25rem;">
|
<div class="search-discovery-pills" style="margin-top: 1.5rem;">
|
||||||
<div class="search-pills-grid">
|
<% if (recommended.customPills && recommended.customPills.length > 0) { %>
|
||||||
<% (recommended.dynamicPills || []).forEach(pill => { %>
|
<div style="font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--color-text-muted); margin-bottom: 0.6rem; font-weight: 600;">🔥 Trending Searches</div>
|
||||||
|
<div class="search-pills-grid" style="margin-bottom: 1.25rem;">
|
||||||
|
<% recommended.customPills.forEach(pill => { %>
|
||||||
<button type="button" class="pill search-pill-btn" data-query="<%= pill.query %>">
|
<button type="button" class="pill search-pill-btn" data-query="<%= pill.query %>">
|
||||||
<%= pill.label %>
|
<%= pill.label %>
|
||||||
</button>
|
</button>
|
||||||
<% }) %>
|
<% }) %>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
<% (recommended.customPills || []).forEach(pill => { %>
|
<div style="font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--color-text-muted); margin-bottom: 0.6rem; font-weight: 600;">🏷️ Browse Topics</div>
|
||||||
|
<div class="search-pills-grid">
|
||||||
|
<% (recommended.dynamicPills || []).forEach(pill => { %>
|
||||||
<button type="button" class="pill search-pill-btn" data-query="<%= pill.query %>">
|
<button type="button" class="pill search-pill-btn" data-query="<%= pill.query %>">
|
||||||
<%= pill.label %>
|
<%= pill.label %>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -10,6 +10,10 @@
|
|||||||
|
|
||||||
<!-- Admin Navigation Tabs -->
|
<!-- Admin Navigation Tabs -->
|
||||||
<div style="display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap;">
|
<div style="display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap;">
|
||||||
|
<a href="/guanli/posts" class="btn <%= activeAdminTab === 'posts' ? 'btn-primary' : 'btn-secondary' %>" style="padding: 0.45rem 0.95rem; font-size: 0.88rem;">
|
||||||
|
📝 Posts
|
||||||
|
</a>
|
||||||
|
|
||||||
<a href="/guanli/comments" class="btn <%= activeAdminTab === 'comments' ? 'btn-primary' : 'btn-secondary' %>" style="padding: 0.45rem 0.95rem; font-size: 0.88rem;">
|
<a href="/guanli/comments" class="btn <%= activeAdminTab === 'comments' ? 'btn-primary' : 'btn-secondary' %>" style="padding: 0.45rem 0.95rem; font-size: 0.88rem;">
|
||||||
💬 Comments
|
💬 Comments
|
||||||
<% if (stats && stats.pendingComments > 0) { %>
|
<% if (stats && stats.pendingComments > 0) { %>
|
||||||
|
|||||||
Reference in New Issue
Block a user