后台“认知观察”文章管理改版:搜索筛选、上下线、批量删除,修改正文网站立即生效
- 文章列表:按标题/路径搜索,按状态和来源(内容系统/手动添加)筛选,显示发布时间、字数和推荐权重;一键上线/下线、查看前台页面、单篇和批量删除,操作结果有提示 - 编辑改为右侧抽屉,正文按段落纯文本编辑;发布时间可改;来自内容系统的文章提示再次发布会覆盖修改 - 修复:后台改正文网站不变(网站显示 body_plain,后台只改了 content_html),现在两者一起保存 - 修复:修改文章时没传的字段会被清空,现在只改提交的字段 - 删除文章时同时删除内容系统的发布记录,内容系统可以重新发布;新增批量删除接口 - 上线时没有发布时间就用当前时间;路径标识重复时给出可读提示 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
import { plainTextToParagraphHtml } from './content-ingest.js';
|
||||
|
||||
// Article editing from the admin. The website shows body_plain as paragraphs (content_html only when
|
||||
// body_plain is empty), so the two are always saved together: plain text is what gets edited.
|
||||
const EDITABLE = ['slug', 'title', 'subtitle', 'category', 'cover_url', 'publish_time', 'recommend_weight', 'status'];
|
||||
export const MAX_BATCH = 200;
|
||||
|
||||
export class ArticleAdminError extends Error {
|
||||
constructor(status, message) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
const ENTITIES = { ' ': ' ', '<': '<', '>': '>', '"': '"', ''': "'", '&': '&' };
|
||||
|
||||
/** Paragraph text of an article's HTML, for articles that have no body_plain yet. */
|
||||
export function htmlToPlainText(html) {
|
||||
return String(html || '')
|
||||
.replace(/<\s*br\s*\/?>/gi, '\n')
|
||||
.replace(/<\/(p|div|h[1-6]|li|blockquote|section)>/gi, '\n')
|
||||
.replace(/<[^>]+>/g, '')
|
||||
.replace(/&(nbsp|lt|gt|quot|#39|amp);/g, (entity) => ENTITIES[entity])
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
/** Columns to change for an admin edit. Fields the request leaves out keep their current value. */
|
||||
export function articleChanges(body, current, now) {
|
||||
const changes = {};
|
||||
for (const key of EDITABLE) if (body[key] !== undefined) changes[key] = body[key];
|
||||
if (changes.title !== undefined && !String(changes.title || '').trim()) throw new ArticleAdminError(400, '标题不能为空');
|
||||
if (changes.slug !== undefined && !String(changes.slug || '').trim()) throw new ArticleAdminError(400, '路径标识不能为空');
|
||||
if (changes.recommend_weight !== undefined) changes.recommend_weight = Number(changes.recommend_weight) || 0;
|
||||
if (changes.publish_time === '') changes.publish_time = null;
|
||||
|
||||
if (body.body_plain !== undefined) {
|
||||
const text = String(body.body_plain || '').replace(/\r\n?/g, '\n');
|
||||
changes.body_plain = text;
|
||||
changes.content_html = plainTextToParagraphHtml(text);
|
||||
} else if (body.content_html !== undefined) {
|
||||
changes.content_html = String(body.content_html || '');
|
||||
changes.body_plain = htmlToPlainText(changes.content_html);
|
||||
}
|
||||
|
||||
// Going live without a publish time: now.
|
||||
const status = changes.status ?? current?.status;
|
||||
const publishTime = changes.publish_time !== undefined ? changes.publish_time : current?.publish_time;
|
||||
if (status === 'published' && !publishTime) changes.publish_time = now;
|
||||
return changes;
|
||||
}
|
||||
|
||||
function duplicateSlug(error) {
|
||||
return error?.code === 'ER_DUP_ENTRY' ? new ArticleAdminError(409, '路径标识已被其他文章使用,请换一个') : error;
|
||||
}
|
||||
|
||||
export async function updateArticle(tx, id, body, now = new Date().toISOString()) {
|
||||
const current = await tx.one(`SELECT id, status, publish_time FROM articles WHERE id=? FOR UPDATE`, [id]);
|
||||
if (!current) throw new ArticleAdminError(404, '文章不存在,可能已被删除');
|
||||
const changes = articleChanges(body, current, now);
|
||||
const columns = Object.keys(changes);
|
||||
try {
|
||||
await tx.execute(`UPDATE articles SET ${[...columns.map((key) => `${key}=?`), 'updated_at=?'].join(', ')} WHERE id=?`,
|
||||
[...columns.map((key) => changes[key]), now, id]);
|
||||
} catch (error) {
|
||||
throw duplicateSlug(error);
|
||||
}
|
||||
return tx.one(`SELECT * FROM articles WHERE id=?`, [id]);
|
||||
}
|
||||
|
||||
export async function createArticle(tx, id, body, now = new Date().toISOString()) {
|
||||
if (!String(body.title || '').trim()) throw new ArticleAdminError(400, '标题必填');
|
||||
const changes = articleChanges({ status: 'published', ...body, slug: body.slug || `art-${Date.now()}` }, null, now);
|
||||
const columns = ['id', ...Object.keys(changes), 'created_at', 'updated_at'];
|
||||
try {
|
||||
await tx.execute(`INSERT INTO articles (${columns.join(', ')}) VALUES (${columns.map(() => '?').join(',')})`,
|
||||
[id, ...Object.values(changes), now, now]);
|
||||
} catch (error) {
|
||||
throw duplicateSlug(error);
|
||||
}
|
||||
return tx.one(`SELECT id, slug FROM articles WHERE id=?`, [id]);
|
||||
}
|
||||
|
||||
/** Deletes articles and their content-system publication records, so the content system can publish them again. */
|
||||
export async function deleteArticles(tx, ids) {
|
||||
const list = [...new Set((Array.isArray(ids) ? ids : []).filter((id) => typeof id === 'string' && id))];
|
||||
if (!list.length) throw new ArticleAdminError(400, '请选择要删除的文章');
|
||||
if (list.length > MAX_BATCH) throw new ArticleAdminError(400, `一次最多删除 ${MAX_BATCH} 篇`);
|
||||
const marks = list.map(() => '?').join(',');
|
||||
await tx.execute(`DELETE FROM article_ingest_requests WHERE article_id IN (${marks})`, list);
|
||||
const result = await tx.execute(`DELETE FROM articles WHERE id IN (${marks})`, list);
|
||||
return result?.affectedRows ?? 0;
|
||||
}
|
||||
+25
-14
@@ -8,7 +8,8 @@ import multer from 'multer';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import crypto from 'crypto';
|
||||
import PDFDocument from 'pdfkit';
|
||||
import { initDB, getDB, UPLOAD_DIR } from './db.js';
|
||||
import { initDB, getDB, UPLOAD_DIR, withTransaction } from './db.js';
|
||||
import { createArticle, deleteArticles, htmlToPlainText, updateArticle } from './article-admin.js';
|
||||
import { signToken, verifyToken, authMiddleware } from './middleware/auth.js';
|
||||
import { createContentIngestRouter } from './content-ingest.js';
|
||||
import { sendContactLeadToWeCom } from './wecom-notifier.js';
|
||||
@@ -295,21 +296,31 @@ app.put('/api/cms/cases/:id', (req,res)=>{
|
||||
app.delete('/api/cms/cases/:id', (req,res)=> db.run(`DELETE FROM cases WHERE id=?`,[req.params.id], e=> res.json({code:0,msg:'删除成功'})));
|
||||
|
||||
// 文章
|
||||
app.get('/api/cms/articles', (req,res)=> db.all(`SELECT * FROM articles ORDER BY recommend_weight DESC, publish_time DESC, created_at DESC`, (e,r)=> res.json({code:0,data:r})));
|
||||
app.get('/api/cms/articles/:id', (req,res)=> db.get(`SELECT * FROM articles WHERE id=?`,[req.params.id], (e,r)=> res.json({code:0,data:r})));
|
||||
app.post('/api/cms/articles', (req,res)=>{
|
||||
const b=req.body; if(!b.title) return res.status(400).json({code:400,msg:'标题必填'});
|
||||
const id=genId('art');
|
||||
const slug = b.slug || `art-${Date.now()}`;
|
||||
db.run(`INSERT INTO articles (id, slug, title, subtitle, category, cover_url, content_html, publish_time, recommend_weight, status, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
[id,slug,b.title,b.subtitle,b.category,b.cover_url,b.content_html,b.publish_time||nowISO(),b.recommend_weight||0,b.status||'published',nowISO(),nowISO()], e=> res.json({code:0,data:{id,slug}}));
|
||||
const ARTICLE_LIST_COLUMNS = `id, slug, title, subtitle, category, cover_url, publish_time, recommend_weight, status, source_system, revision, created_at, updated_at, CHAR_LENGTH(COALESCE(NULLIF(body_plain, ''), content_html, '')) AS body_length`;
|
||||
const articleError = (res, error) => res.status(error.status || 500).json({ code: error.status || 500, msg: error.status ? error.message : `操作没有完成:${error.message}` });
|
||||
app.get('/api/cms/articles', (req,res)=> db.all(`SELECT ${ARTICLE_LIST_COLUMNS} FROM articles ORDER BY recommend_weight DESC, publish_time DESC, created_at DESC`, (e,r)=> e ? articleError(res, e) : res.json({code:0,data:r})));
|
||||
app.get('/api/cms/articles/:id', (req,res)=> db.get(`SELECT * FROM articles WHERE id=?`,[req.params.id], (e,r)=> {
|
||||
if (e) return articleError(res, e);
|
||||
if (!r) return res.status(404).json({code:404,msg:'文章不存在,可能已被删除'});
|
||||
res.json({code:0,data:{...r, body_plain: r.body_plain || htmlToPlainText(r.content_html)}});
|
||||
}));
|
||||
app.post('/api/cms/articles', async (req,res)=>{
|
||||
try { res.json({code:0,data: await withTransaction(tx => createArticle(tx, genId('art'), req.body || {}, nowISO()))}); }
|
||||
catch (error) { articleError(res, error); }
|
||||
});
|
||||
app.put('/api/cms/articles/:id', (req,res)=>{
|
||||
const b=req.body;
|
||||
db.run(`UPDATE articles SET slug=?, title=?, subtitle=?, category=?, cover_url=?, content_html=?, publish_time=?, recommend_weight=?, status=?, updated_at=? WHERE id=?`,
|
||||
[b.slug,b.title,b.subtitle,b.category,b.cover_url,b.content_html,b.publish_time,b.recommend_weight,b.status,nowISO(),req.params.id], e=> res.json({code:0,msg:'更新成功'}));
|
||||
app.post('/api/cms/articles/batch-delete', async (req,res)=>{
|
||||
try { const deleted = await withTransaction(tx => deleteArticles(tx, req.body?.ids)); res.json({code:0,msg:`已删除 ${deleted} 篇`,data:{deleted}}); }
|
||||
catch (error) { articleError(res, error); }
|
||||
});
|
||||
// Only the fields sent are changed, so a status toggle does not wipe the rest of the article.
|
||||
app.put('/api/cms/articles/:id', async (req,res)=>{
|
||||
try { res.json({code:0,msg:'更新成功',data: await withTransaction(tx => updateArticle(tx, req.params.id, req.body || {}, nowISO()))}); }
|
||||
catch (error) { articleError(res, error); }
|
||||
});
|
||||
app.delete('/api/cms/articles/:id', async (req,res)=>{
|
||||
try { await withTransaction(tx => deleteArticles(tx, [req.params.id])); res.json({code:0,msg:'删除成功'}); }
|
||||
catch (error) { articleError(res, error); }
|
||||
});
|
||||
app.delete('/api/cms/articles/:id', (req,res)=> db.run(`DELETE FROM articles WHERE id=?`,[req.params.id], e=> res.json({code:0,msg:'删除成功'})));
|
||||
|
||||
// 自我对照
|
||||
app.get('/api/cms/self-check', (req,res)=> db.get(`SELECT * FROM self_check WHERE id=1`, (e,r)=> res.json({code:0,data:r})));
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import { ArticleAdminError, articleChanges, createArticle, deleteArticles, htmlToPlainText, updateArticle } from '../src/article-admin.js';
|
||||
|
||||
const NOW = '2026-10-19T08:00:00.000Z';
|
||||
|
||||
class FakeTx {
|
||||
constructor(rows = {}) {
|
||||
this.rows = rows;
|
||||
this.statements = [];
|
||||
}
|
||||
|
||||
async one(sql, params) {
|
||||
this.statements.push([sql, params]);
|
||||
return this.rows[params[0]] || null;
|
||||
}
|
||||
|
||||
async execute(sql, params) {
|
||||
this.statements.push([sql, params]);
|
||||
return { affectedRows: params.length };
|
||||
}
|
||||
}
|
||||
|
||||
test('a status toggle changes only the status', () => {
|
||||
const changes = articleChanges({ status: 'draft' }, { status: 'published', publish_time: NOW }, NOW);
|
||||
assert.deepEqual(changes, { status: 'draft' });
|
||||
});
|
||||
|
||||
test('editing the plain body keeps the HTML body in step', () => {
|
||||
const changes = articleChanges({ body_plain: '第一段\r\n\r\n<第二段> & 结尾' }, { status: 'draft' }, NOW);
|
||||
assert.equal(changes.body_plain, '第一段\n\n<第二段> & 结尾');
|
||||
assert.equal(changes.content_html, '<p>第一段</p>\n<p><第二段> & 结尾</p>');
|
||||
});
|
||||
|
||||
test('an HTML-only edit refreshes the plain body the website shows', () => {
|
||||
const changes = articleChanges({ content_html: '<p>新的一段</p><p>A&B<br>换行</p>' }, { status: 'draft' }, NOW);
|
||||
assert.equal(changes.body_plain, '新的一段\nA&B\n换行');
|
||||
});
|
||||
|
||||
test('going live without a publish time uses now; an existing time is kept', () => {
|
||||
assert.equal(articleChanges({ status: 'published' }, { status: 'draft', publish_time: null }, NOW).publish_time, NOW);
|
||||
assert.equal(articleChanges({ status: 'published' }, { status: 'draft', publish_time: '2026-01-01T00:00:00.000Z' }, NOW).publish_time, undefined);
|
||||
assert.equal(articleChanges({ status: 'published', publish_time: '' }, { status: 'draft', publish_time: '2026-01-01' }, NOW).publish_time, NOW);
|
||||
});
|
||||
|
||||
test('an empty title or slug is refused', () => {
|
||||
assert.throws(() => articleChanges({ title: ' ' }, {}, NOW), ArticleAdminError);
|
||||
assert.throws(() => articleChanges({ slug: '' }, {}, NOW), ArticleAdminError);
|
||||
});
|
||||
|
||||
test('htmlToPlainText handles empty values', () => {
|
||||
assert.equal(htmlToPlainText(null), '');
|
||||
assert.equal(htmlToPlainText('<div>一</div><div>二</div>'), '一\n二');
|
||||
});
|
||||
|
||||
test('updating writes only the changed columns', async () => {
|
||||
const tx = new FakeTx({ a1: { id: 'a1', status: 'published', publish_time: NOW } });
|
||||
await updateArticle(tx, 'a1', { title: '新标题' }, NOW);
|
||||
const [sql, params] = tx.statements.find(([statement]) => statement.startsWith('UPDATE'));
|
||||
assert.equal(sql, 'UPDATE articles SET title=?, updated_at=? WHERE id=?');
|
||||
assert.deepEqual(params, ['新标题', NOW, 'a1']);
|
||||
});
|
||||
|
||||
test('updating a missing article is a 404', async () => {
|
||||
await assert.rejects(updateArticle(new FakeTx(), 'gone', { title: 'x' }, NOW), (error) => error.status === 404);
|
||||
});
|
||||
|
||||
test('a duplicate slug becomes a readable 409', async () => {
|
||||
const tx = new FakeTx({ a1: { id: 'a1', status: 'draft' } });
|
||||
tx.execute = async () => { throw Object.assign(new Error('dup'), { code: 'ER_DUP_ENTRY' }); };
|
||||
await assert.rejects(updateArticle(tx, 'a1', { slug: 'taken' }, NOW), (error) => error.status === 409);
|
||||
});
|
||||
|
||||
test('creating saves plain and HTML bodies and defaults to published now', async () => {
|
||||
const tx = new FakeTx();
|
||||
await createArticle(tx, 'a2', { title: '标题', body_plain: '正文' }, NOW);
|
||||
const [sql, params] = tx.statements.find(([statement]) => statement.startsWith('INSERT'));
|
||||
const columns = sql.match(/\(([^)]+)\) VALUES/)[1].split(', ');
|
||||
const row = Object.fromEntries(columns.map((column, index) => [column, params[index]]));
|
||||
assert.equal(row.status, 'published');
|
||||
assert.equal(row.publish_time, NOW);
|
||||
assert.equal(row.body_plain, '正文');
|
||||
assert.equal(row.content_html, '<p>正文</p>');
|
||||
assert.match(row.slug, /^art-\d+$/);
|
||||
});
|
||||
|
||||
test('deleting also removes the content-system publication records', async () => {
|
||||
const tx = new FakeTx();
|
||||
const deleted = await deleteArticles(tx, ['a1', 'a2', 'a1', 3]);
|
||||
assert.equal(deleted, 2);
|
||||
assert.deepEqual(tx.statements.map(([sql]) => sql), [
|
||||
'DELETE FROM article_ingest_requests WHERE article_id IN (?,?)',
|
||||
'DELETE FROM articles WHERE id IN (?,?)',
|
||||
]);
|
||||
await assert.rejects(deleteArticles(tx, []), (error) => error.status === 400);
|
||||
await assert.rejects(deleteArticles(tx, Array.from({ length: 201 }, (_, i) => `a${i}`)), (error) => error.status === 400);
|
||||
});
|
||||
Reference in New Issue
Block a user