From 320e130ea505b8daff9c02ec54cf1724038f1e29 Mon Sep 17 00:00:00 2001 From: taiyi Date: Wed, 22 Jul 2026 09:34:55 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E9=97=AE=E9=A2=98=EF=BC=8C?= =?UTF-8?q?=E9=80=9A=E8=BF=87=E6=B5=8F=E8=A7=88=E5=99=A8=E6=8F=92=E4=BB=B6?= =?UTF-8?q?=E5=8F=91=E5=B8=83=E8=BF=87=E7=9A=84=E7=AC=94=E8=AE=B0=E5=90=8E?= =?UTF-8?q?=EF=BC=8C=E5=9C=A8web=E9=A1=B5=E9=9D=A2=E7=9A=84=E7=AC=94?= =?UTF-8?q?=E8=AE=B0=E7=9A=84=E7=8A=B6=E6=80=81=E4=B8=8D=E4=BC=9A=E6=9B=B4?= =?UTF-8?q?=E6=94=B9=EF=BC=8C=E8=BF=98=E6=9C=89=E4=B8=80=E4=B8=AA=E9=97=AE?= =?UTF-8?q?=E9=A2=98=EF=BC=8C=E5=A6=82=E6=9E=9C=E4=B8=8A=E4=BC=A0=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E4=BB=A5=E5=90=8E=EF=BC=8C=E7=AC=94=E8=AE=B0=E7=9A=84?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E5=B0=B1=E5=8F=98=E6=88=90=E4=BA=86=E5=9C=A8?= =?UTF-8?q?=E6=9C=AA=E5=AE=A1=E6=A0=B8=E9=A1=B5=E9=9D=A2=E7=9A=84=E6=94=B9?= =?UTF-8?q?=E6=88=90=E4=BA=86=E5=A4=B1=E8=B4=A5=EF=BC=8C=E5=BA=94=E8=AF=A5?= =?UTF-8?q?=E6=98=AF=E5=9C=A8=E5=B7=B2=E5=AE=A1=E6=A0=B8=E9=A1=B5=E9=9D=A2?= =?UTF-8?q?=E4=B8=AD=E7=AC=94=E8=AE=B0=E5=88=97=E8=A1=A8=E7=9A=84=E2=80=9C?= =?UTF-8?q?=E4=BD=BF=E7=94=A8=E7=8A=B6=E6=80=81=E2=80=9D=E5=8F=98=E6=88=90?= =?UTF-8?q?=E5=A4=B1=E8=B4=A5=EF=BC=8C=E5=8F=AF=E4=BB=A5=E6=89=B9=E9=87=8F?= =?UTF-8?q?=E5=B0=86=E4=BD=BF=E7=94=A8=E5=A4=B1=E8=B4=A5=E7=9A=84=E7=AC=94?= =?UTF-8?q?=E8=AE=B0=E6=94=B9=E4=B8=BA=E5=BE=85=E5=8F=91=E5=B8=83=E7=9A=84?= =?UTF-8?q?=E7=8A=B6=E6=80=81=EF=BC=8C=E5=B7=B2=E7=BB=8F=E5=8F=91=E5=B8=83?= =?UTF-8?q?=E6=88=90=E5=8A=9F=E7=9A=84=E7=8A=B6=E6=80=81=E6=94=B9=E4=B8=BA?= =?UTF-8?q?=E5=B7=B2=E4=BD=BF=E7=94=A8=E3=80=82=E5=88=86=E6=9E=90=E4=B8=80?= =?UTF-8?q?=E4=B8=8B=E7=8E=B0=E5=9C=A8=E7=9A=84=E6=B5=81=E7=A8=8B=EF=BC=8C?= =?UTF-8?q?=E6=84=9F=E8=A7=89=E6=9C=89=E7=82=B9=E6=B7=B7=E4=B9=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/index.js | 6 ++-- server/lib/db.js | 40 +++++++++++++++++++++++ server/routes/notes.js | 40 ++++++++++++++++++----- server/routes/tasks.js | 10 +++--- web/src/api.js | 2 ++ web/src/pages/NotesApproved.jsx | 56 ++++++++++++++++++++++++++++----- 6 files changed, 131 insertions(+), 23 deletions(-) diff --git a/server/index.js b/server/index.js index be8a05b..27cf68c 100644 --- a/server/index.js +++ b/server/index.js @@ -58,9 +58,9 @@ app.get('/api/stats', (req, res) => { const shopCount = db.prepare('SELECT COUNT(*) as cnt FROM shops').get().cnt; const productCount = db.prepare('SELECT COUNT(*) as cnt FROM products').get().cnt; const noteCount = db.prepare('SELECT COUNT(*) as cnt FROM notes').get().cnt; - const publishedCount = db.prepare("SELECT COUNT(*) as cnt FROM notes WHERE status = 'published'").get().cnt; + const publishedCount = db.prepare("SELECT COUNT(*) as cnt FROM notes WHERE use_status = 'used'").get().cnt; const pendingReviewCount = db.prepare("SELECT COUNT(*) as cnt FROM notes WHERE status = 'generated'").get().cnt; - const failedCount = db.prepare("SELECT COUNT(*) as cnt FROM notes WHERE status = 'failed'").get().cnt; + const failedCount = db.prepare("SELECT COUNT(*) as cnt FROM notes WHERE status = 'failed' OR use_status = 'failed'").get().cnt; res.json({ shops: shopCount, products: productCount, notes: noteCount, published: publishedCount, pendingReview: pendingReviewCount, failed: failedCount }); }); @@ -78,7 +78,7 @@ app.use((req, res, next) => { setInterval(() => { try { const stale = db.prepare( - "UPDATE notes SET status = 'approved', worker_id = NULL, claimed_at = NULL, updated_at = CURRENT_TIMESTAMP WHERE status = 'publishing' AND claimed_at < datetime('now', '-10 minutes')" + "UPDATE notes SET status = 'approved', use_status = 'pending', worker_id = NULL, claimed_at = NULL, updated_at = CURRENT_TIMESTAMP WHERE status = 'publishing' AND claimed_at < datetime('now', '-10 minutes')" ).run(); if (stale.changes > 0) { console.log(`[定时任务] 释放了 ${stale.changes} 条超时的 publishing 笔记`); diff --git a/server/lib/db.js b/server/lib/db.js index 6fd41b9..ae9ac35 100644 --- a/server/lib/db.js +++ b/server/lib/db.js @@ -37,6 +37,7 @@ function initDatabase() { topics TEXT DEFAULT '[]', image_paths TEXT DEFAULT '[]', status TEXT DEFAULT 'pending' CHECK(status IN ('pending', 'generating', 'generated', 'draft', 'approved', 'publishing', 'published', 'failed', 'rejected')), + use_status TEXT DEFAULT 'pending', error_message TEXT DEFAULT '', publish_result TEXT DEFAULT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, @@ -67,12 +68,30 @@ function initDatabase() { FOREIGN KEY (shop_id) REFERENCES shops(id) ON DELETE CASCADE ); + CREATE TABLE IF NOT EXISTS generation_jobs ( + id TEXT PRIMARY KEY, + shop_id TEXT NOT NULL, + status TEXT DEFAULT 'pending' CHECK(status IN ('pending', 'running', 'completed', 'failed')), + total_products INTEGER DEFAULT 0, + total_notes INTEGER DEFAULT 0, + completed_notes INTEGER DEFAULT 0, + failed_notes INTEGER DEFAULT 0, + current_product_id TEXT DEFAULT NULL, + current_product_title TEXT DEFAULT '', + message TEXT DEFAULT '', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (shop_id) REFERENCES shops(id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_products_shop_id ON products(shop_id); CREATE INDEX IF NOT EXISTS idx_notes_product_id ON notes(product_id); CREATE INDEX IF NOT EXISTS idx_notes_shop_id ON notes(shop_id); CREATE INDEX IF NOT EXISTS idx_notes_status ON notes(status); CREATE INDEX IF NOT EXISTS idx_publish_tasks_status ON publish_tasks(status); CREATE INDEX IF NOT EXISTS idx_publish_tasks_shop_id ON publish_tasks(shop_id); + CREATE INDEX IF NOT EXISTS idx_generation_jobs_status ON generation_jobs(status); + CREATE INDEX IF NOT EXISTS idx_generation_jobs_shop_id ON generation_jobs(shop_id); CREATE TABLE IF NOT EXISTS image_hashes ( id TEXT PRIMARY KEY, @@ -90,12 +109,33 @@ function initDatabase() { "ALTER TABLE notes ADD COLUMN image_prompt TEXT DEFAULT ''", "ALTER TABLE notes ADD COLUMN worker_id TEXT DEFAULT NULL", "ALTER TABLE notes ADD COLUMN claimed_at DATETIME DEFAULT NULL", + "ALTER TABLE notes ADD COLUMN use_status TEXT DEFAULT 'pending'", "ALTER TABLE products ADD COLUMN reference_image TEXT DEFAULT ''", ]; for (const sql of migrate) { try { db.exec(sql); } catch (e) { /* column already exists */ } } + db.exec(` + UPDATE notes + SET use_status = 'used', status = 'approved' + WHERE status = 'published'; + + UPDATE notes + SET use_status = 'failed', status = 'approved' + WHERE status = 'failed' + AND EXISTS ( + SELECT 1 FROM publish_tasks + WHERE publish_tasks.note_id = notes.id + AND publish_tasks.status = 'failed' + ); + + UPDATE notes + SET use_status = 'pending' + WHERE status IN ('approved', 'publishing') + AND (use_status IS NULL OR use_status = ''); + `); + // Insert default configs const insertConfig = db.prepare(` diff --git a/server/routes/notes.js b/server/routes/notes.js index 6538e15..b6c751b 100644 --- a/server/routes/notes.js +++ b/server/routes/notes.js @@ -122,7 +122,7 @@ function createNoteRoutes(db) { return res.status(400).json({ error: 'ids array required' }); } const placeholders = ids.map(() => '?').join(','); - db.prepare('UPDATE notes SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE id IN (' + placeholders + ')').run('approved', ...ids); + db.prepare('UPDATE notes SET status = ?, use_status = ?, updated_at = CURRENT_TIMESTAMP WHERE id IN (' + placeholders + ')').run('approved', 'pending', ...ids); const batchNotes = db.prepare('SELECT * FROM notes WHERE id IN (' + placeholders + ')').all(...ids); for (const note of batchNotes) { createPublishTask(note); @@ -136,21 +136,47 @@ function createNoteRoutes(db) { return res.status(400).json({ error: 'ids array required' }); } const placeholders = ids.map(() => '?').join(','); - db.prepare('UPDATE notes SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE id IN (' + placeholders + ')').run('pending', ...ids); + db.prepare('UPDATE notes SET status = ?, use_status = ?, updated_at = CURRENT_TIMESTAMP WHERE id IN (' + placeholders + ')').run('pending', 'pending', ...ids); res.json({ updated: ids.length }); }); + router.post('/batch/publish-reset', (req, res) => { + const { ids } = req.body; + if (!Array.isArray(ids) || ids.length === 0) { + return res.status(400).json({ error: 'ids array required' }); + } + const placeholders = ids.map(() => '?').join(','); + const resetNotes = db.prepare( + 'SELECT * FROM notes WHERE id IN (' + placeholders + ") AND status = 'approved' AND use_status = 'failed'" + ).all(...ids); + + const reset = db.transaction(() => { + const result = db.prepare( + 'UPDATE notes SET use_status = ?, error_message = ?, updated_at = CURRENT_TIMESTAMP WHERE id IN (' + placeholders + ") AND status = 'approved' AND use_status = 'failed'" + ).run('pending', '', ...ids); + for (const note of resetNotes) { + createPublishTask({ ...note, use_status: 'pending' }); + } + return result.changes; + }); + + res.json({ updated: reset() }); + }); + router.put('/:id', (req, res) => { const note = db.prepare('SELECT * FROM notes WHERE id = ?').get(req.params.id); if (!note) return res.status(404).json({ error: 'Note not found' }); const { title, content, topics, image_paths, status, image_prompt, worker_id } = req.body; - db.prepare(`UPDATE notes SET title=?, content=?, topics=?, image_paths=?, status=?, image_prompt=?, worker_id=?, updated_at=CURRENT_TIMESTAMP WHERE id=?`) + const nextStatus = status || note.status; + const nextUseStatus = nextStatus === 'approved' && note.status !== 'approved' ? 'pending' : note.use_status; + db.prepare(`UPDATE notes SET title=?, content=?, topics=?, image_paths=?, status=?, use_status=?, image_prompt=?, worker_id=?, updated_at=CURRENT_TIMESTAMP WHERE id=?`) .run( title || note.title, content !== undefined ? content : note.content, topics ? JSON.stringify(topics) : note.topics, image_paths ? JSON.stringify(image_paths) : note.image_paths, - status || note.status, + nextStatus, + nextUseStatus, image_prompt !== undefined ? image_prompt : note.image_prompt, worker_id !== undefined ? worker_id : note.worker_id, req.params.id @@ -177,7 +203,7 @@ function createNoteRoutes(db) { const product = db.prepare('SELECT * FROM products WHERE id = ?').get(note.product_id); const productTitle = product?.title || note.title; - db.prepare("UPDATE notes SET status='generating', updated_at=CURRENT_TIMESTAMP WHERE id=?").run(req.params.id); + db.prepare("UPDATE notes SET status='generating', use_status='pending', updated_at=CURRENT_TIMESTAMP WHERE id=?").run(req.params.id); // Cancel any pending publish tasks for this note db.prepare("UPDATE publish_tasks SET status='failed', updated_at=CURRENT_TIMESTAMP WHERE note_id = ? AND status IN ('pending', 'claimed', 'executing')").run(req.params.id); @@ -225,7 +251,7 @@ function createNoteRoutes(db) { router.post('/:id/complete', (req, res) => { const note = db.prepare('SELECT * FROM notes WHERE id = ?').get(req.params.id); if (!note) return res.status(404).json({ error: 'Note not found' }); - db.prepare("UPDATE notes SET status='published', publish_result=?, published_at=CURRENT_TIMESTAMP, updated_at=CURRENT_TIMESTAMP WHERE id=?") + db.prepare("UPDATE notes SET status='approved', use_status='used', publish_result=?, error_message='', published_at=CURRENT_TIMESTAMP, updated_at=CURRENT_TIMESTAMP WHERE id=?") .run(JSON.stringify(req.body.result || {}), req.params.id); db.prepare("UPDATE publish_tasks SET status='completed', updated_at=CURRENT_TIMESTAMP WHERE note_id=? AND status != 'completed'") .run(req.params.id); @@ -236,7 +262,7 @@ function createNoteRoutes(db) { const note = db.prepare('SELECT * FROM notes WHERE id = ?').get(req.params.id); if (!note) return res.status(404).json({ error: 'Note not found' }); const { error_message } = req.body; - db.prepare("UPDATE notes SET status='failed', error_message=?, updated_at=CURRENT_TIMESTAMP WHERE id=?") + db.prepare("UPDATE notes SET status='approved', use_status='failed', error_message=?, updated_at=CURRENT_TIMESTAMP WHERE id=?") .run(error_message || 'Unknown error', req.params.id); db.prepare("UPDATE publish_tasks SET status='failed', error_message=?, updated_at=CURRENT_TIMESTAMP WHERE note_id=? AND status != 'completed'") .run(error_message || 'Unknown error', req.params.id); diff --git a/server/routes/tasks.js b/server/routes/tasks.js index ba00aa8..b5c2315 100644 --- a/server/routes/tasks.js +++ b/server/routes/tasks.js @@ -60,7 +60,7 @@ function createTaskRoutes(db) { "UPDATE publish_tasks SET status = 'pending', claimed_by = NULL, claimed_at = NULL, updated_at = CURRENT_TIMESTAMP WHERE shop_id = ? AND status IN ('claimed', 'executing') AND claimed_at < datetime('now', '-10 minutes')" ).run(shop_id); db.prepare( - "UPDATE notes SET status = 'approved', worker_id = NULL, claimed_at = NULL, updated_at = CURRENT_TIMESTAMP WHERE shop_id = ? AND status = 'publishing' AND claimed_at < datetime('now', '-10 minutes')" + "UPDATE notes SET status = 'approved', use_status = 'pending', worker_id = NULL, claimed_at = NULL, updated_at = CURRENT_TIMESTAMP WHERE shop_id = ? AND status = 'publishing' AND claimed_at < datetime('now', '-10 minutes')" ).run(shop_id); const claim = db.transaction(() => { @@ -72,7 +72,7 @@ function createTaskRoutes(db) { "UPDATE publish_tasks SET status = 'claimed', claimed_by = ?, claimed_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND status = 'pending'" ).run(claimed_by, task.id); if (result.changes === 0) return null; - db.prepare("UPDATE notes SET status = 'publishing', worker_id = ?, claimed_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP WHERE id = ?") + db.prepare("UPDATE notes SET status = 'publishing', use_status = 'publishing', worker_id = ?, claimed_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP WHERE id = ?") .run(claimed_by, task.note_id); return task; }); @@ -112,7 +112,7 @@ function createTaskRoutes(db) { ).run(req.params.id); if (result.changes === 0) return false; db.prepare( - "UPDATE notes SET status = 'published', publish_result = ?, published_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP WHERE id = ?" + "UPDATE notes SET status = 'approved', use_status = 'used', publish_result = ?, error_message = '', published_at = CURRENT_TIMESTAMP, worker_id = NULL, claimed_at = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?" ).run(JSON.stringify(req.body.result || {}), task.note_id); return true; }); @@ -142,7 +142,7 @@ function createTaskRoutes(db) { ).run(errorMessage, req.params.id); if (result.changes === 0) return false; db.prepare( - "UPDATE notes SET status = 'failed', error_message = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?" + "UPDATE notes SET status = 'approved', use_status = 'failed', error_message = ?, worker_id = NULL, claimed_at = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?" ).run(errorMessage, task.note_id); return true; }); @@ -157,7 +157,7 @@ function createTaskRoutes(db) { if (!shop_id) return res.status(400).json({ error: 'shop_id required' }); const approvedNotes = db.prepare( - "SELECT id, shop_id FROM notes WHERE shop_id = ? AND status = 'approved'" + "SELECT id, shop_id FROM notes WHERE shop_id = ? AND status = 'approved' AND use_status = 'pending'" ).all(shop_id); if (approvedNotes.length === 0) { diff --git a/web/src/api.js b/web/src/api.js index 8b61671..16ea023 100644 --- a/web/src/api.js +++ b/web/src/api.js @@ -46,6 +46,7 @@ export const notes = { batchDelete: (ids) => request("/api/notes/batch-delete", { method: "POST", body: JSON.stringify({ ids }) }), batchApprove: (ids) => request("/api/notes/batch/approve", { method: "POST", body: JSON.stringify({ ids }) }), batchReject: (ids) => request("/api/notes/batch/reject", { method: "POST", body: JSON.stringify({ ids }) }), + batchResetPublish: (ids) => request("/api/notes/batch/publish-reset", { method: "POST", body: JSON.stringify({ ids }) }), rewrite: (id) => request(`/api/notes/${id}/rewrite`, { method: "POST" }), }; @@ -64,6 +65,7 @@ export const generate = { note: (data) => request("/api/generate/note", { method: "POST", body: JSON.stringify(data) }), batch: (data) => request("/api/generate/batch", { method: "POST", body: JSON.stringify(data) }), image: (data) => request("/api/generate/image", { method: "POST", body: JSON.stringify(data) }), + jobs: (shopId) => request(`/api/generate/jobs${shopId ? "?shop_id=" + shopId : ""}`), }; export const stats = { diff --git a/web/src/pages/NotesApproved.jsx b/web/src/pages/NotesApproved.jsx index 3228353..1e59ef8 100644 --- a/web/src/pages/NotesApproved.jsx +++ b/web/src/pages/NotesApproved.jsx @@ -19,8 +19,27 @@ const STATUS_LABEL = { }; const APPROVED_STATUSES = ["draft", "approved", "publishing", "published"]; +const USE_STATUS_MAP = { + pending: "bg-gray-100 text-gray-600", + publishing: "bg-orange-100 text-orange-700", + used: "bg-emerald-100 text-emerald-800", + failed: "bg-red-100 text-red-700", +}; +const USE_STATUS_LABEL = { + pending: "待发布", + publishing: "发布中", + used: "已使用", + failed: "使用失败", +}; const PAGE_SIZE = 10; +function getUseStatus(note) { + if (note.use_status) return note.use_status; + if (note.status === "published") return "used"; + if (note.status === "publishing") return "publishing"; + return "pending"; +} + export default function NotesApproved() { const [list, setList] = useState([]); const [shopList, setShopList] = useState([]); @@ -57,7 +76,7 @@ export default function NotesApproved() { const filteredList = useMemo(() => { let result = list; if (filterStatus) { - result = result.filter((n) => n.status === filterStatus); + result = result.filter((n) => getUseStatus(n) === filterStatus); } if (search.trim()) { const q = search.trim().toLowerCase(); @@ -107,12 +126,24 @@ export default function NotesApproved() { } catch (e) { toast(e.message, "error"); } }; + const batchResetPublish = async () => { + if (selected.size === 0) { toast("请先选择笔记", "error"); return; } + if (!window.confirm("确定将选中的使用失败笔记重置为待发布吗?")) return; + try { + const result = await notes.batchResetPublish([...selected]); + toast(`已重置 ${result.updated || 0} 条使用失败笔记`, "success"); + setSelected(new Set()); + load(); + } catch (e) { toast(e.message, "error"); } + }; + const handleExport = () => { const data = filteredList.map((n) => ({ 红薯名称: shopList.find((s) => s.id === n.shop_id)?.shop_name || "", 标题: n.title, 正文内容: n.content, 热推话题: Array.isArray(n.topics) ? n.topics.join(", ") : n.topics, + 使用状态: USE_STATUS_LABEL[getUseStatus(n)] || getUseStatus(n), 状态: STATUS_LABEL[n.status] || n.status, 创建时间: n.created_at, 更新时间: n.updated_at, @@ -152,11 +183,11 @@ export default function NotesApproved() { setSearch(e.target.value)} />