249 lines
22 KiB
JavaScript
249 lines
22 KiB
JavaScript
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: '账号关注人数', scheduled_codex: '定时运营 Codex 用量', harness_new_runs: '后续 DeepSeek Harness 用量', codex: '项目会话 Codex 用量', deepseek: 'DeepSeek 历史用量快照' };
|
||
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 '待核实';
|
||
if (currency === 'BILIBILI_COIN') return `${value} B站硬币`;
|
||
if (typeof currency !== 'string' || !/^[A-Z]{3}$/.test(currency)) return `${value} ${String(currency)}`;
|
||
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) {
|
||
for (const name of ['overview', 'execution', 'report', 'evolution', 'daily']) $(`#${name}-view`).hidden = view !== name;
|
||
for (const [selector, active] of [['#show-overview', view === 'overview'], ['#show-execution', view === 'execution'], ['#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 openExecution(updateHistory = true) {
|
||
showView('execution'); if (updateHistory) history.replaceState(null, '', '/?view=execution');
|
||
const host = $('#execution-content'); host.replaceChildren(el('p', '正在读取运行状态…', 'muted'));
|
||
try {
|
||
const [data, queue] = await Promise.all([api('/api/execution'), api('/api/workqueue')]); host.replaceChildren();
|
||
const sampled = Date.parse(data.observed_at || ''); const stale = !Number.isFinite(sampled) || Date.now() - sampled > 3 * 60 * 1000;
|
||
host.append(el('p', `状态采样:${data.observed_at ? dateTime(data.observed_at) : '暂无'}${stale ? ' · 状态可能过期,请检查采集服务' : ' · 约每两分钟刷新'}`, stale ? 'error' : 'muted'));
|
||
const now = el('section', undefined, 'report-section'); now.append(el('h2', '当前执行'));
|
||
const cycle = (data.units || []).find(x => x.id === 'cycle'); const active = cycle && ['active','activating'].includes(cycle.state);
|
||
if (data.daily_budget) now.append(el('p', `今日主运营尝试:${data.daily_budget.attempts} / ${data.daily_budget.limit};达到上限后等待次日,避免耗尽共享额度。`, 'muted'));
|
||
now.append(el('p', stale ? '快照已过期,不能确定当前运行情况。' : active ? '主运营进程正在运行。' : cycle?.state === 'inactive' ? '主运营当前空闲,等待可执行任务或预算恢复。' : `主运营状态:${cycle?.state || '未知'}`));
|
||
for (const t of data.running_tasks || []) now.append(el('p', `领取任务:${t.title} · ${t.updated_at ? dateTime(t.updated_at) : '时间未知'}`)); host.append(now);
|
||
const schedules = el('section', undefined, 'report-section'); schedules.append(el('h2', '服务与计划'), el('p', '有待执行任务且额度允许时,守护程序约每两分钟尝试启动;下方为额外的固定时刻。', 'muted')); const list = el('ul');
|
||
for (const u of data.units || []) list.append(el('li', `${u.label} · ${u.state === 'activating' || u.state === 'active' ? '正在运行' : u.state === 'inactive' ? '当前空闲' : u.state || '未知'} · 定时器${u.timer_state === 'active' ? '已启用' : u.timer_state || '未知'} · 下次固定时刻 ${u.next_run || '未知'} · 上次结果 ${u.result || '暂无'}`)); schedules.append(list); host.append(schedules);
|
||
const tasks = el('section', undefined, 'report-section'); tasks.append(el('h2', '任务列表')); const status = {pending:'待执行或等待退避',running:'已领取',done:'已完成',blocked:'已阻塞'}; const ul = el('ul'); for (const t of queue.tasks || []) ul.append(el('li', `${t.title} · ${status[t.status] || t.status} · 尝试 ${t.attempts} 次 · ${t.updated_at ? dateTime(t.updated_at) : '时间未知'}`)); tasks.append(ul); host.append(tasks);
|
||
const past = el('section', undefined, 'report-section'); past.append(el('h2', '主运营执行历史'), el('p', '记录来自每轮执行汇总;进程成功不代表整个任务已经完成。详细产出和复盘见每日计划与复盘。', 'muted'));
|
||
const results = {complete:'进程成功',failed:'失败',timeout:'超时',incomplete:'未记录进度',preflight_failed:'前置检查失败',unknown:'状态未知'}; const rows = el('ul'); for (const x of data.history || []) rows.append(el('li', `${dateTime(x.started_at)} · ${x.title} · ${results[x.status] || '未知'}${x.elapsed_seconds === null ? '' : ` · ${x.elapsed_seconds} 秒`}`)); if (!(data.history || []).length) rows.append(el('li', '暂无执行汇总')); past.append(rows); host.append(past);
|
||
} catch (error) { host.replaceChildren(el('p', error.message, 'error')); }
|
||
}
|
||
async function openDaily() {
|
||
showView('daily');
|
||
const host = $('#daily-content'); host.replaceChildren(el('p', '正在读取每日记录…', 'muted'));
|
||
history.replaceState(null, '', '/?view=daily');
|
||
try {
|
||
const data = await api('/api/daily');
|
||
host.replaceChildren();
|
||
try {
|
||
const queue = await api('/api/workqueue');
|
||
const work = el('section', undefined, 'report-section');
|
||
work.append(el('h2', '持续工作'));
|
||
const status = { pending: '待继续', running: '进行中', done: '已完成', blocked: '需要处理' };
|
||
const tasks = Array.isArray(queue.tasks) ? queue.tasks : [];
|
||
if (!tasks.length) work.append(el('p', '暂无任务。', 'muted'));
|
||
else { const list = el('ul'); for (const task of tasks) list.append(el('li', `${task.title} · ${status[task.status] || task.status} · ${task.updated_at ? dateTime(task.updated_at) : '时间待确认'}`)); work.append(list); }
|
||
host.append(work);
|
||
} catch { host.append(el('p', '暂时无法读取持续工作状态。', 'error')); }
|
||
if (data.invalid_count) host.append(el('p', `${data.invalid_count} 条记录无法读取,请检查数据文件。`, 'error'));
|
||
if (!data.records.length) host.append(el('p', '暂无每日记录。', 'empty'));
|
||
for (const record of data.records) {
|
||
const day = el('details', undefined, 'report-section');
|
||
const heading = el('summary', record.date); day.append(heading);
|
||
for (const [title, field] of [['已计划', 'planned'], ['实际执行', 'executed'], ['证据', 'evidence'], ['成本', 'costs'], ['复盘', 'review'], ['明日任务', 'tomorrow']]) day.append(listSection(title, record[field]));
|
||
host.append(day);
|
||
}
|
||
} catch (error) { host.replaceChildren(el('p', error.message, 'error')); }
|
||
}
|
||
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();
|
||
if (initialView === 'daily') await openDaily();
|
||
if (initialView === 'execution') await openExecution(false);
|
||
if (!selected && !initialView) await openOverview();
|
||
}
|
||
$('#show-execution').addEventListener('click', () => openExecution());
|
||
setInterval(() => { if (dashboard && !$('#execution-view').hidden) openExecution(false); }, 60000);
|
||
$('#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)}` : '/'); });
|
||
$('#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(); $('#daily-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; }
|
||
})();
|