From 713c3cd0a22d3c1ca0fb43a5329d41e5483c1ef8 Mon Sep 17 00:00:00 2001 From: wuyang <5700876+banisherwy@user.noreply.gitee.com> Date: Wed, 4 Nov 2026 10:11:04 +0800 Subject: [PATCH] Show persistent operations queue --- ops-portal/public/app.js | 10 ++++++++++ ops-portal/server.mjs | 9 +++++++++ ops-portal/test/server.test.mjs | 15 ++++++++++++++- 3 files changed, 33 insertions(+), 1 deletion(-) diff --git a/ops-portal/public/app.js b/ops-portal/public/app.js index c204e47..e1a4a23 100644 --- a/ops-portal/public/app.js +++ b/ops-portal/public/app.js @@ -118,6 +118,16 @@ async function openDaily() { try { const data = await api('/api/daily'); host.replaceChildren(); + try { + const queue = await api('/api/workqueue'); + const work = el('section', undefined, 'report-section'); + work.append(el('h2', '持续工作')); + const status = { pending: '待继续', running: '进行中', done: '已完成', blocked: '需要处理' }; + const tasks = Array.isArray(queue.tasks) ? queue.tasks : []; + if (!tasks.length) work.append(el('p', '暂无任务。', 'muted')); + else { const list = el('ul'); for (const task of tasks) list.append(el('li', `${task.title} · ${status[task.status] || task.status} · ${task.updated_at ? dateTime(task.updated_at) : '时间待确认'}`)); work.append(list); } + host.append(work); + } catch { host.append(el('p', '暂时无法读取持续工作状态。', 'error')); } if (data.invalid_count) host.append(el('p', `${data.invalid_count} 条记录无法读取,请检查数据文件。`, 'error')); if (!data.records.length) host.append(el('p', '暂无每日记录。', 'empty')); for (const record of data.records) { diff --git a/ops-portal/server.mjs b/ops-portal/server.mjs index 1d48f76..13aa001 100644 --- a/ops-portal/server.mjs +++ b/ops-portal/server.mjs @@ -154,6 +154,15 @@ export function createApp(options = process.env) { } if (route.startsWith('/api/')) { if (!session(req)) return json(res, 401, { error: '请先登录' }); + if (req.method === 'GET' && route === '/api/workqueue') { + const value = await jsonFile('workqueue.json', { schema: 1, tasks: [] }); + if (value?.schema !== 1 || !Array.isArray(value.tasks)) return json(res, 500, { error: '工作队列格式无效' }); + const valid = new Set(['pending', 'running', 'done', 'blocked']); + const tasks = value.tasks.filter(t => t && typeof t.id === 'string' && typeof t.title === 'string' && valid.has(t.status)) + .map(t => ({ title: t.id.startsWith('interaction-') ? '核查新互动' : t.title.slice(0, 100), status: t.status, + attempts: Number.isInteger(t.attempts) ? t.attempts : 0, updated_at: typeof t.updated_at === 'string' ? t.updated_at : null })); + return json(res, 200, { tasks }); + } if (req.method === 'GET' && route === '/api/evolution') return json(res, 200, await jsonFile('evolution.json', { schema_version: 1, events: [] })); if (req.method === 'GET' && route === '/api/daily') return json(res, 200, await dailyRecords()); if (req.method === 'GET' && route === '/api/dashboard') { diff --git a/ops-portal/test/server.test.mjs b/ops-portal/test/server.test.mjs index 5550186..68b5baf 100644 --- a/ops-portal/test/server.test.mjs +++ b/ops-portal/test/server.test.mjs @@ -43,7 +43,7 @@ test('password hashing uses random salts and checks a supplied password', () => test('anonymous users can see health/login shell but cannot read private reports or ledger', async t => { const { request } = await fixture(t); - for (const endpoint of ['/api/dashboard', '/api/evolution', '/api/reports/2026-W45', '/api/reports/2026-W45/download', '/api/ledger']) { + for (const endpoint of ['/api/dashboard', '/api/evolution', '/api/workqueue', '/api/reports/2026-W45', '/api/reports/2026-W45/download', '/api/ledger']) { const r = await request(endpoint); assert.equal(r.status, 401); assert.equal(r.headers.get('cache-control'), 'no-store'); assert.doesNotMatch(await r.text(), /PRIVATE|entries|cash_usd/); } @@ -52,6 +52,19 @@ test('anonymous users can see health/login shell but cannot read private reports assert.equal((await request('/reports/2026-W45.json')).status, 404); }); +test('work queue exposes only safe progress fields behind login', async t => { + const { directory, request, login } = await fixture(t); + await writeFile(path.join(directory, 'workqueue.json'), JSON.stringify({ schema: 1, tasks: [ + {id:'interaction-private-a', title:'PRIVATE MESSAGE CONTENT', status:'pending', details:'PRIVATE MESSAGE', checkpoint:'PRIVATE CONTENT', attempts:1}, + {id:'pilot',title:'动画试镜',status:'running',details:'SECRET',attempts:2,updated_at:'2026-11-04T01:00:00Z'} + ] })); + assert.equal((await request('/api/workqueue')).status, 401); + const auth = await login(); const cookie = auth.headers.get('set-cookie').split(';')[0]; + const response = await request('/api/workqueue', { headers: { cookie } }); + const body = await response.text(); assert.doesNotMatch(body, /PRIVATE|SECRET/); + const data = JSON.parse(body); assert.equal(data.tasks[0].title, '核查新互动'); assert.equal(data.tasks[1].status, 'running'); +}); + test('login protects API and download, preserves unknown money, logout revokes copied cookie', async t => { const { request, login } = await fixture(t); const response = await login(); assert.equal(response.status, 200);