feat: 新增知资札记工具及相关功能
1. 新增知资札记完整站点:包含首页、详情页、管理后台 2. 添加站点图标、鉴权配置与后端API鉴权逻辑 3. 在市场页面新增知资札记入口跳转 4. 新增经典乡村音乐播放列表文件 5. 临时添加养生谚语批量导入脚本
This commit is contained in:
Binary file not shown.
Binary file not shown.
@@ -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',
|
||||
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user