diff --git a/ops-portal/public/app.js b/ops-portal/public/app.js index 40f03ea..0cbe848 100644 --- a/ops-portal/public/app.js +++ b/ops-portal/public/app.js @@ -122,7 +122,7 @@ async function openOverview() { const works = el('section', undefined, 'report-section'); works.append(el('h2', '已发布作品'), el('p', `采样时间:${market.observed_at ? dateTime(market.observed_at) : '暂无'};播放量不是曝光或完播率。`, 'muted')); for (const v of market.account_videos || []) { const p = el('p'); if (v.bvid) { const a = el('a', v.title); a.href = `https://www.bilibili.com/video/${v.bvid}/`; a.target = '_blank'; a.rel = 'noopener noreferrer'; p.append(a); } else p.append(el('span', v.title)); p.append(document.createTextNode(` · 播放 ${v.views ?? '未知'} · 点赞 ${v.likes ?? '未知'} · 评论 ${v.comments ?? '未知'}`)); works.append(p); } host.append(works); const budget = el('section', undefined, 'report-section'); budget.append(el('h2', '计划投入'), el('p', (strategy.budget || []).map(x => `${x.name} ${x.percent}%`).join(' · ')), el('p', strategy.budget_note || '', 'muted')); host.append(budget); - const work = el('section', undefined, 'report-section'); work.append(el('h2', '持续工作')); const cats = {content:'内容',engineering:'工程',research:'研究',community:'社区',management:'管理'}, status = {pending:'待继续',running:'进行中',done:'已完成',blocked:'需要处理'}, list = el('ul'); for (const t of queue.tasks || []) if (t.status !== 'done') list.append(el('li', `${cats[t.category] || '管理'} · ${t.title} · ${status[t.status] || t.status}`)); work.append(list); host.append(work); + const work = el('section', undefined, 'report-section'); work.append(el('h2', '持续工作')); const cats = {content:'内容',engineering:'工程',research:'研究',community:'社区',management:'管理'}, status = {pending:'待执行',running:'进行中',waiting:'等待触发条件',done:'已完成',blocked:'需要处理'}, list = el('ul'); for (const t of queue.tasks || []) if (t.status !== 'done') list.append(el('li', `${cats[t.category] || '管理'} · ${t.title} · ${status[t.status] || t.status}`)); work.append(list); host.append(work); const trends = el('section', undefined, 'report-section'); trends.append(el('h2', 'B 站公开榜单快照'), el('p', '单时点观察,基于标题和公开数据,不能代表趋势或保证效果。', 'muted')); const ul = el('ul'); for (const v of market.ranking || []) { const li = el('li'); if (v.bvid) { const a = el('a', v.title); a.href = `https://www.bilibili.com/video/${v.bvid}/`; a.target = '_blank'; a.rel = 'noopener noreferrer'; li.append(a); } else li.append(el('span', v.title)); li.append(document.createTextNode(` · ${v.owner} · ${v.views ?? '未知'} 播放`)); ul.append(li); } trends.append(ul); host.append(trends); } catch (error) { host.replaceChildren(el('p', error.message, 'error')); } } @@ -144,7 +144,7 @@ async function openExecution(updateHistory = true) { } const schedules = el('section', undefined, 'report-section'); schedules.append(el('h2', '服务与计划'), el('p', '有待执行任务且运行条件满足时,守护程序约每两分钟尝试启动;下方为额外的固定时刻。', 'muted')); const list = el('ul'); for (const u of data.units || []) list.append(el('li', `${u.label} · ${u.state === 'activating' || u.state === 'active' ? '正在运行' : u.state === 'inactive' ? '当前空闲' : u.state || '未知'} · 定时器${u.timer_state === 'active' ? '已启用' : u.timer_state || '未知'} · 下次固定时刻 ${u.next_run || '未知'} · 上次结果 ${u.result || '暂无'}`)); schedules.append(list); host.append(schedules); - const tasks = el('section', undefined, 'report-section'); tasks.append(el('h2', '任务列表')); const status = {pending:'待执行或等待退避',running:'已领取',done:'已完成',blocked:'已阻塞'}; const ul = el('ul'); for (const t of queue.tasks || []) ul.append(el('li', `${t.title} · ${status[t.status] || t.status} · 尝试 ${t.attempts} 次 · ${t.updated_at ? dateTime(t.updated_at) : '时间未知'}`)); tasks.append(ul); host.append(tasks); + const tasks = el('section', undefined, 'report-section'); tasks.append(el('h2', '任务列表')); const status = {pending:'待执行',running:'已领取',waiting:'等待触发条件',done:'已完成',blocked:'已阻塞'}; const ul = el('ul'); for (const t of queue.tasks || []) ul.append(el('li', `${t.title} · ${status[t.status] || t.status} · 尝试 ${t.attempts} 次 · ${t.updated_at ? dateTime(t.updated_at) : '时间未知'}`)); tasks.append(ul); host.append(tasks); const past = el('section', undefined, 'report-section'); past.append(el('h2', '主运营执行历史'), el('p', '记录来自每轮执行汇总;进程成功不代表整个任务已经完成。详细产出和复盘见每日计划与复盘。', 'muted')); const results = {complete:'进程成功',failed:'失败',timeout:'超时',incomplete:'未记录进度',preflight_failed:'前置检查失败',unknown:'状态未知'}; const rows = el('ul'); for (const x of data.history || []) rows.append(el('li', `${dateTime(x.started_at)} · ${x.title} · ${results[x.status] || '未知'}${x.elapsed_seconds === null ? '' : ` · ${x.elapsed_seconds} 秒`}`)); if (!(data.history || []).length) rows.append(el('li', '暂无执行汇总')); past.append(rows); host.append(past); } catch (error) { host.replaceChildren(el('p', error.message, 'error')); } @@ -160,7 +160,7 @@ async function openDaily() { const queue = await api('/api/workqueue'); const work = el('section', undefined, 'report-section'); work.append(el('h2', '持续工作')); - const status = { pending: '待继续', running: '进行中', done: '已完成', blocked: '需要处理' }; + const status = { pending: '待执行', running: '进行中', waiting: '等待触发条件', 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); } diff --git a/ops-portal/server.mjs b/ops-portal/server.mjs index 737fe86..c7c6eb0 100644 --- a/ops-portal/server.mjs +++ b/ops-portal/server.mjs @@ -164,10 +164,10 @@ export function createApp(options = process.env) { 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 valid = new Set(['pending', 'running', 'waiting', 'done', 'blocked']); const categories = new Set(['content', 'engineering', 'research', 'community', 'management']); 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, + .map(t => ({ title: t.id.startsWith('interaction-') ? '核查新互动' : t.title.slice(0, 100), status: t.status === 'pending' && t.dispatch_ready !== true ? 'waiting' : t.status, category: categories.has(t.category) ? t.category : 'management', attempts: Number.isInteger(t.attempts) ? t.attempts : 0, updated_at: typeof t.updated_at === 'string' ? t.updated_at : null })); return json(res, 200, { tasks }); } diff --git a/ops-portal/test/server.test.mjs b/ops-portal/test/server.test.mjs index 116d852..2e47fb3 100644 --- a/ops-portal/test/server.test.mjs +++ b/ops-portal/test/server.test.mjs @@ -194,3 +194,19 @@ test('execution status is private and absent data degrades safely', async t => { const data = await (await request('/api/execution', { headers:{cookie} })).json(); assert.equal(data.history[0].title, '任务'); assert.equal(data.history[0].status, 'complete'); }); + +test('waiting queue tasks remain visible to the owner', async t => { + const { directory, request, login } = await fixture(t); + await writeFile(path.join(directory, 'workqueue.json'), JSON.stringify({schema:1,tasks:[{id:'idea',title:'等待素材',status:'waiting'}]})); + const cookie=(await login()).headers.get('set-cookie').split(';')[0]; + const result=await (await request('/api/workqueue',{headers:{cookie}})).json(); + assert.equal(result.tasks[0].status,'waiting'); +}); + +test('legacy pending tasks are not presented as ready to dispatch', async t => { + const { directory, request, login } = await fixture(t); + await writeFile(path.join(directory,'workqueue.json'),JSON.stringify({schema:1,tasks:[{id:'old',title:'旧任务',status:'pending'}]})); + const cookie=(await login()).headers.get('set-cookie').split(';')[0]; + const value=await (await request('/api/workqueue',{headers:{cookie}})).json(); + assert.equal(value.tasks[0].status,'waiting'); +}); diff --git a/ops/report.py b/ops/report.py index 04f8fdf..2874172 100644 --- a/ops/report.py +++ b/ops/report.py @@ -234,9 +234,10 @@ def build_report(data_dir, snapshot=False, now=None): next_actions = [i['title'] for i in tasks.get('items', []) if i.get('status') in ('todo', 'in_progress')][:8] # A task queue is a current snapshot; it does not reconstruct the past reporting period. if isinstance(queue, dict) and queue.get('schema') == 1 and isinstance(queue.get('tasks'), list): - safe = [i for i in queue['tasks'] if isinstance(i, dict) and i.get('status') in ('pending', 'running', 'blocked') and isinstance(i.get('title'), str) and isinstance(i.get('id'), str)] - safe.sort(key=lambda i: (0 if i['status'] == 'running' else 1 if i['status'] == 'pending' else 2, i.get('updated_at') or '')) - next_actions = [('核查新互动' if i['id'].startswith('interaction-') else i['title'][:100]) + ('(已阻塞)' if i['status'] == 'blocked' else '') for i in safe[:8]] + safe = [i for i in queue['tasks'] if isinstance(i, dict) and i.get('status') in ('pending', 'running', 'waiting', 'blocked') and isinstance(i.get('title'), str) and isinstance(i.get('id'), str)] + safe = [{**i, 'status': 'waiting' if i['status'] == 'pending' and i.get('dispatch_ready') is not True else i['status']} for i in safe] + safe.sort(key=lambda i: (0 if i['status'] == 'running' else 1 if i['status'] == 'pending' else 2 if i['status'] == 'waiting' else 3, i.get('updated_at') or '')) + next_actions = [('核查新互动' if i['id'].startswith('interaction-') else i['title'][:100]) + ('(已阻塞)' if i['status'] == 'blocked' else '(等待触发条件)' if i['status'] == 'waiting' else '') for i in safe[:8]] current = next(({'title': '核查新互动' if i['id'].startswith('interaction-') else i['title'][:100]} for i in safe if i['status'] == 'running'), None) identifier = end.strftime('%G-W%V') + ('-snapshot-' + now.strftime('%Y%m%dT%H%M%S') if snapshot else '') diff --git a/ops/test_report.py b/ops/test_report.py index d816616..70c6b35 100644 --- a/ops/test_report.py +++ b/ops/test_report.py @@ -81,10 +81,10 @@ class AccountingTests(unittest.TestCase): with tempfile.TemporaryDirectory() as directory: root = Path(directory) (root / 'tasks.json').write_text(json.dumps({'items':[{'title':'旧计划','status':'todo'}]})) - (root / 'workqueue.json').write_text(json.dumps({'schema':1,'tasks':[{'id':'interaction-private','title':'私人消息内容','status':'running'}, {'id':'video','title':'制作挑战视频','status':'pending'}, {'id':'done','title':'已完成','status':'done'}]})) + (root / 'workqueue.json').write_text(json.dumps({'schema':1,'tasks':[{'id':'interaction-private','title':'私人消息内容','status':'running'}, {'id':'video','title':'制作挑战视频','status':'pending','dispatch_ready':True}, {'id':'wait','title':'等待反馈','status':'waiting'}, {'id':'done','title':'已完成','status':'done'}]})) path,_ = report.build_report(root, now=dt.datetime(2026,11,6,20,tzinfo=report.TZ)) value=report.read_json(path) - self.assertEqual(value['next_actions'],['核查新互动','制作挑战视频']) + self.assertEqual(value['next_actions'],['核查新互动','制作挑战视频','等待反馈(等待触发条件)']) self.assertNotIn('私人消息内容',json.dumps(value,ensure_ascii=False)) self.assertIn('workqueue.json',value['sources']['next_actions_source'])