Files
pawplay/ops-portal/public/app.js
T
2026-11-03 17:29:10 +08:00

182 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
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 用量' };
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) {
currentReportId = report?.id;
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);
}
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; }
}
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 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(); $('#evolution-content').replaceChildren(); currentReportId = undefined; showView('report'); $('#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; }
})();