Add private operations portal and weekly accounting reports

This commit is contained in:
wuyang
2026-11-03 16:45:50 +08:00
parent 10c4487463
commit 20aea13054
18 changed files with 958 additions and 0 deletions
+138
View File
@@ -0,0 +1,138 @@
const $ = selector => document.querySelector(selector);
const el = (tag, text, className) => {
const node = document.createElement(tag);
if (text !== undefined) node.textContent = text;
if (className) node.className = className;
return node;
};
let dashboard;
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 用量' };
function valueText(value) {
if (value === null || value === undefined || value === '') return '待核实';
if (typeof value === 'boolean') return value ? '是' : '否';
if (Array.isArray(value)) return value.map(valueText).join(';') || '暂无记录';
if (typeof value === 'object') return Object.entries(value).map(([k, v]) => `${labels[k] || k}:${valueText(v)}`).join(' · ') || '暂无记录';
return String(value);
}
function money(value, currency = 'USD') {
if (typeof value !== 'number' || !Number.isFinite(value)) return '待核实';
return new Intl.NumberFormat('zh-CN', { style: 'currency', currency, maximumFractionDigits: 2 }).format(value);
}
function dateTime(value) {
if (!value) return '待确认';
if (!String(value).includes('T')) return String(value);
const date = new Date(value);
if (!Number.isFinite(date.getTime())) return String(value);
return new Intl.DateTimeFormat('zh-CN', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' }).format(date);
}
async function api(url, options = {}) {
const response = await fetch(url, { credentials: 'same-origin', ...options, headers: { 'Content-Type': 'application/json', ...options.headers } });
const data = await response.json();
if (response.status === 401 && url !== '/api/login') showLogin();
if (!response.ok) throw new Error(data.error || '请求失败,请稍后再试');
return data;
}
function showLogin() {
$('#loading').hidden = true; $('#dashboard-view').hidden = true; $('#login-view').hidden = false;
$('#password').value = '';
}
function card(title, amount, note, accent = false) {
const node = el('article', undefined, `stat-card${accent ? ' accent' : ''}`);
node.append(el('p', title, 'stat-label'), el('strong', amount), el('p', note, 'stat-note'));
return node;
}
function listSection(title, items, style = '') {
const section = el('section', undefined, `report-section ${style}`);
section.append(el('h2', title));
if (!Array.isArray(items) || !items.length) { section.append(el('p', '暂无记录', 'muted')); return section; }
const list = el('ul');
for (const item of items) list.append(el('li', valueText(item)));
section.append(list); return section;
}
function budgetPanel() {
const budget = dashboard.config?.budget || {};
const section = el('section', undefined, 'budget-note');
const header = el('div', undefined, 'section-heading');
header.append(el('h2', '预算口径'), el('span', '套餐内 Token 不重复计费', 'tag'));
const period = { monthly: '月', weekly: '周', pending: '待确认周期' }[budget.billing_period] || '待确认周期';
const amount = typeof budget.subscription_usd === 'number' ? `${money(budget.subscription_usd)} / ${period}` : '套餐价格待确认';
const fraction = typeof budget.project_allocation_fraction === 'number' ? `${Math.round(budget.project_allocation_fraction * 100)}%` : '待归因';
section.append(header, el('p', `账户套餐:${amount} · 本项目分摊比例:${fraction}`), el('p', '套餐成本按确认的周期和项目占比分摊。套餐内 Token 用量单独记录,不再加算为现金支出;额外 API、托管和电费按实际凭据入账。账户总用量不等于本项目用量。', 'muted'));
return section;
}
function renderReport(report) {
const content = $('#report-content'); content.replaceChildren();
const download = $('#download-report');
if (!report) {
$('#report-title').textContent = '第一份报告,正在准备'; $('#report-period').textContent = '每周进展与收支将在这里归档。';
download.hidden = true; content.append(budgetPanel()); return;
}
$('#report-title').textContent = report.title || report.id;
$('#report-period').textContent = `${dateTime(report.period_start)} — ${dateTime(report.period_end)} · 北京时间${report.partial_period || report.kind === 'snapshot' ? ' · 截至当前的部分周期' : ''}`;
download.href = `/api/reports/${encodeURIComponent(report.id)}/download`; download.hidden = false;
for (const link of $('#report-nav').children) link.classList.toggle('selected', link.dataset.id === report.id);
const intro = el('section', undefined, 'summary'); intro.append(el('span', '本周摘要', 'eyebrow'), el('p', valueText(report.summary))); content.append(intro);
const costs = report.costs || {}, revenue = report.revenue || {};
const stats = el('div', undefined, 'stats');
const cashNote = typeof costs.confirmed_recorded_cash_usd === 'number' ? `已确认记录 ${money(costs.confirmed_recorded_cash_usd)};未覆盖支出仍待核实` : '仅计实际发生的美元支出';
stats.append(card('本周实际现金支出', money(costs.cash_usd), cashNote), card('本周套餐分摊', money(costs.allocated_usd), '固定费用归因,与现金支出分开'), card('已结算收入', money(revenue.settled_cny, 'CNY'), '以平台结算或到账为依据', true), card('预估收入', money(revenue.estimated_cny, 'CNY'), '尚未结算,不计作已实现收益'));
content.append(stats, budgetPanel());
if (report.metrics && Object.keys(report.metrics).length) {
const section = el('section', undefined, 'report-section'); section.append(el('h2', '运营数据'));
const metrics = el('dl', undefined, 'metric-grid');
for (const [name, value] of Object.entries(report.metrics)) { const row = el('div'); row.append(el('dt', labels[name] || name), el('dd', valueText(value))); metrics.append(row); }
section.append(metrics); content.append(section);
}
const columns = el('div', undefined, 'report-columns'); columns.append(listSection('本周完成', report.highlights), listSection('下一步', report.next_actions)); content.append(columns);
if (report.risks?.length) content.append(listSection('需要关注', report.risks, 'attention'));
if (report.decisions?.length) content.append(listSection('运营决策', report.decisions));
}
function renderLedger() {
const host = $('#ledger'); host.replaceChildren();
const entries = dashboard.ledger?.entries || [];
if (!entries.length) { host.append(el('p', '暂无已入账记录。未取得数据的项目保持待核实,不按零成本处理。', 'empty')); return; }
const wrapper = el('div', undefined, 'table-wrap'), table = el('table'), head = el('thead'), header = el('tr');
for (const title of ['日期', '项目', '类型', '金额', '口径', '依据']) header.append(el('th', title));
head.append(header); const body = el('tbody');
for (const item of [...entries].sort((a, b) => String(b.date).localeCompare(String(a.date)))) {
const row = el('tr');
for (const value of [item.date, item.label, categoryLabels[item.category] || item.category, money(item.amount, item.currency || 'USD'), basisLabels[item.basis] || item.basis, item.source || '待补充']) row.append(el('td', valueText(value)));
body.append(row);
}
table.append(head, body); wrapper.append(table); host.append(wrapper);
}
async function openReport(id) {
$('#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; }
}
async function loadDashboard() {
dashboard = await api('/api/dashboard');
$('#loading').hidden = true; $('#login-view').hidden = true; $('#dashboard-view').hidden = false;
document.title = dashboard.config?.title || 'PawPlay 运营台';
const nav = $('#report-nav'); nav.replaceChildren();
for (const report of dashboard.reports) {
const link = el('a'); link.href = `/?report=${encodeURIComponent(report.id)}`; link.dataset.id = report.id;
link.append(el('strong', report.id), el('span', report.title || '每周运营报告'));
link.addEventListener('click', event => { event.preventDefault(); openReport(report.id); }); nav.append(link);
}
if (!dashboard.reports.length) nav.append(el('p', '还没有历史周报', 'muted'));
renderLedger();
const selected = new URL(location.href).searchParams.get('report');
if (selected && dashboard.reports.some(r => r.id === selected)) await openReport(selected); else renderReport(dashboard.latest);
}
$('#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(); }
catch (error) { $('#global-error').textContent = error.message; }
});
(async () => {
try { const status = await api('/api/session'); if (status.authenticated) await loadDashboard(); else showLogin(); }
catch (error) { showLogin(); $('#login-error').textContent = error.message; }
})();
+24
View File
@@ -0,0 +1,24 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="color-scheme" content="light">
<title>PawPlay 运营台</title>
<link rel="stylesheet" href="/style.css">
<script src="/app.js" defer></script>
</head>
<body>
<div id="login-view" class="login-shell" hidden>
<section class="login-intro"><div class="wordmark">pawplay<span>运营台</span></div><p class="eyebrow">每一次进展,都留下记录</p><h1>把小小的游乐场,<br>慢慢做成一门事业。</h1><p>查看每周进展、投入与回报。<br>从第一条视频,到下一次产品更新。</p><div class="paw-decoration" aria-hidden="true">●<span>●</span>●<b>♥</b></div><p class="private-note">私有工作空间 · 登录后查看运营数据</p></section>
<main class="login-card"><p class="eyebrow">欢迎回来</p><h2>登录运营台</h2><p class="muted">报告、预算与发布记录,在这里汇总。</p><form id="login-form"><label for="username">账号</label><input id="username" name="username" autocomplete="username" required maxlength="128"><label for="password">密码</label><input id="password" name="password" type="password" autocomplete="current-password" required maxlength="1024"><button class="primary" type="submit">进入运营台 <span aria-hidden="true">↗</span></button><p id="login-error" role="alert" class="error"></p></form></main>
</div>
<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>
</div>
</div>
<p id="loading" class="loading" role="status">正在打开运营台…</p>
</body>
</html>
File diff suppressed because one or more lines are too long