PAWPLAY · WEEKLY REVIEW
运营概览
收支台账
现金、分摊、预估分别保留PAWPLAY · EVOLUTION
演变路径
从起因到下一步,回看每次探索的依据与决定。
PAWPLAY · DAILY
每日计划与复盘
按日期查看计划、执行、证据与下一步。
diff --git a/ops-portal/public/app.js b/ops-portal/public/app.js index e1a4a23..20bdcda 100644 --- a/ops-portal/public/app.js +++ b/ops-portal/public/app.js @@ -106,11 +106,24 @@ function renderLedger() { table.append(head, body); wrapper.append(table); host.append(wrapper); } function showView(view) { - for (const name of ['report', 'evolution', 'daily']) $(`#${name}-view`).hidden = view !== name; - for (const [selector, active] of [['#show-report', view === 'report'], ['#show-evolution', view === 'evolution'], ['#show-daily', view === 'daily']]) { + 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']]) { $(selector).classList.toggle('active', active); $(selector).setAttribute('aria-pressed', String(active)); } } +async function openOverview() { + showView('overview'); history.replaceState(null, '', '/'); + const host = $('#overview-content'); host.replaceChildren(el('p', '正在读取运营全景…', 'muted')); + try { + const [data, queue] = await Promise.all([api('/api/overview'), api('/api/workqueue')]); host.replaceChildren(); const strategy = data.strategy || {}, market = data.market || {}; + const hero = el('section', undefined, 'report-section'); hero.append(el('h2', '方向与当前阶段'), el('p', strategy.mission || '方向待补充'), el('p', strategy.phase || '', 'muted')); const pillars = el('ul'); for (const item of strategy.pillars || []) pillars.append(el('li', item)); hero.append(pillars, el('p', strategy.cadence || '', 'muted')); host.append(hero); + 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 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 openDaily() { showView('daily'); const host = $('#daily-content'); host.replaceChildren(el('p', '正在读取每日记录…', 'muted')); @@ -190,7 +203,9 @@ 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 (!selected && !initialView) await openOverview(); } +$('#show-overview').addEventListener('click', openOverview); $('#show-evolution').addEventListener('click', openEvolution); $('#show-daily').addEventListener('click', openDaily); $('#show-report').addEventListener('click', () => { showView('report'); history.replaceState(null, '', currentReportId ? `/?report=${encodeURIComponent(currentReportId)}` : '/'); }); diff --git a/ops-portal/public/index.html b/ops-portal/public/index.html index 2537170..0211b4f 100644 --- a/ops-portal/public/index.html +++ b/ops-portal/public/index.html @@ -16,7 +16,7 @@
PAWPLAY · WEEKLY REVIEW
PAWPLAY · EVOLUTION
从起因到下一步,回看每次探索的依据与决定。
PAWPLAY · DAILY
按日期查看计划、执行、证据与下一步。
XINGQI · OPERATIONS
方向、作品、研究和预算,放在一起看。
PAWPLAY · WEEKLY REVIEW
PAWPLAY · EVOLUTION
从起因到下一步,回看每次探索的依据与决定。
PAWPLAY · DAILY
按日期查看计划、执行、证据与下一步。
正在打开运营台…
diff --git a/ops-portal/server.mjs b/ops-portal/server.mjs index 13aa001..ea88f0c 100644 --- a/ops-portal/server.mjs +++ b/ops-portal/server.mjs @@ -154,13 +154,20 @@ export function createApp(options = process.env) { } if (route.startsWith('/api/')) { if (!session(req)) return json(res, 401, { error: '请先登录' }); + 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) : ''; + const video = v => ({ bvid: /^BV[0-9A-Za-z]+$/.test(v?.bvid || '') ? v.bvid : '', title: short(v?.title, 150), owner: short(v?.owner, 80), views: Number.isFinite(v?.views) ? v.views : null, likes: Number.isFinite(v?.likes) ? v.likes : null, comments: Number.isFinite(v?.comments) ? v.comments : null }); + return json(res, 200, { strategy: { mission: short(strategy.mission), pillars: Array.isArray(strategy.pillars) ? strategy.pillars.slice(0, 6).map(v => short(v)) : [], cadence: short(strategy.cadence), phase: short(strategy.phase), budget_note: short(strategy.budget_note), budget: Array.isArray(strategy.budget) ? strategy.budget.slice(0, 8).map(v => ({ name: short(v?.name, 50), percent: Number.isFinite(v?.percent) ? v.percent : null })) : [] }, market: { observed_at: short(market.observed_at), ranking: Array.isArray(market.ranking) ? market.ranking.slice(0, 5).map(video) : [], account_videos: Array.isArray(market.account_videos) ? market.account_videos.slice(0, 20).map(video) : [] } }); + } 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 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, - attempts: Number.isInteger(t.attempts) ? t.attempts : 0, updated_at: typeof t.updated_at === 'string' ? t.updated_at : null })); + 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 }); } if (req.method === 'GET' && route === '/api/evolution') return json(res, 200, await jsonFile('evolution.json', { schema_version: 1, events: [] })); diff --git a/ops-portal/test/server.test.mjs b/ops-portal/test/server.test.mjs index 68b5baf..fb024b8 100644 --- a/ops-portal/test/server.test.mjs +++ b/ops-portal/test/server.test.mjs @@ -174,3 +174,14 @@ test('daily symlinks cannot disclose files outside DATA_DIR', async t => { assert.equal(response.status, 200); const text = await response.text(); assert.doesNotMatch(text, /OUTSIDE-SECRET/); assert.deepEqual(JSON.parse(text), { records: [], invalid_count: 1 }); }); + +test('overview is authenticated and exposes only selected fields', async t => { + const { directory, request, login } = await fixture(t); + await writeFile(path.join(directory, 'ops-strategy.json'), JSON.stringify({ mission:'星栖', private:'SECRET' })); + await writeFile(path.join(directory, 'market-latest.json'), JSON.stringify({ observed_at:'2026-11-04', ranking:[{bvid:'https://evil.test',title:'榜单',views:1,private:'SECRET'}],account_videos:[] })); + assert.equal((await request('/api/overview')).status, 401); + const cookie = (await login()).headers.get('set-cookie').split(';')[0]; + const response = await request('/api/overview', { headers:{cookie} }); const text = await response.text(); + assert.doesNotMatch(text, /SECRET|evil/); const data = JSON.parse(text); + assert.equal(data.strategy.mission, '星栖'); assert.equal(data.market.ranking[0].bvid, ''); +}); diff --git a/ops/report.py b/ops/report.py index a16ec9b..fead48b 100644 --- a/ops/report.py +++ b/ops/report.py @@ -124,6 +124,7 @@ def scheduled_codex_usage(directory, start, end): totals = {k: 0 for k in ('input_tokens', 'cached_input_tokens', 'output_tokens', 'reasoning_output_tokens')} api_totals = dict.fromkeys(totals, 0) runs = failed = 0 + categories = {} api_runs = api_uncertain = api_usage_events = 0 for path in Path(directory).glob('*.summary.json'): try: @@ -142,12 +143,19 @@ def scheduled_codex_usage(directory, start, end): for k in api_totals: api_totals[k] += max(0, int(usage.get(k, 0))) continue + category = record.get('category') if record.get('category') in ('content', 'engineering', 'research', 'community', 'management') else 'unattributed' + lane = categories.setdefault(category, {'runs': 0, 'elapsed_seconds': 0, 'elapsed_missing': 0, **dict.fromkeys(totals, 0)}) + lane['runs'] += 1 + if isinstance(record.get('elapsed_seconds'), (int, float)) and record['elapsed_seconds'] >= 0: lane['elapsed_seconds'] += record['elapsed_seconds'] + else: lane['elapsed_missing'] += 1 for usage in record.get('usage', []): for k in totals: - totals[k] += max(0, int(usage.get(k, 0))) + amount = max(0, int(usage.get(k, 0))) + totals[k] += amount + lane[k] += amount except (ValueError, OSError, KeyError, TypeError, AttributeError): continue - return {'available': True, 'runs': runs, 'failed_runs': failed, 'api_runs_separate': api_runs, 'api_runs_uncertain': api_uncertain, 'api_observed_usage': api_totals if api_usage_events else None, **totals, + return {'available': True, 'runs': runs, 'failed_runs': failed, 'api_runs_separate': api_runs, 'api_runs_uncertain': api_uncertain, 'api_observed_usage': api_totals if api_usage_events else None, 'by_category': categories, **totals, 'measurement_note': '来自独立定时会话的运行汇总,与交互会话分开统计;API 备用调用不计入订阅 token,实际成本单独核算。失败或中断可能没有完整用量。'}