Compare commits

...

6 Commits

Author SHA1 Message Date
wuyang 5e7cae0bee Show waiting tasks accurately in operations views 2026-11-05 11:02:42 +08:00
wuyang f882c9a831 Use active work queue for weekly priorities 2026-11-04 17:42:20 +08:00
wuyang 9e5f16dfb8 Show daily scheduled usage in operations view 2026-11-04 14:44:55 +08:00
wuyang ed978ac0ae Reflect unrestricted daily operations in dashboard 2026-11-04 14:42:19 +08:00
wuyang a5b0b5c9e8 Clarify work-trigger and fixed schedules 2026-11-04 12:23:43 +08:00
wuyang 204387efa2 Show actual operations execution and history 2026-11-04 12:22:08 +08:00
6 changed files with 83 additions and 9 deletions
+30 -4
View File
@@ -108,8 +108,8 @@ function renderLedger() {
table.append(head, body); wrapper.append(table); host.append(wrapper);
}
function showView(view) {
for (const name of ['overview', 'report', 'evolution', 'daily']) $(`#${name}-view`).hidden = view !== name;
for (const [selector, active] of [['#show-overview', view === 'overview'], ['#show-report', view === 'report'], ['#show-evolution', view === 'evolution'], ['#show-daily', view === 'daily']]) {
for (const name of ['overview', 'execution', 'report', 'evolution', 'daily']) $(`#${name}-view`).hidden = view !== name;
for (const [selector, active] of [['#show-overview', view === 'overview'], ['#show-execution', view === 'execution'], ['#show-report', view === 'report'], ['#show-evolution', view === 'evolution'], ['#show-daily', view === 'daily']]) {
$(selector).classList.toggle('active', active); $(selector).setAttribute('aria-pressed', String(active));
}
}
@@ -122,10 +122,33 @@ 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')); }
}
async function openExecution(updateHistory = true) {
showView('execution'); if (updateHistory) history.replaceState(null, '', '/?view=execution');
const host = $('#execution-content'); host.replaceChildren(el('p', '正在读取运行状态…', 'muted'));
try {
const [data, queue] = await Promise.all([api('/api/execution'), api('/api/workqueue')]); host.replaceChildren();
const sampled = Date.parse(data.observed_at || ''); const stale = !Number.isFinite(sampled) || Date.now() - sampled > 3 * 60 * 1000;
host.append(el('p', `状态采样:${data.observed_at ? dateTime(data.observed_at) : '暂无'}${stale ? ' · 状态可能过期,请检查采集服务' : ' · 约每两分钟刷新'}`, stale ? 'error' : 'muted'));
const now = el('section', undefined, 'report-section'); now.append(el('h2', '当前执行'));
const cycle = (data.units || []).find(x => x.id === 'cycle'); const active = cycle && ['active','activating'].includes(cycle.state);
if (data.daily_budget) now.append(el('p', data.daily_budget.limit == null ? `今日主运营尝试:${data.daily_budget.attempts} 次 · 暂无每日固定次数上限,持续记录用量。` : `今日主运营尝试:${data.daily_budget.attempts} / ${data.daily_budget.limit}。`, 'muted'));
now.append(el('p', stale ? '快照已过期,不能确定当前运行情况。' : active ? '主运营进程正在运行。' : cycle?.state === 'inactive' ? '主运营当前空闲,等待可执行任务或预算恢复。' : `主运营状态:${cycle?.state || '未知'}`));
for (const t of data.running_tasks || []) now.append(el('p', `领取任务:${t.title} · ${t.updated_at ? dateTime(t.updated_at) : '时间未知'}`)); host.append(now);
if (data.daily_usage) {
const usage = el('section', undefined, 'report-section'); usage.append(el('h2', '今日运行用量'), el('p', '仅统计已结束的定时运营;输入包含缓存输入。套餐不是按 token 逐笔收费,实际额外账单另行核对。', 'muted'));
for (const [key, label] of [['subscription', '订阅'], ['api', 'API 备用']]) { const v = data.daily_usage[key] || {}; usage.append(el('p', `${label} · ${v.runs ?? 0} 轮 · 输入 ${v.input_tokens ?? 0}(其中缓存 ${v.cached_input_tokens ?? 0})· 输出 ${v.output_tokens ?? 0} token`)); } host.append(usage);
}
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:'已领取',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')); }
}
async function openDaily() {
showView('daily');
const host = $('#daily-content'); host.replaceChildren(el('p', '正在读取每日记录…', 'muted'));
@@ -137,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); }
@@ -205,8 +228,11 @@ async function loadDashboard() {
if (selected && dashboard.reports.some(r => r.id === selected)) await openReport(selected); else renderReport(dashboard.latest);
if (initialView === 'evolution') await openEvolution();
if (initialView === 'daily') await openDaily();
if (initialView === 'execution') await openExecution(false);
if (!selected && !initialView) await openOverview();
}
$('#show-execution').addEventListener('click', () => openExecution());
setInterval(() => { if (dashboard && !$('#execution-view').hidden) openExecution(false); }, 60000);
$('#show-overview').addEventListener('click', openOverview);
$('#show-evolution').addEventListener('click', openEvolution);
$('#show-daily').addEventListener('click', openDaily);
+1 -1
View File
@@ -16,7 +16,7 @@
<div id="dashboard-view" hidden>
<header class="topbar"><a class="wordmark" href="/">pawplay<span>运营台</span></a><div class="top-right"><span class="private-pill">私有档案</span><button id="logout" class="quiet">退出</button></div></header>
<div class="workspace"><aside class="sidebar"><div class="sidebar-heading"><p class="eyebrow">每周运营</p><h2>进展档案</h2></div><nav id="report-nav" aria-label="历史周报"></nav><div class="sidebar-foot"><span class="status-dot"></span>北京时间归档<br><span class="muted">费用与收益独立核算</span></div></aside>
<main class="main"><nav class="view-tabs" aria-label="浏览内容"><button id="show-overview" class="active" type="button" aria-pressed="true">运营全景</button><button id="show-report" type="button" aria-pressed="false">运营报告</button><button id="show-evolution" type="button" aria-pressed="false">演变路径</button><button id="show-daily" type="button" aria-pressed="false">每日计划与复盘</button></nav><section id="overview-view"><div class="page-heading"><div><p class="eyebrow">XINGQI · OPERATIONS</p><h1>星栖运营全景</h1><p class="muted">方向、作品、研究和预算,放在一起看。</p></div></div><div id="overview-content"></div></section><div id="report-view" hidden><div class="page-heading"><div><p class="eyebrow">PAWPLAY · WEEKLY REVIEW</p><h1 id="report-title">运营概览</h1><p id="report-period" class="muted"></p></div><a id="download-report" class="download" hidden>下载 JSON ↓</a></div><p id="global-error" class="error" role="alert"></p><div id="report-content"></div><section class="ledger-section"><div class="section-heading"><h2>收支台账</h2><span class="muted">现金、分摊、预估分别保留</span></div><div id="ledger"></div></section></div><section id="evolution-view" hidden><div class="page-heading"><div><p class="eyebrow">PAWPLAY · EVOLUTION</p><h1>演变路径</h1><p class="muted">从起因到下一步,回看每次探索的依据与决定。</p></div></div><div id="evolution-content"></div></section><section id="daily-view" hidden><div class="page-heading"><div><p class="eyebrow">PAWPLAY · DAILY</p><h1>每日计划与复盘</h1><p class="muted">按日期查看计划、执行、证据与下一步。</p></div></div><div id="daily-content"></div></section><footer class="main-footer">PawPlay 猫爪游乐场 <span>让每一笔投入有依据,让每一步进展可回看。</span></footer></main>
<main class="main"><nav class="view-tabs" aria-label="浏览内容"><button id="show-overview" class="active" type="button" aria-pressed="true">运营全景</button><button id="show-execution" type="button" aria-pressed="false">运行与历史</button><button id="show-report" type="button" aria-pressed="false">运营报告</button><button id="show-evolution" type="button" aria-pressed="false">演变路径</button><button id="show-daily" type="button" aria-pressed="false">每日计划与复盘</button></nav><section id="overview-view"><div class="page-heading"><div><p class="eyebrow">XINGQI · OPERATIONS</p><h1>星栖运营全景</h1><p class="muted">方向、作品、研究和预算,放在一起看。</p></div></div><div id="overview-content"></div></section><section id="execution-view" hidden><div class="page-heading"><div><p class="eyebrow">XINGQI · EXECUTION</p><h1>运行与历史</h1><p class="muted">区分实际运行、等待执行和已结束的工作。</p></div></div><div id="execution-content"></div></section><div id="report-view" hidden><div class="page-heading"><div><p class="eyebrow">PAWPLAY · WEEKLY REVIEW</p><h1 id="report-title">运营概览</h1><p id="report-period" class="muted"></p></div><a id="download-report" class="download" hidden>下载 JSON ↓</a></div><p id="global-error" class="error" role="alert"></p><div id="report-content"></div><section class="ledger-section"><div class="section-heading"><h2>收支台账</h2><span class="muted">现金、分摊、预估分别保留</span></div><div id="ledger"></div></section></div><section id="evolution-view" hidden><div class="page-heading"><div><p class="eyebrow">PAWPLAY · EVOLUTION</p><h1>演变路径</h1><p class="muted">从起因到下一步,回看每次探索的依据与决定。</p></div></div><div id="evolution-content"></div></section><section id="daily-view" hidden><div class="page-heading"><div><p class="eyebrow">PAWPLAY · DAILY</p><h1>每日计划与复盘</h1><p class="muted">按日期查看计划、执行、证据与下一步。</p></div></div><div id="daily-content"></div></section><footer class="main-footer">PawPlay 猫爪游乐场 <span>让每一笔投入有依据,让每一步进展可回看。</span></footer></main>
</div>
</div>
<p id="loading" class="loading" role="status">正在打开运营台…</p>
+3 -2
View File
@@ -154,6 +154,7 @@ export function createApp(options = process.env) {
}
if (route.startsWith('/api/')) {
if (!session(req)) return json(res, 401, { error: '请先登录' });
if (req.method === 'GET' && route === '/api/execution') return json(res, 200, await jsonFile('ops-status.json', { schema: 1, observed_at: null, units: [], running_tasks: [], history: [] }));
if (req.method === 'GET' && route === '/api/overview') {
const strategy = await jsonFile('ops-strategy.json', {}), market = await jsonFile('market-latest.json', {});
const short = (v, n = 300) => typeof v === 'string' ? v.slice(0, n) : '';
@@ -163,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 });
}
+25
View File
@@ -185,3 +185,28 @@ test('overview is authenticated and exposes only selected fields', async t => {
assert.doesNotMatch(text, /SECRET|evil/); const data = JSON.parse(text);
assert.equal(data.strategy.mission, '星栖'); assert.equal(data.market.ranking[0].bvid, '');
});
test('execution status is private and absent data degrades safely', async t => {
const { directory, request, login } = await fixture(t);
await writeFile(path.join(directory, 'ops-status.json'), JSON.stringify({schema:1,observed_at:'2026-11-04T04:00:00Z',units:[],running_tasks:[],history:[{title:'任务',status:'complete'}]}));
assert.equal((await request('/api/execution')).status, 401);
const cookie = (await login()).headers.get('set-cookie').split(';')[0];
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');
});
+12 -2
View File
@@ -193,6 +193,7 @@ def build_report(data_dir, snapshot=False, now=None):
ledger = read_json(data_dir / 'ledger.json', {'entries': []})
content = read_json(data_dir / 'content.json', {'items': []})
tasks = read_json(data_dir / 'tasks.json', {'items': []})
queue = read_json(data_dir / 'workqueue.json', None)
findings = read_json(data_dir / 'findings.json', {})
api = read_json(data_dir / 'deepseek-usage.json', {})
entries = []
@@ -230,6 +231,15 @@ def build_report(data_dir, snapshot=False, now=None):
if not observed_this_period(findings.get('checked_at')):
risks.append('基础验收记录未在本周期更新,历史成果未重复计作本周完成。')
current = next((i for i in tasks.get('items', []) if i.get('status') == 'in_progress'), 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', '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 '')
report = {
'id': identifier,
@@ -239,7 +249,7 @@ def build_report(data_dir, snapshot=False, now=None):
'summary': ('当前推进:' + current['title']) if current else '根据已归档的项目记录汇总;缺失数据保留待核算。',
'highlights': highlights,
'risks': risks,
'next_actions': [i['title'] for i in tasks.get('items', []) if i.get('status') in ('todo', 'in_progress')][:8],
'next_actions': next_actions,
'decisions': findings.get('decisions', []),
'metrics': {
'published_project_videos': len(published),
@@ -260,7 +270,7 @@ def build_report(data_dir, snapshot=False, now=None):
},
'revenue': {'settled_cny': round(sum(e['amount'] for e in incomes), 2) if incomes else None, 'estimated_cny': None,
'notes': ['播放量不是结算收入,自有产品推广稿件是否纳入激励需单独验证。', '未结算或未归因到PawPlay的收益不算项目收入。']},
'sources': {'findings_checked_at': findings.get('checked_at'), 'ledger_entries_in_period': len(entries), 'content_ledger': 'content.json', 'usage_source': '本机会话计数(只保存汇总,不发布对话)'},
'sources': {'findings_checked_at': findings.get('checked_at'), 'ledger_entries_in_period': len(entries), 'content_ledger': 'content.json', 'next_actions_source': 'workqueue.json:报告生成时的任务快照' if isinstance(queue, dict) and queue.get('schema') == 1 and isinstance(queue.get('tasks'), list) else 'tasks.json:静态任务清单', 'usage_source': '本机会话计数(只保存汇总,不发布对话)'},
}
repo = collector.get('repository')
if repo:
+12
View File
@@ -76,5 +76,17 @@ class AccountingTests(unittest.TestCase):
self.assertIsNone(value['cash_settlement_usd'])
def test_report_uses_current_queue_and_redacts_interaction_titles(self):
import json
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','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.assertNotIn('私人消息内容',json.dumps(value,ensure_ascii=False))
self.assertIn('workqueue.json',value['sources']['next_actions_source'])
if __name__ == '__main__':
unittest.main()