PAWPLAY · WEEKLY REVIEW
From 84fc19c9653d7de4fbbaae5e48560eb86fd1d47d Mon Sep 17 00:00:00 2001 From: wuyang <5700876+banisherwy@user.noreply.gitee.com> Date: Tue, 3 Nov 2026 17:29:10 +0800 Subject: [PATCH] Expose private operational evolution timeline --- ops-portal/README.md | 2 ++ ops-portal/public/app.js | 47 +++++++++++++++++++++++++++++++-- ops-portal/public/index.html | 2 +- ops-portal/public/style.css | 2 ++ ops-portal/server.mjs | 1 + ops-portal/test/server.test.mjs | 17 +++++++++++- 6 files changed, 67 insertions(+), 4 deletions(-) diff --git a/ops-portal/README.md b/ops-portal/README.md index df0428e..a9dd2a5 100644 --- a/ops-portal/README.md +++ b/ops-portal/README.md @@ -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 diff --git a/ops-portal/public/app.js b/ops-portal/public/app.js index 71383c7..6563082 100644 --- a/ops-portal/public/app.js +++ b/ops-portal/public/app.js @@ -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 () => { diff --git a/ops-portal/public/index.html b/ops-portal/public/index.html index 389ff24..75b655c 100644 --- a/ops-portal/public/index.html +++ b/ops-portal/public/index.html @@ -16,7 +16,7 @@
正在打开运营台…
diff --git a/ops-portal/public/style.css b/ops-portal/public/style.css index b589b93..39d80d4 100644 --- a/ops-portal/public/style.css +++ b/ops-portal/public/style.css @@ -1,2 +1,4 @@ @charset "UTF-8"; :root{--ink:#242a27;--muted:#727a73;--paper:#f8f8f2;--green:#244c3c;--line:#e3e7df;--lime:#eaf0cb;--orange:#d8824b;--card:#fff;--serif:Georgia,"Songti SC","Noto Serif CJK SC",serif}*{box-sizing:border-box}body{margin:0;color:var(--ink);background:var(--paper);font-family:system-ui,-apple-system,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;font-size:14px;line-height:1.7}button,input{font:inherit}button,a,input{ -webkit-tap-highlight-color:transparent}button,a{touch-action:manipulation}a{color:inherit}button{cursor:pointer}button:disabled{cursor:wait;opacity:.65}button:focus-visible,a:focus-visible,input:focus-visible{outline:3px solid #a4bfa4;outline-offset:4px}[hidden]{display:none!important}.muted{color:var(--muted)}.eyebrow{font-size:11px;font-weight:650;letter-spacing:.1em;margin:0 0 12px;color:var(--green)}h1,h2,p{margin-top:0}h1{font-size:31px;line-height:1.45;letter-spacing:-.04em;font-weight:600}h2{font-size:18px;letter-spacing:-.02em;font-weight:600}.wordmark{text-decoration:none;font-size:25px;font-weight:750;letter-spacing:-1.4px;display:flex;align-items:center;gap:14px;color:var(--green)}.wordmark span{letter-spacing:.05em;font-size:12px;font-weight:500;border-left:1px solid #ccd4ca;padding-left:14px;color:var(--muted)}.login-shell{max-width:1150px;margin:auto;min-height:100vh;display:grid;grid-template-columns:1.2fr 1fr;align-items:center;gap:80px;padding:60px 40px}.login-intro{position:relative}.login-intro .wordmark{margin-bottom:80px}.login-intro h1{font-family:var(--serif);font-size:39px;font-weight:500;margin-bottom:25px}.login-intro>p:not(.eyebrow){color:var(--muted)}.private-note{font-size:12px;margin-top:95px}.login-card{background:#fff;padding:46px 40px 32px;border:1px solid var(--line);border-radius:20px;box-shadow:0 18px 70px #253e2d06}.login-card h2{font-size:26px;margin-bottom:10px}.login-card form{margin-top:30px}.login-card label{display:block;margin-bottom:7px;font-size:13px}.login-card input{display:block;width:100%;border:1px solid #d8ded5;border-radius:8px;padding:12px 13px;background:#fdfdfb;margin-bottom:19px;color:var(--ink)}.primary{background:var(--green);color:#fff;border:0;border-radius:8px;width:100%;padding:13px 16px;margin-top:8px;text-align:left}.primary span{float:right}.error{color:#ae402f;font-size:13px;min-height:1.7em;margin:12px 0 0}.paw-decoration{position:absolute;right:14px;bottom:82px;font-size:16px;letter-spacing:8px;transform:rotate(-15deg);color:#b7c4a0}.paw-decoration span{position:relative;top:-9px}.paw-decoration b{display:block;font-size:46px;text-align:center;line-height:1;letter-spacing:0}.loading{text-align:center;padding-top:25vh;color:var(--muted)}.topbar{height:86px;display:flex;align-items:center;justify-content:space-between;padding:0 46px;background:#fff;border-bottom:1px solid var(--line)}.top-right{display:flex;gap:22px;align-items:center}.private-pill{font-size:11px;color:var(--green);border:1px solid #dbe2d6;border-radius:50px;padding:4px 12px}.quiet{background:transparent;border:0;color:var(--muted);font-size:12px;padding:8px}.workspace{max-width:1600px;margin:auto;display:grid;grid-template-columns:244px minmax(0,1fr);min-height:calc(100vh - 86px)}.sidebar{padding:38px 22px;border-right:1px solid var(--line)}.sidebar-heading{padding:0 16px 20px}.sidebar-heading h2{font-size:19px}.sidebar nav{display:grid;gap:8px}.sidebar nav>a{display:block;text-decoration:none;padding:13px 16px;border:1px solid transparent;border-radius:9px}.sidebar nav>a:hover{background:#eef1e8}.sidebar nav>a.selected{background:var(--green);color:white}.sidebar nav strong{font-size:12px;display:block;font-weight:600}.sidebar nav span{font-size:12px;display:block;opacity:.7;line-height:1.5;margin-top:4px}.sidebar-foot{padding:40px 16px;font-size:11px;color:var(--green);line-height:2.2}.status-dot{display:inline-block;background:#90a97c;width:6px;height:6px;border-radius:50%;margin-right:7px}.main{padding:41px 44px 24px;min-width:0}.page-heading{display:flex;justify-content:space-between;align-items:center;gap:18px;margin-bottom:22px}.page-heading h1{margin-bottom:8px}.page-heading .muted{font-size:12px;margin:0}.download{flex-shrink:0;text-decoration:none;font-size:12px;padding:8px 13px;border:1px solid #d7ded2;border-radius:7px;color:var(--green)}.download:hover{background:white}.summary{background:var(--lime);border-radius:12px;padding:25px 28px;margin-bottom:22px}.summary .eyebrow{font-size:10px;display:block;opacity:.7;margin-bottom:7px}.summary p{margin:0;font-size:15px;line-height:1.9}.stats{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:13px;margin-bottom:24px}.stat-card{background:#fff;border:1px solid var(--line);border-radius:11px;padding:20px 18px}.stat-card.accent{background:#edf2e9;border-color:#d8e2d1}.stat-label{font-size:11px;color:var(--muted);margin:0 0 15px}.stat-card strong{font-size:25px;font-weight:550;letter-spacing:-.035em;color:var(--green)}.stat-note{margin:12px 0 0;font-size:10px;color:var(--muted);line-height:1.6}.budget-note{border-bottom:1px solid var(--line);padding:5px 0 21px;margin-bottom:24px}.budget-note h2{font-size:14px}.budget-note p{font-size:12px;margin-bottom:8px}.budget-note p:last-child{font-size:11px;margin-bottom:0;max-width:870px}.section-heading{display:flex;gap:15px;align-items:center;justify-content:space-between;margin-bottom:13px}.section-heading h2{margin:0}.section-heading>.muted{font-size:11px}.tag{border-radius:4px;padding:3px 7px;background:#eef1e7;color:#6c7b62;font-size:10px}.report-columns{display:grid;grid-template-columns:1fr 1fr;gap:22px}.report-section{padding:22px 24px;background:#fff;border:1px solid var(--line);border-radius:11px;margin-bottom:20px;min-width:0}.report-section h2{font-size:15px;margin-bottom:15px}.report-section ul{padding-left:18px;margin:0}.report-section li{padding-left:2px;margin:9px 0;line-height:1.9;font-size:13px;overflow-wrap:anywhere}.report-section li::marker{color:#a1b48e}.report-section.attention{background:#fbf6ec;border-color:#eee2ca}.report-section.attention li::marker{color:var(--orange)}.metric-grid{margin:0;display:grid;grid-template-columns:1fr 1fr;gap:17px 26px}.metric-grid>div{border-bottom:1px solid #eef0eb;padding-bottom:13px;min-width:0}.metric-grid dt{font-size:11px;color:var(--muted);margin-bottom:6px}.metric-grid dd{margin:0;font-size:13px;overflow-wrap:anywhere}.ledger-section{margin-top:35px}.ledger-section h2{font-size:17px}.empty{background:white;border:1px solid var(--line);border-radius:10px;padding:24px;color:var(--muted);font-size:12px}.table-wrap{overflow:auto;background:#fff;border:1px solid var(--line);border-radius:10px}table{border-collapse:collapse;width:100%;font-size:12px;min-width:620px;text-align:left}th{font-size:10px;font-weight:500;color:var(--muted);background:#fafbf7;white-space:nowrap}td,th{padding:13px 16px;border-bottom:1px solid #edf0e8}td:last-child{font-size:11px;max-width:280px;overflow-wrap:anywhere}tbody tr:last-child td{border-bottom:0}.main-footer{border-top:1px solid var(--line);margin-top:45px;padding-top:20px;font-size:11px;color:var(--green);display:flex;justify-content:space-between;gap:12px}.main-footer span{font-size:10px;color:var(--muted)}@media(min-width:1500px){.main{padding-left:60px;padding-right:60px}}@media(max-width:1150px){.stats{grid-template-columns:repeat(2,minmax(0,1fr))}.main{padding:34px 28px}.workspace{grid-template-columns:205px minmax(0,1fr)}.sidebar{padding:35px 12px}.login-shell{gap:45px}.login-intro h1{font-size:33px}}@media(max-width:760px){.topbar{height:70px;padding:0 20px}.wordmark{font-size:23px}.wordmark span{font-size:10px;gap:10px}.top-right{gap:10px}.private-pill{display:none}.workspace{display:block;min-height:calc(100vh - 70px)}.sidebar{border-right:0;border-bottom:1px solid var(--line);padding:16px 20px;overflow:auto}.sidebar-heading,.sidebar-foot{display:none}.sidebar nav{display:flex;gap:8px;min-width:max-content}.sidebar nav>a{padding:8px 12px;border-color:var(--line)}.sidebar nav span{max-width:150px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-size:10px}.sidebar nav strong{font-size:10px}.main{padding:25px 20px 20px}.page-heading{align-items:flex-start;margin-bottom:18px}.page-heading h1{font-size:24px}.page-heading .eyebrow{font-size:9px}.download{font-size:10px;padding:6px 9px;margin-top:4px}.summary{padding:20px}.summary p{font-size:14px}.stats{gap:10px}.stat-card{padding:16px 13px}.stat-card strong{font-size:23px}.stat-label{font-size:10px}.stat-note{font-size:9px}.report-columns{display:block}.report-section{padding:19px}.section-heading{align-items:flex-start}.tag{font-size:8px;max-width:130px}.budget-note p{font-size:11px}.metric-grid{grid-template-columns:1fr}.main-footer{display:block}.main-footer span{display:block;margin-top:6px}.login-shell{display:block;padding:30px 22px;min-height:100vh;max-width:470px}.login-intro .wordmark{margin-bottom:32px}.login-intro h1{font-size:27px;margin-bottom:12px}.login-intro>.eyebrow{display:none}.login-intro>p:not(.eyebrow){font-size:12px}.private-note,.paw-decoration{display:none}.login-card{padding:26px 23px 17px;margin-top:27px;border-radius:13px}.login-card h2{font-size:23px}.login-card .muted{font-size:12px}.login-card form{margin-top:24px}}@media(prefers-reduced-motion:reduce){*{scroll-behavior:auto!important}} + +.view-tabs{display:flex;gap:24px;border-bottom:1px solid var(--line);margin-bottom:28px}.view-tabs button{border:0;border-bottom:2px solid transparent;background:transparent;color:var(--muted);padding:0 2px 12px}.view-tabs button.active{color:var(--green);border-color:var(--green);font-weight:600}.timeline{display:grid;gap:12px}.evolution-event{background:#fff;border:1px solid var(--line);border-radius:11px;overflow:hidden}.evolution-event summary{cursor:pointer;padding:20px 24px;color:var(--green)}.event-heading{display:inline-grid;vertical-align:top;width:calc(100% - 24px);gap:5px;padding-left:8px}.event-heading strong{font-size:15px;overflow-wrap:anywhere}.event-heading .muted{font-size:11px}.event-fields{margin:0;padding:0 24px 22px 46px;display:grid;gap:12px}.event-fields>div{display:grid;grid-template-columns:110px minmax(0,1fr);gap:12px;border-top:1px solid var(--line);padding-top:12px}.event-fields dt{color:var(--muted);font-size:12px}.event-fields dd{margin:0;overflow-wrap:anywhere;white-space:pre-wrap}@media(max-width:760px){.event-fields{padding:0 19px 19px}.event-fields>div{grid-template-columns:1fr;gap:3px}.evolution-event summary{padding:17px 19px}} diff --git a/ops-portal/server.mjs b/ops-portal/server.mjs index a0b3f7a..7a717eb 100644 --- a/ops-portal/server.mjs +++ b/ops-portal/server.mjs @@ -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' } }); diff --git a/ops-portal/test/server.test.mjs b/ops-portal/test/server.test.mjs index 3abbb44..7e91a50 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/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: [] }); +});