Expose private operational evolution timeline

This commit is contained in:
wuyang
2026-11-03 17:29:10 +08:00
parent 20aea13054
commit 84fc19c965
6 changed files with 67 additions and 4 deletions
+2
View File
@@ -44,6 +44,8 @@
`billing_period` 支持 `monthly`、`weekly`、`pending`;项目分摊比例范围由生成任务校验,未确认时使用 `null`。
`evolution.json` 保存演变路径,登录后在「演变路径」中展开浏览。缺失文件显示空状态;事件按文件中的顺序展示,建议按发生顺序维护。每条事件包含 `id`、`recorded_at`、可选 `occurred_at`、`occurred_at_precision`、`retrospective`、`trigger`、`hypothesis`、`change`、`evidence`、`cost`(`known` 和 `unknown`)、`decision`、`next_step` 与 `artifact_refs`。证据和关联资料可使用可读字符串数组,未知信息应保留而非猜测。写入时应使用临时文件原子替换。
`ledger.json`:
```json
+45 -2
View File
@@ -6,6 +6,7 @@ const el = (tag, text, className) => {
return node;
};
let dashboard;
let currentReportId;
const labels = { views: '视频播放', likes: '点赞', followers: '关注人数', downloads: 'APK 下载', visits: '网页访问', videos_published: '已发布视频', comments: '评论', favorites: '收藏', coins: '投币', watch_time: '观看时长', completion_rate: '完播率', revenue: '收益', value: '数值', unit: '单位', source: '来源', status: '状态', note: '备注', observed_at: '观测时间', project: '项目', account: '账号', account_followers: '账号关注人数' };
const basisLabels = { cash: '实际现金', allocated: '套餐分摊', estimate: '预估', settled: '已结算' };
const categoryLabels = { subscription: '套餐', api: 'API', electricity: '电费', hosting: '托管', revenue: '收入', tokens: 'Token 用量' };
@@ -63,6 +64,7 @@ function budgetPanel() {
return section;
}
function renderReport(report) {
currentReportId = report?.id;
const content = $('#report-content'); content.replaceChildren();
const download = $('#download-report');
if (!report) {
@@ -103,7 +105,43 @@ function renderLedger() {
}
table.append(head, body); wrapper.append(table); host.append(wrapper);
}
function showView(view) {
const evolution = view === 'evolution';
$('#report-view').hidden = evolution; $('#evolution-view').hidden = !evolution;
for (const [selector, active] of [['#show-report', !evolution], ['#show-evolution', evolution]]) {
$(selector).classList.toggle('active', active); $(selector).setAttribute('aria-pressed', String(active));
}
}
function renderEvolution(data) {
const host = $('#evolution-content'); host.replaceChildren();
const events = Array.isArray(data?.events) ? data.events : [];
if (!events.length) { host.append(el('p', '还没有演变记录。后续探索与决策会在这里归档。', 'empty')); return; }
const timeline = el('div', undefined, 'timeline');
for (const event of events) {
const item = el('details', undefined, 'evolution-event');
const summary = el('summary');
const heading = el('span', undefined, 'event-heading');
const precision = { datetime: '精确时间', date: '日期', approximate: '约略时间', unknown: '时间待确认' }[event.occurred_at_precision] || event.occurred_at_precision;
const timing = event.occurred_at ? `发生:${dateTime(event.occurred_at)}${precision ? `(${precision})` : ''} · ` : (precision ? `发生时间:${precision} · ` : '');
heading.append(el('strong', valueText(event.change || event.trigger || event.id)), el('span', `${timing}记录:${dateTime(event.recorded_at)} · ${event.retrospective === true ? '早期补录' : event.retrospective === false ? '实时记录' : '记录方式待确认'}`, 'muted'));
summary.append(heading); item.append(summary);
const fields = el('dl', undefined, 'event-fields');
for (const [label, value] of [['起因', event.trigger], ['假设', event.hypothesis], ['改变', event.change], ['证据', event.evidence], ['已知成本', event.cost?.known], ['待确认成本', event.cost?.unknown], ['决策', event.decision], ['下一步', event.next_step], ['关联版本与资料', event.artifact_refs]]) {
const row = el('div'); row.append(el('dt', label), el('dd', valueText(value))); fields.append(row);
}
item.append(fields); timeline.append(item);
}
host.append(timeline);
}
async function openEvolution() {
showView('evolution');
const host = $('#evolution-content'); host.replaceChildren(el('p', '正在读取演变记录…', 'muted'));
history.replaceState(null, '', `/?view=evolution${currentReportId ? `&report=${encodeURIComponent(currentReportId)}` : ''}`);
try { renderEvolution(await api('/api/evolution')); }
catch (error) { host.replaceChildren(el('p', error.message, 'error')); }
}
async function openReport(id) {
showView('report');
$('#global-error').textContent = '';
try { const report = await api(`/api/reports/${encodeURIComponent(id)}`); renderReport(report); history.replaceState(null, '', `/?report=${encodeURIComponent(id)}`); }
catch (error) { $('#global-error').textContent = error.message; }
@@ -120,16 +158,21 @@ async function loadDashboard() {
}
if (!dashboard.reports.length) nav.append(el('p', '还没有历史周报', 'muted'));
renderLedger();
const selected = new URL(location.href).searchParams.get('report');
const params = new URL(location.href).searchParams;
const selected = params.get('report');
const initialView = params.get('view');
if (selected && dashboard.reports.some(r => r.id === selected)) await openReport(selected); else renderReport(dashboard.latest);
if (initialView === 'evolution') await openEvolution();
}
$('#show-evolution').addEventListener('click', openEvolution);
$('#show-report').addEventListener('click', () => { showView('report'); history.replaceState(null, '', currentReportId ? `/?report=${encodeURIComponent(currentReportId)}` : '/'); });
$('#login-form').addEventListener('submit', async event => {
event.preventDefault(); const button = event.currentTarget.querySelector('button'); button.disabled = true; $('#login-error').textContent = '';
try { await api('/api/login', { method: 'POST', body: JSON.stringify({ username: $('#username').value, password: $('#password').value }) }); $('#password').value = ''; await loadDashboard(); }
catch (error) { $('#login-error').textContent = error.message; } finally { button.disabled = false; }
});
$('#logout').addEventListener('click', async () => {
try { await api('/api/logout', { method: 'POST', body: '{}' }); dashboard = null; $('#report-content').replaceChildren(); $('#ledger').replaceChildren(); $('#report-nav').replaceChildren(); showLogin(); }
try { await api('/api/logout', { method: 'POST', body: '{}' }); dashboard = null; $('#report-content').replaceChildren(); $('#evolution-content').replaceChildren(); currentReportId = undefined; showView('report'); $('#ledger').replaceChildren(); $('#report-nav').replaceChildren(); showLogin(); }
catch (error) { $('#global-error').textContent = error.message; }
});
(async () => {
+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"><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><footer class="main-footer">PawPlay 猫爪游乐场 <span>让每一笔投入有依据,让每一步进展可回看。</span></footer></main>
<main class="main"><nav class="view-tabs" aria-label="浏览内容"><button id="show-report" class="active" type="button" aria-pressed="true">运营报告</button><button id="show-evolution" type="button" aria-pressed="false">演变路径</button></nav><div id="report-view"><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><footer class="main-footer">PawPlay 猫爪游乐场 <span>让每一笔投入有依据,让每一步进展可回看。</span></footer></main>
</div>
</div>
<p id="loading" class="loading" role="status">正在打开运营台…</p>
File diff suppressed because one or more lines are too long
+1
View File
@@ -129,6 +129,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/evolution') return json(res, 200, await jsonFile('evolution.json', { schema_version: 1, events: [] }));
if (req.method === 'GET' && route === '/api/dashboard') {
const list = await reports();
const config = await jsonFile('config.json', { title: 'PawPlay 运营台', timezone: 'Asia/Shanghai', budget: { billing_period: 'pending' } });
+16 -1
View File
@@ -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/reports/2026-W45', '/api/reports/2026-W45/download', '/api/ledger']) {
for (const endpoint of ['/api/dashboard', '/api/evolution', '/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/);
}
@@ -110,3 +110,18 @@ test('invalid JSON values and oversized bodies are rejected', async t => {
assert.equal((await request('/api/login', { method: 'POST', headers, body: 'null' })).status, 400);
assert.equal((await request('/api/login', { method: 'POST', headers, body: JSON.stringify({ password: 'a'.repeat(9000) }) })).status, 413);
});
test('evolution is private, readable after login, and empty when absent', async t => {
const { request, login, directory } = await fixture(t);
const example = { schema_version: 1, events: [{ id: 'fictional-1', recorded_at: '2026-11-03', retrospective: true, trigger: '虚构起因', hypothesis: '虚构假设', change: '虚构改变', evidence: ['虚构证据'], cost: { known: '未知', unknown: '待确认' }, decision: '虚构决策', next_step: '虚构下一步', artifact_refs: ['虚构版本'] }] };
await writeFile(path.join(directory, 'evolution.json'), JSON.stringify(example));
const anonymous = await request('/api/evolution'); assert.equal(anonymous.status, 401); assert.doesNotMatch(await anonymous.text(), /虚构/);
const shell = await request('/'); assert.doesNotMatch(await shell.text(), /虚构/);
const cookie = (await login()).headers.get('set-cookie').split(';')[0];
const authenticated = await request('/api/evolution', { headers: { Cookie: cookie } });
assert.equal(authenticated.status, 200); assert.deepEqual(await authenticated.json(), example);
await rm(path.join(directory, 'evolution.json'));
const empty = await request('/api/evolution', { headers: { Cookie: cookie } });
assert.equal(empty.status, 200); assert.deepEqual(await empty.json(), { schema_version: 1, events: [] });
});