feat: 新增知资札记工具及相关功能

1. 新增知资札记完整站点:包含首页、详情页、管理后台
2. 添加站点图标、鉴权配置与后端API鉴权逻辑
3. 在市场页面新增知资札记入口跳转
4. 新增经典乡村音乐播放列表文件
5. 临时添加养生谚语批量导入脚本
This commit is contained in:
yangxiangyuan
2026-07-19 20:45:41 +08:00
parent 6a043687f5
commit 421a8aa1f7
20 changed files with 3752 additions and 14 deletions
+165
View File
@@ -613,6 +613,170 @@ app.use('/api/fitness_diary', (req, res, next) => {
ensureFitnessDiaryLoaded()
// ── 知资札记 鉴权 ──
const ZHIZI_NOTES_AUTH_COOKIE = 'zhizi_notes_gate'
const readZhiziNotesAuthConfig = () => {
try {
const p = path.join(process.cwd(), 'public', 'tools', 'zhizi_notes', 'auth_config.json')
if (fs.existsSync(p)) {
const cfg = JSON.parse(fs.readFileSync(p, 'utf-8'))
if (cfg && typeof cfg === 'object') return cfg
}
} catch {}
return {}
}
const readZhiziNotesJwk = () => {
try {
const p = path.join(process.cwd(), 'config', 'zhizi_notes.jwk.json')
if (fs.existsSync(p)) return JSON.parse(fs.readFileSync(p, 'utf-8'))
} catch {}
return { kty: 'RSA', n: '', e: '' }
}
const getZhiziNotesCookieName = () => {
try {
const cfg = readZhiziNotesAuthConfig()
const n = String(cfg.cookieName || '')
if (n) return n
} catch {}
return ZHIZI_NOTES_AUTH_COOKIE
}
const getZhiziNotesMaxAge = () => {
try {
const cfg = readZhiziNotesAuthConfig()
const days = Number(cfg.max_age_days || 0)
if (Number.isFinite(days) && days > 0) return Math.min(days, 365) * 24 * 3600 * 1000
} catch {}
return 30 * 24 * 3600 * 1000
}
const getZhiziNotesGuidConfig = () => {
try {
const cfg = readZhiziNotesAuthConfig()
const keyRaw = String(cfg.guidParam || cfg.guid_param || 'guid').trim()
const valueRaw = String(cfg.guidValue || cfg.guid_value || '').trim()
return { key: keyRaw || 'guid', value: valueRaw }
} catch {
return { key: 'guid', value: '' }
}
}
const hasValidZhiziNotesGuid = (req) => {
try {
const cfg = readZhiziNotesAuthConfig()
if (cfg.enable_auth === false) return false
const { key, value } = getZhiziNotesGuidConfig()
if (!value) return false
const q = req && req.query ? req.query : {}
const raw = q[key]
if (raw === undefined || raw === null) return false
const incoming = Array.isArray(raw) ? String(raw[0] || '') : String(raw)
return incoming === value
} catch { return false }
}
const isZhiziNotesForceGuid = () => {
try {
const cfg = readZhiziNotesAuthConfig()
if (cfg.enable_auth === false) return false
if (Object.prototype.hasOwnProperty.call(cfg, 'requireGuid')) return !!cfg.requireGuid
if (Object.prototype.hasOwnProperty.call(cfg, 'require_guid')) return !!cfg.require_guid
return false
} catch { return false }
}
const hasZhiziNotesAuth = (req) => {
try {
const config = readZhiziNotesAuthConfig()
if (config.enable_auth === false) return true
const cookies = parseCookie(req.headers.cookie || '')
if (cookies[getZhiziNotesCookieName()] === '1') return true
return false
} catch { return false }
}
// 知资札记 入口守卫
app.get('/tools/zhizi_notes', (req, res, next) => {
setNoCache(res)
try {
const forceGuid = isZhiziNotesForceGuid()
if (hasZhiziNotesAuth(req)) return next()
const guidKey = getZhiziNotesGuidConfig().key
const hasGuidInput = req && req.query && Object.prototype.hasOwnProperty.call(req.query, guidKey)
if (hasValidZhiziNotesGuid(req)) {
const maxAge = getZhiziNotesMaxAge()
const cookieName = getZhiziNotesCookieName()
res.cookie(cookieName, '1', { httpOnly: true, sameSite: 'lax', maxAge, path: '/' })
return res.redirect('/tools/zhizi_notes/index.html')
}
if (hasGuidInput) return res.status(401).send('未授权')
const token = String(req.query.token || '')
if (!token) {
if (forceGuid) return res.status(401).send('未授权')
return next()
}
const payload = verifyJwtWithKeys(token, jwkKeys(readZhiziNotesJwk()))
if (!payload) return res.status(401).send('未授权')
{
const iss = String(payload.iss || '')
const navIss = getNavIssFromFlags()
if (iss === navIss && !audMatch(payload, 'Tools-zhizi_notes')) return res.status(401).send('未授权')
}
const maxAge = getZhiziNotesMaxAge()
const cookieName = getZhiziNotesCookieName()
res.cookie(cookieName, '1', { httpOnly: true, sameSite: 'lax', maxAge, path: '/' })
return res.redirect('/tools/zhizi_notes/index.html')
} catch (e) { return res.status(401).send('未授权') }
})
// 知资札记 子路径守卫
app.use('/tools/zhizi_notes', (req, res, next) => {
const forceGuid = isZhiziNotesForceGuid()
if (hasZhiziNotesAuth(req)) return next()
const isHandshake = req.method === 'GET' && (req.path === '/' || req.path === '' || req.path === '/index.html')
const guidKey = getZhiziNotesGuidConfig().key
const hasGuidInput = isHandshake && req && req.query && Object.prototype.hasOwnProperty.call(req.query, guidKey)
if (isHandshake && hasValidZhiziNotesGuid(req)) {
const maxAge = getZhiziNotesMaxAge()
const cookieName = getZhiziNotesCookieName()
res.cookie(cookieName, '1', { httpOnly: true, sameSite: 'lax', maxAge, path: '/' })
return res.redirect('/tools/zhizi_notes/index.html')
}
if (hasGuidInput) return res.status(401).send('未授权')
if (isHandshake && String(req.query.token || '')) {
const payload = verifyJwtWithKeys(String(req.query.token), jwkKeys(readZhiziNotesJwk()))
if (payload) {
const iss = String(payload.iss || '')
const navIss = getNavIssFromFlags()
if (iss === navIss && !audMatch(payload, 'Tools-zhizi_notes')) return res.status(401).send('未授权')
const maxAge = getZhiziNotesMaxAge()
const cookieName = getZhiziNotesCookieName()
res.cookie(cookieName, '1', { httpOnly: true, sameSite: 'lax', maxAge, path: '/' })
return res.redirect('/tools/zhizi_notes/index.html')
}
}
if (isHandshake && forceGuid) return res.status(401).send('未授权')
return res.status(401).send('未授权')
})
// 知资札记 API 鉴权
app.use('/api/zhizi_notes', (req, res, next) => {
if (hasZhiziNotesAuth(req)) return next()
return res.status(401).json({ ok: false, error: 'unauthorized' })
})
const ensureZhiziNotesLoaded = () => {
let mod = null
try { mod = require('./zhizi_notes') } catch (e) { try { logJSON('zhizi_notes.require.error', { error: String(e.message || e) }, 'zhizi_notes') } catch {}; setTimeout(ensureZhiziNotesLoaded, 2000); return }
try {
if (mod && mod.initDb) {
mod.initDb(path.join(__dirname, '..', '..', 'data', 'zhizi_notes.db'))
}
if (mod && mod.bindRoutes) {
mod.bindRoutes(app)
try { logJSON('zhizi_notes.bind_routes.done', { ok: true }, 'zhizi_notes') } catch {}
}
} catch (e) {
try { logJSON('zhizi_notes.bind_routes.error', { error: String(e.message || e) }, 'zhizi_notes') } catch {}
}
}
ensureZhiziNotesLoaded()
const PLANT_HOME_AUTH_COOKIE = 'plant_home_gate'
const readPlantHomeAuthConfig = () => {
try {
@@ -1406,6 +1570,7 @@ const mapSystemIdToUrl = (systemId) => {
'Tools-calendar_voice': '/tools/calendar_voice',
'TRAE-PSC': '/tools/psc',
'FitnessDiary': '/tools/fitness_diary',
'Tools-zhizi_notes': '/tools/zhizi_notes',
'Tools-doc_cloud_keeper': '/tools/doc_cloud_keeper',
'Tools-yuanzhupai': '/tools/yuanzhupai',
'Tools-oss_file_cabinet': '/tools/oss_file_cabinet',
+518
View File
@@ -0,0 +1,518 @@
/**
* 知资札记 - 后端模块
* 证券投资图文存档站,SQLite 存储 + 本地图片管理
*/
const Database = require('better-sqlite3');
const path = require('path');
const fs = require('fs');
const multer = require('multer');
let db = null;
// ── 链接清洗:移除所有 <a> 标签,保留文字 ──
const clearAllLinks = (html) => {
if (!html || typeof html !== 'string') return '';
return html.replace(/<a\b[^>]*>(.*?)<\/a>/gi, '$1');
};
// ── 初始化数据库 ──
function initDb(dbPath) {
const dir = path.dirname(dbPath);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
db = new Database(dbPath);
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
db.exec(`
CREATE TABLE IF NOT EXISTS zn_articles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
content TEXT NOT NULL DEFAULT '',
content_plain TEXT NOT NULL DEFAULT '',
category TEXT NOT NULL DEFAULT '未分类',
source TEXT NOT NULL DEFAULT '',
tags_json TEXT NOT NULL DEFAULT '[]',
pics_json TEXT NOT NULL DEFAULT '[]',
cover_url TEXT NOT NULL DEFAULT '',
content_type TEXT NOT NULL DEFAULT 'text',
status TEXT NOT NULL DEFAULT 'normal',
views INTEGER NOT NULL DEFAULT 0,
is_pinned INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')),
updated_at TEXT NOT NULL DEFAULT (datetime('now','localtime'))
)
`);
db.exec(`
CREATE TABLE IF NOT EXISTS zn_images (
id INTEGER PRIMARY KEY AUTOINCREMENT,
article_id INTEGER NOT NULL DEFAULT 0,
filename TEXT NOT NULL,
original_name TEXT NOT NULL DEFAULT '',
file_size INTEGER NOT NULL DEFAULT 0,
mime_type TEXT NOT NULL DEFAULT 'image/jpeg',
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')),
FOREIGN KEY (article_id) REFERENCES zn_articles(id) ON DELETE CASCADE
)
`);
db.exec(`
CREATE TABLE IF NOT EXISTS zn_tags (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
article_count INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime'))
)
`);
db.exec(`
CREATE TABLE IF NOT EXISTS zn_categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime'))
)
`);
db.exec(`
CREATE TABLE IF NOT EXISTS zn_favorites (
id INTEGER PRIMARY KEY AUTOINCREMENT,
article_id INTEGER NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')),
FOREIGN KEY (article_id) REFERENCES zn_articles(id) ON DELETE CASCADE
)
`);
// 初始化默认分类
const defaultCats = ['宏观分析', '个股研报', '交易复盘', '行业逻辑', '估值教程', '风险避坑'];
const insertCat = db.prepare('INSERT OR IGNORE INTO zn_categories (name, sort_order) VALUES (?, ?)');
defaultCats.forEach((name, i) => insertCat.run(name, i));
// 初始化默认标签
const defaultTags = ['价值投资', '短线技术', '新能源', '医药', '财报解读', '量化', '仓位管理'];
const insertTag = db.prepare('INSERT OR IGNORE INTO zn_tags (name) VALUES (?)');
defaultTags.forEach(name => insertTag.run(name));
}
// ── 上传目录 ──
const uploadsDir = path.join(process.cwd(), 'public', 'uploads', 'zhizi_notes');
if (!fs.existsSync(uploadsDir)) fs.mkdirSync(uploadsDir, { recursive: true });
const storage = multer.diskStorage({
destination: (req, file, cb) => cb(null, uploadsDir),
filename: (req, file, cb) => {
const ext = path.extname(file.originalname) || '.jpg';
const name = `${Date.now()}_${Math.random().toString(36).slice(2, 8)}${ext}`;
cb(null, name);
}
});
const upload = multer({
storage,
limits: { fileSize: 20 * 1024 * 1024 },
fileFilter: (req, file, cb) => {
if (file.mimetype.startsWith('image/')) return cb(null, true);
cb(new Error('仅支持图片文件上传,不支持视频'));
}
});
// ── 标签统计更新 ──
const updateTagCounts = () => {
try {
const articles = db.prepare("SELECT tags_json FROM zn_articles WHERE status = 'normal'").all();
const countMap = {};
articles.forEach(row => {
try {
const tags = JSON.parse(row.tags_json || '[]');
tags.forEach(t => { countMap[t] = (countMap[t] || 0) + 1; });
} catch {}
});
const upsert = db.prepare('INSERT INTO zn_tags (name, article_count) VALUES (?, ?) ON CONFLICT(name) DO UPDATE SET article_count = excluded.article_count');
for (const [name, count] of Object.entries(countMap)) upsert.run(name, count);
} catch {}
};
// ── 绑定路由 ──
function bindRoutes(app) {
const API = '/api/zhizi_notes';
app.use(API, (req, res, next) => {
res.set('Cache-Control', 'no-store');
next();
});
// ── 未授权提示(公开接口,无需鉴权) ──
app.get(`${API}/auth/hint`, (req, res) => {
res.json({ ok: true, message: '请通过首页九宫格进入,或使用正确的访问链接' })
});
// ── 站点统计 ──
app.get(`${API}/stats`, (req, res) => {
try {
const totalArticles = db.prepare("SELECT COUNT(*) AS c FROM zn_articles WHERE status = 'normal'").get().c;
const totalImages = db.prepare('SELECT COUNT(*) AS c FROM zn_images').get().c;
const catStats = db.prepare("SELECT category, COUNT(*) AS c FROM zn_articles WHERE status = 'normal' GROUP BY category ORDER BY c DESC").all();
res.json({ ok: true, data: { totalArticles, totalImages, catStats } });
} catch (e) {
res.status(500).json({ ok: false, error: e.message });
}
});
// ── 分类列表 ──
app.get(`${API}/categories`, (req, res) => {
try {
const rows = db.prepare('SELECT * FROM zn_categories ORDER BY sort_order ASC').all();
res.json({ ok: true, data: rows });
} catch (e) {
res.status(500).json({ ok: false, error: e.message });
}
});
// ── 标签列表 ──
app.get(`${API}/tags`, (req, res) => {
try {
updateTagCounts();
const rows = db.prepare('SELECT * FROM zn_tags ORDER BY article_count DESC, name ASC').all();
res.json({ ok: true, data: rows });
} catch (e) {
res.status(500).json({ ok: false, error: e.message });
}
});
// ── 文章列表(分页+筛选) ──
app.get(`${API}/articles`, (req, res) => {
try {
const page = Math.max(1, parseInt(req.query.page) || 1);
const pageSize = Math.min(100, Math.max(1, parseInt(req.query.pageSize) || 12));
const offset = (page - 1) * pageSize;
const { category, tag, timeRange, contentType, search, status } = req.query;
let where = ["1=1"];
let params = {};
if (status) {
where.push('a.status = @status');
params.status = status;
} else {
where.push("a.status = 'normal'");
}
if (category) {
where.push('a.category = @category');
params.category = category;
}
if (contentType) {
where.push('a.content_type = @contentType');
params.contentType = contentType;
}
if (tag) {
where.push("a.tags_json LIKE @tagLike");
params.tagLike = `%"${tag}"%`;
}
if (search) {
where.push('(a.title LIKE @search OR a.content_plain LIKE @search OR a.tags_json LIKE @searchTag)');
params.search = `%${search}%`;
params.searchTag = `%${search}%`;
}
if (timeRange === 'week') {
where.push("a.created_at >= datetime('now','localtime','-7 days')");
} else if (timeRange === 'month') {
where.push("a.created_at >= datetime('now','localtime','-1 month')");
} else if (timeRange === 'quarter') {
where.push("a.created_at >= datetime('now','localtime','-3 months')");
}
const whereClause = where.join(' AND ');
const countRow = db.prepare(`SELECT COUNT(*) AS total FROM zn_articles a WHERE ${whereClause}`).get(params);
const total = countRow ? countRow.total : 0;
const rows = db.prepare(`
SELECT a.*,
(SELECT COUNT(*) FROM zn_favorites f WHERE f.article_id = a.id) AS fav_count,
(SELECT COUNT(*) FROM zn_images im WHERE im.article_id = a.id) AS image_count
FROM zn_articles a
WHERE ${whereClause}
ORDER BY a.is_pinned DESC, a.created_at DESC
LIMIT @limit OFFSET @offset
`).all({ ...params, limit: pageSize, offset });
res.json({
ok: true,
data: rows,
pagination: { page, pageSize, total, totalPages: Math.ceil(total / pageSize) }
});
} catch (e) {
res.status(500).json({ ok: false, error: e.message });
}
});
// ── 置顶文章 ──
app.get(`${API}/articles/pinned`, (req, res) => {
try {
const rows = db.prepare(`
SELECT * FROM zn_articles WHERE is_pinned = 1 AND status = 'normal' ORDER BY created_at DESC LIMIT 3
`).all();
res.json({ ok: true, data: rows });
} catch (e) {
res.status(500).json({ ok: false, error: e.message });
}
});
// ── 最新入库 ──
app.get(`${API}/articles/latest`, (req, res) => {
try {
const rows = db.prepare(`
SELECT * FROM zn_articles WHERE status = 'normal' ORDER BY created_at DESC LIMIT 5
`).all();
res.json({ ok: true, data: rows });
} catch (e) {
res.status(500).json({ ok: false, error: e.message });
}
});
// ── 文章详情 ──
app.get(`${API}/articles/:id`, (req, res) => {
try {
const row = db.prepare('SELECT * FROM zn_articles WHERE id = ?').get(req.params.id);
if (!row) return res.status(404).json({ ok: false, error: '文章不存在' });
db.prepare('UPDATE zn_articles SET views = views + 1 WHERE id = ?').run(req.params.id);
row.views += 1;
const images = db.prepare('SELECT * FROM zn_images WHERE article_id = ?').all(req.params.id);
res.json({ ok: true, data: { ...row, images } });
} catch (e) {
res.status(500).json({ ok: false, error: e.message });
}
});
// ── 新增文章 ──
app.post(`${API}/articles`, (req, res) => {
try {
const { title, content, category, source, tags, pics, coverUrl, contentType } = req.body;
if (!title) return res.status(400).json({ ok: false, error: '标题不能为空' });
const cleanedContent = clearAllLinks(content || '');
const contentPlain = cleanedContent.replace(/<[^>]+>/g, '').trim();
const result = db.prepare(`
INSERT INTO zn_articles (title, content, content_plain, category, source, tags_json, pics_json, cover_url, content_type)
VALUES (@title, @content, @contentPlain, @category, @source, @tagsJson, @picsJson, @coverUrl, @contentType)
`).run({
title,
content: cleanedContent,
contentPlain,
category: category || '未分类',
source: source || '',
tagsJson: JSON.stringify(tags || []),
picsJson: JSON.stringify(pics || []),
coverUrl: coverUrl || '',
contentType: contentType || 'text'
});
updateTagCounts();
res.json({ ok: true, data: { id: result.lastInsertRowid } });
} catch (e) {
res.status(500).json({ ok: false, error: e.message });
}
});
// ── 更新文章 ──
app.put(`${API}/articles/:id`, (req, res) => {
try {
const existing = db.prepare('SELECT * FROM zn_articles WHERE id = ?').get(req.params.id);
if (!existing) return res.status(404).json({ ok: false, error: '文章不存在' });
const { title, content, category, source, tags, pics, coverUrl, contentType, isPinned, status } = req.body;
const cleanedContent = content !== undefined ? clearAllLinks(content) : existing.content;
const contentPlain = cleanedContent.replace(/<[^>]+>/g, '').trim();
db.prepare(`
UPDATE zn_articles SET
title = @title, content = @content, content_plain = @contentPlain,
category = @category, source = @source,
tags_json = @tagsJson, pics_json = @picsJson,
cover_url = @coverUrl, content_type = @contentType,
is_pinned = @isPinned, status = @status,
updated_at = datetime('now','localtime')
WHERE id = @id
`).run({
id: req.params.id,
title: title || existing.title,
content: cleanedContent,
contentPlain,
category: category || existing.category,
source: source !== undefined ? source : existing.source,
tagsJson: tags ? JSON.stringify(tags) : existing.tags_json,
picsJson: pics ? JSON.stringify(pics) : existing.pics_json,
coverUrl: coverUrl !== undefined ? coverUrl : existing.cover_url,
contentType: contentType || existing.content_type,
isPinned: isPinned !== undefined ? (isPinned ? 1 : 0) : existing.is_pinned,
status: status || existing.status
});
updateTagCounts();
res.json({ ok: true });
} catch (e) {
res.status(500).json({ ok: false, error: e.message });
}
});
// ── 删除单张图片 ─
app.delete(`${API}/images/:id`, (req, res) => {
try {
const img = db.prepare('SELECT * FROM zn_images WHERE id = ?').get(req.params.id);
if (!img) return res.status(404).json({ ok: false, error: '图片不存在' });
// 删除物理文件
const filePath = path.join(uploadsDir, img.filename);
try { if (fs.existsSync(filePath)) fs.unlinkSync(filePath); } catch {}
db.prepare('DELETE FROM zn_images WHERE id = ?').run(req.params.id);
res.json({ ok: true });
} catch (e) {
res.status(500).json({ ok: false, error: e.message });
}
});
// ── 删除文章(含关联图片) ──
app.delete(`${API}/articles/:id`, (req, res) => {
try {
const existing = db.prepare('SELECT * FROM zn_articles WHERE id = ?').get(req.params.id);
if (!existing) return res.status(404).json({ ok: false, error: '文章不存在' });
// 删除关联图片文件
const images = db.prepare('SELECT * FROM zn_images WHERE article_id = ?').all(req.params.id);
images.forEach(img => {
const filePath = path.join(uploadsDir, img.filename);
try { if (fs.existsSync(filePath)) fs.unlinkSync(filePath); } catch {}
});
db.prepare('DELETE FROM zn_favorites WHERE article_id = ?').run(req.params.id);
db.prepare('DELETE FROM zn_images WHERE article_id = ?').run(req.params.id);
db.prepare('DELETE FROM zn_articles WHERE id = ?').run(req.params.id);
updateTagCounts();
res.json({ ok: true });
} catch (e) {
res.status(500).json({ ok: false, error: e.message });
}
});
// ── 批量操作 ──
app.post(`${API}/articles/batch`, (req, res) => {
try {
const { ids, action } = req.body;
if (!Array.isArray(ids) || ids.length === 0) return res.status(400).json({ ok: false, error: '请选择至少一篇文章' });
if (!['delete', 'offline', 'online'].includes(action)) return res.status(400).json({ ok: false, error: '无效操作' });
if (action === 'delete') {
ids.forEach(id => {
const images = db.prepare('SELECT * FROM zn_images WHERE article_id = ?').all(id);
images.forEach(img => {
const filePath = path.join(uploadsDir, img.filename);
try { if (fs.existsSync(filePath)) fs.unlinkSync(filePath); } catch {}
});
db.prepare('DELETE FROM zn_favorites WHERE article_id = ?').run(id);
db.prepare('DELETE FROM zn_images WHERE article_id = ?').run(id);
db.prepare('DELETE FROM zn_articles WHERE id = ?').run(id);
});
} else {
const newStatus = action === 'offline' ? 'offline' : 'normal';
const stmt = db.prepare('UPDATE zn_articles SET status = ?, updated_at = datetime(\'now\',\'localtime\') WHERE id = ?');
ids.forEach(id => stmt.run(newStatus, id));
}
updateTagCounts();
res.json({ ok: true });
} catch (e) {
res.status(500).json({ ok: false, error: e.message });
}
});
// ── 收藏切换 ──
app.post(`${API}/articles/:id/toggle_favorite`, (req, res) => {
try {
const articleId = parseInt(req.params.id);
const existing = db.prepare('SELECT * FROM zn_favorites WHERE article_id = ?').get(articleId);
if (existing) {
db.prepare('DELETE FROM zn_favorites WHERE article_id = ?').run(articleId);
res.json({ ok: true, data: { favorited: false } });
} else {
db.prepare('INSERT INTO zn_favorites (article_id) VALUES (?)').run(articleId);
res.json({ ok: true, data: { favorited: true } });
}
} catch (e) {
res.status(500).json({ ok: false, error: e.message });
}
});
// ── 收藏列表 ──
app.get(`${API}/favorites`, (req, res) => {
try {
const rows = db.prepare(`
SELECT a.*, (SELECT COUNT(*) FROM zn_images im WHERE im.article_id = a.id) AS image_count
FROM zn_articles a
INNER JOIN zn_favorites f ON f.article_id = a.id
WHERE a.status = 'normal'
ORDER BY f.created_at DESC
`).all();
res.json({ ok: true, data: rows });
} catch (e) {
res.status(500).json({ ok: false, error: e.message });
}
});
// ── 图片上传 ──
app.post(`${API}/upload`, upload.array('images', 20), (req, res) => {
try {
if (!req.files || req.files.length === 0) return res.status(400).json({ ok: false, error: '请选择图片' });
const articleId = parseInt(req.body.articleId) || 0;
const images = req.files.map(f => ({
filename: f.filename,
originalName: f.originalname,
size: f.size,
mimeType: f.mimetype,
url: `/uploads/zhizi_notes/${f.filename}`
}));
if (articleId > 0) {
const stmt = db.prepare('INSERT INTO zn_images (article_id, filename, original_name, file_size, mime_type) VALUES (?, ?, ?, ?, ?)');
images.forEach(img => stmt.run(articleId, img.filename, img.originalName, img.size, img.mimeType));
}
res.json({ ok: true, data: images });
} catch (e) {
res.status(500).json({ ok: false, error: e.message });
}
});
// ── 导出纯文本 ──
app.post(`${API}/export`, (req, res) => {
try {
const { ids } = req.body;
if (!Array.isArray(ids) || ids.length === 0) return res.status(400).json({ ok: false, error: '请选择文章' });
const rows = db.prepare(`SELECT title, content_plain, source, category, created_at FROM zn_articles WHERE id IN (${ids.map(() => '?').join(',')})`).all(...ids);
let text = '';
rows.forEach((row, i) => {
text += `【${i + 1}】${row.title}\n`;
text += `分类:${row.category} | 来源:${row.source} | 存档时间:${row.created_at}\n`;
text += `${'─'.repeat(50)}\n`;
text += `${row.content_plain}\n\n`;
});
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
res.setHeader('Content-Disposition', `attachment; filename="知资札记_导出_${new Date().toISOString().slice(0, 10)}.txt"`);
res.send(text);
} catch (e) {
res.status(500).json({ ok: false, error: e.message });
}
});
// ── 静态文件服务(上传图片) ──
app.use('/uploads/zhizi_notes', (req, res, next) => {
res.set('Cache-Control', 'public, max-age=86400');
next();
});
}
module.exports = { initDb, bindRoutes };