Compare commits

...

16 Commits

Author SHA1 Message Date
wuyang 9e5f16dfb8 Show daily scheduled usage in operations view 2026-11-04 14:44:55 +08:00
wuyang ed978ac0ae Reflect unrestricted daily operations in dashboard 2026-11-04 14:42:19 +08:00
wuyang a5b0b5c9e8 Clarify work-trigger and fixed schedules 2026-11-04 12:23:43 +08:00
wuyang 204387efa2 Show actual operations execution and history 2026-11-04 12:22:08 +08:00
wuyang ac440f5299 Handle platform coin units in operations ledger 2026-11-04 11:14:12 +08:00
wuyang dde9742f9c Add operations overview and category usage tracking 2026-11-04 11:10:29 +08:00
wuyang 7fc9d4d843 Separate paid fallback usage in operations reports 2026-11-04 10:22:03 +08:00
wuyang 713c3cd0a2 Show persistent operations queue 2026-11-04 10:11:04 +08:00
wuyang 830d5b722a Include scheduled operations usage in weekly reports 2026-11-03 22:22:29 +08:00
wuyang ad86a1661b Show authenticated daily operation records 2026-11-03 20:57:42 +08:00
wuyang 84fc19c965 Expose private operational evolution timeline 2026-11-03 17:29:10 +08:00
wuyang 20aea13054 Add private operations portal and weekly accounting reports 2026-11-03 16:45:50 +08:00
wuyang 10c4487463 Add lightweight offline web game for tablets 2026-11-03 16:45:50 +08:00
wuyang 4a99cd94a7 Require original signing material for release builds 2026-11-03 16:45:50 +08:00
wuyang 22f9a3b8ad build: use a portable default toolchain path 2026-10-24 22:19:29 +08:00
wuyang 42f4c427d2 docs: point install steps at the published release APK 2026-10-24 22:15:20 +08:00
34 changed files with 1923 additions and 6 deletions
+4
View File
@@ -20,3 +20,7 @@ app/keystore/
*.iml *.iml
.DS_Store .DS_Store
Thumbs.db Thumbs.db
# Local Python caches
__pycache__/
*.pyc
+20 -3
View File
@@ -79,18 +79,18 @@
- 大小:约 209 KB - 大小:约 209 KB
- 权限:**无** - 权限:**无**
- 签名:本机自签名(不是应用商店版本) - 签名:本机自签名(不是应用商店版本)
- APK:从 <https://git.k1412.top/wuyang/pawplay/releases> 下载 - APK:[PawPlay-1.0.1.apk](https://git.k1412.top/wuyang/pawplay/releases/download/v1.0.1/PawPlay-1.0.1.apk)(约 209 KB)
安装: 安装:
```bash ```bash
adb install -r 猫爪游乐场-1.0.1.apk adb install -r PawPlay-1.0.1.apk
``` ```
也可以用系统包管理器安装(例如在设备本机的 shell 中): 也可以用系统包管理器安装(例如在设备本机的 shell 中):
```bash ```bash
pm install -r --user 0 /data/local/tmp/猫爪游乐场-1.0.1.apk pm install -r --user 0 /data/local/tmp/PawPlay-1.0.1.apk
``` ```
启动: 启动:
@@ -139,6 +139,15 @@ TOOLCHAIN=/path/to/toolchain bash app/build.sh
如果 `build.sh` 没有执行权限,用 `bash app/build.sh` 即可。 如果 `build.sh` 没有执行权限,用 `bash app/build.sh` 即可。
默认会去 `$HOME/android-toolchain` 找工具链;如果放在别处,记得导出 `TOOLCHAIN`。一个可用的目录结构示例:
```
$HOME/android-toolchain/
android-35/android.jar
android-34/android.jar
android-15/lib/d8.jar
```
可用环境变量覆盖: 可用环境变量覆盖:
- `ANDROID_JAR`:android-35/android.jar - `ANDROID_JAR`:android-35/android.jar
@@ -149,6 +158,14 @@ TOOLCHAIN=/path/to/toolchain bash app/build.sh
输出:`app/猫爪游乐场.apk`。 输出:`app/猫爪游乐场.apk`。
发布更新时,设置 `RELEASE_BUILD=1`,并通过 `KEYSTORE` 指向原签名密钥。此模式下密钥缺失会立即报错,不会生成新密钥。密钥不提交 Git,签名密码可通过 `STOREPASS` 和 `KEYPASS` 环境变量提供。
构建后用 `apksigner verify --print-certs` 比对已发布 APK 与新 APK 的签名证书,证书一致后再发布。当前 v1.0.1 的签名证书 SHA-256:
```text
40613d8504936be2db15f0d1094f3bf9f693950c726e357b7f3af04f17d53cfb
```
重新生成音效: 重新生成音效:
```bash ```bash
+9 -3
View File
@@ -3,7 +3,7 @@
set -euo pipefail set -euo pipefail
ROOT=$(cd -- "$(dirname -- "$0")" && pwd) ROOT=$(cd -- "$(dirname -- "$0")" && pwd)
TOOLCHAIN=${TOOLCHAIN:-/root/Documents/Codex/2026-10-12/ub7f-2s8z-chatgpt-1510/work/toolchain} TOOLCHAIN=${TOOLCHAIN:-$HOME/android-toolchain}
ANDROID_JAR=${ANDROID_JAR:-$TOOLCHAIN/android-35/android.jar} ANDROID_JAR=${ANDROID_JAR:-$TOOLCHAIN/android-35/android.jar}
RESOURCE_JAR=${RESOURCE_JAR:-$TOOLCHAIN/android-34/android.jar} RESOURCE_JAR=${RESOURCE_JAR:-$TOOLCHAIN/android-34/android.jar}
D8_JAR=${D8_JAR:-$TOOLCHAIN/android-15/lib/d8.jar} D8_JAR=${D8_JAR:-$TOOLCHAIN/android-15/lib/d8.jar}
@@ -11,10 +11,16 @@ AAPT2=${AAPT2:-aapt2}
KEYSTORE=${KEYSTORE:-$ROOT/keystore/catplay.jks} KEYSTORE=${KEYSTORE:-$ROOT/keystore/catplay.jks}
STOREPASS=${STOREPASS:-catplay-local} STOREPASS=${STOREPASS:-catplay-local}
KEYPASS=${KEYPASS:-catplay-local} KEYPASS=${KEYPASS:-catplay-local}
export STOREPASS KEYPASS
ALIAS=${ALIAS:-catplay} ALIAS=${ALIAS:-catplay}
BUILD_DIR=${BUILD_DIR:-$ROOT/build} BUILD_DIR=${BUILD_DIR:-$ROOT/build}
APK_OUT=${APK_OUT:-$ROOT/猫爪游乐场.apk} APK_OUT=${APK_OUT:-$ROOT/猫爪游乐场.apk}
if [[ ${RELEASE_BUILD:-0} == 1 && ! -f "$KEYSTORE" ]]; then
echo "release build requires the existing signing keystore: $KEYSTORE" >&2
exit 1
fi
for f in "$ANDROID_JAR" "$RESOURCE_JAR" "$D8_JAR"; do for f in "$ANDROID_JAR" "$RESOURCE_JAR" "$D8_JAR"; do
[[ -f "$f" ]] || { echo "missing toolchain file: $f" >&2; exit 1; } [[ -f "$f" ]] || { echo "missing toolchain file: $f" >&2; exit 1; }
done done
@@ -24,7 +30,7 @@ if [[ ! -f "$KEYSTORE" ]]; then
keytool -genkeypair -v \ keytool -genkeypair -v \
-keystore "$KEYSTORE" -storetype PKCS12 \ -keystore "$KEYSTORE" -storetype PKCS12 \
-alias "$ALIAS" -keyalg RSA -keysize 2048 -validity 10000 \ -alias "$ALIAS" -keyalg RSA -keysize 2048 -validity 10000 \
-storepass "$STOREPASS" -keypass "$KEYPASS" \ -storepass:env STOREPASS -keypass:env KEYPASS \
-dname "CN=PawPlay, OU=Local, O=PawPlay, L=Local, ST=Local, C=CN" >/dev/null -dname "CN=PawPlay, OU=Local, O=PawPlay, L=Local, ST=Local, C=CN" >/dev/null
fi fi
@@ -58,7 +64,7 @@ PY
rm -f "$APK_OUT" rm -f "$APK_OUT"
apksigner sign \ apksigner sign \
--ks "$KEYSTORE" --ks-key-alias "$ALIAS" \ --ks "$KEYSTORE" --ks-key-alias "$ALIAS" \
--ks-pass "pass:$STOREPASS" --key-pass "pass:$KEYPASS" \ --ks-pass env:STOREPASS --key-pass env:KEYPASS \
--out "$APK_OUT" "$BUILD_DIR/unsigned.apk" --out "$APK_OUT" "$BUILD_DIR/unsigned.apk"
apksigner verify --verbose "$APK_OUT" | sed -n '1,12p' apksigner verify --verbose "$APK_OUT" | sed -n '1,12p'
echo "Built: $APK_OUT" echo "Built: $APK_OUT"
+6
View File
@@ -0,0 +1,6 @@
*
!package.json
!server.mjs
!password.mjs
!public/
!public/**
+6
View File
@@ -0,0 +1,6 @@
.env
.env.*
data/
node_modules/
*.log
test-artifacts/
+9
View File
@@ -0,0 +1,9 @@
FROM node:22-alpine
ENV NODE_ENV=production PORT=8080 DATA_DIR=/data
WORKDIR /app
COPY --chown=node:node package.json server.mjs password.mjs ./
COPY --chown=node:node public ./public
USER node
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s CMD node -e "fetch('http://127.0.0.1:8080/healthz').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"
CMD ["node", "server.mjs"]
+124
View File
@@ -0,0 +1,124 @@
# PawPlay 私有运营台
周报、预算和收支台账的只读网站。无前端构建、无第三方运行依赖,Node.js 22 及以上可直接运行。数据目录与代码、镜像分离;上传视频、采集数据和生成周报由独立任务完成。
## 配置
必需环境变量:
| 名称 | 内容 |
| --- | --- |
| `USERNAME` | 管理员账号,无默认值 |
| `PASSWORD_HASH` | 下述脚本生成的 scrypt 哈希,无默认密码 |
| `SESSION_SECRET` | 随机生成、至少 32 字符的独立会话密钥 |
| `PUBLIC_ORIGIN` | 对外 HTTPS 源,例如 `https://pawplay-ops.example.com`,不带路径或结尾斜杠 |
`DATA_DIR` 默认 `./data`,容器内默认 `/data`。`PORT` 默认 `8080`,监听 `0.0.0.0`。
密码通过标准输入传给 `node scripts/hash-password.mjs`,不要放进命令参数、Git 或日志。该脚本输出 `scrypt$16384$8$1$<base64url-salt>$<base64url-key>`。要求密码至少 16 字符;环境文件权限应为 `0600`,只由管理员访问。Compose 的 `env_file` 中将哈希值包在单引号内,避免 `$` 被插值。
使用自己的凭据配置环境后,运行 `npm start`。本地 HTTP 预览可以显式设置 `COOKIE_SECURE=false`,同时将 `PUBLIC_ORIGIN` 指向准确的 `http://localhost:<port>` 或 `http://127.0.0.1:<port>`。该开关拒绝非回环地址;生产环境必须保留默认安全 Cookie。
认证会话有效期 4 小时。退出登录会立即撤销当前 Cookie;服务重启会撤销所有会话。更换密码时更新 `PASSWORD_HASH` 并重新启动此服务。网站没有在线重设密码入口,也不记录密码、Cookie 或登录请求体。
## 数据格式
所有金额使用数值或 `null`。`null` 表示未知,不应以 0 代替。不同币种、现金支出、固定套餐分摊和预估费用分开保存;套餐内 Token 用量不能重复计作现金支出。账户总用量不能直接归因到 PawPlay。
`config.json`:
```json
{
"title": "PawPlay 运营台",
"timezone": "Asia/Shanghai",
"currency": "USD",
"budget": {
"subscription_usd": 200,
"billing_period": "pending",
"project_allocation_fraction": null,
"extra_api_weekly_limit_usd": null
},
"links": {"product": "", "repository": ""}
}
```
`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
{
"entries": [
{
"id": "cost-001",
"date": "2026-11-03",
"label": "本周套餐分摊",
"category": "subscription",
"amount": null,
"currency": "USD",
"basis": "allocated",
"source": "计费周期和项目占比待确认"
}
]
}
```
`basis` 支持 `cash`、`allocated`、`estimate`、`settled`。支持额外元数据,例如 `project`、`status`、`usage`;网站不据此重新计算金额。
`reports/2026-W45.json`:
```json
{
"id": "2026-W45",
"title": "第 45 周运营报告",
"period_start": "2026-10-30T20:00:00+08:00",
"period_end": "2026-11-06T20:00:00+08:00",
"published_at": "2026-11-06T20:00:00+08:00",
"summary": "本周进展摘要。",
"highlights": ["完成的工作"],
"risks": ["需要关注的问题"],
"next_actions": ["下一周计划"],
"decisions": ["已确认的决策"],
"metrics": {"views": null, "downloads": {"value": null, "source": "尚未接入采集"}},
"costs": {"cash_usd": null, "allocated_usd": null, "estimated_usd": null},
"revenue": {"settled_cny": null, "estimated_cny": null}
}
```
文件名必须与 `id` 一致。编号只接受字母、数字、下划线和连字符,长度不超过 80 字符。页面按 `period_end` 倒序展示;`metrics` 支持嵌套对象,页面以可读字段呈现。周报 JSON 可在登录后下载。报告生成任务应使用临时文件写完后原子替换,避免读到半份 JSON。
`period_start` 和 `period_end` 可使用 ISO 日期时间,页面统一以北京时间显示。当前未结束周期的快照添加 `"kind": "snapshot"`,页面会注明“截至当前的部分周期”。
数据中不要放密码、Token、浏览器会话或签名密钥。文件下载受同样的认证保护。缺失文件按待核实展示;损坏 JSON 返回读取失败,不会静默按零处理。
## Docker 与反向代理
```sh
docker build -t pawplay-ops .
```
镜像内使用非 root 的 `node` 用户,内部端口 `8080`。生产 Compose 应将私有环境文件通过 `env_file` 注入,将报告目录挂载为 `/data:ro`,并确保该目录对容器的 UID 1000 可读。环境文件不挂载到 `/data`,不复制到镜像。该服务不需要写入报告目录;可设置只读根文件系统、`cap_drop: [ALL]` 和 `no-new-privileges`。
Nginx Proxy Manager 终止 HTTPS,转发到所选 NAS 端口。`PUBLIC_ORIGIN` 必须与用户访问的域名一致。反向代理无需读取报告内容或额外添加认证旁路。`GET /healthz` 可在 NAS 内网使用 HTTP 检查,只返回 `{"ok":true}`;它不代表报告采集已完成。
报告、台账以及配置的所有 API 均在服务端校验会话。报告不会作为静态资源提供。登录静态页面可匿名访问,不能从中读取项目运营数据。登录按连接来源限速;反向代理下来源可能合并,因此单人运营站点优先使用当前的保守限制。
## 检查
```sh
npm test
```
覆盖:缺配置时拒绝启动、密码散列、匿名隔离、登录与注销撤销、会话篡改、Secure Cookie、来源限制、登录限速、路径穿越和符号链接防护、异常请求体及金额空值保留。
## 每日计划与复盘
登录后选择「每日计划与复盘」,只读查看 `DATA_DIR/daily/YYYY-MM-DD.json`,日期倒序。每个文件为 JSON 对象,`date` 须与文件名一致,`planned`、`executed`、`evidence`、`costs`、`review`、`tomorrow` 为字符串数组,可省略表示暂无记录;金额未知应明确写待核实,不能默认为零。示例是结构说明,不代表实际工作:
```json
{"date":"2026-11-03","planned":["示例计划"],"executed":[],"evidence":[],"costs":["待核实"],"review":[],"tomorrow":[]}
```
仅日期文件名参与读取;无效日期、JSON、字段类型或越过数据目录的符号链接将跳过并在页面显示数量。渲染使用文本节点,网页不提供写入或命令执行。每日 Markdown 记录不会自动变成 JSON,须由运营记录流程生成对应 JSON;更新需通过原有挂载数据目录,部署需单独执行。
+8
View File
@@ -0,0 +1,8 @@
{
"name": "pawplay-ops",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {"start": "node server.mjs", "test": "node --test", "hash-password": "node scripts/hash-password.mjs"},
"engines": {"node": ">=22"}
}
+19
View File
@@ -0,0 +1,19 @@
import { randomBytes, scryptSync, timingSafeEqual } from 'node:crypto';
export function hashPassword(password) {
if (password.length < 16 || password.length > 1024) throw new Error('Password must contain 16–1024 characters');
const salt = randomBytes(16).toString('base64url');
const key = scryptSync(password, salt, 64, { N: 16384, r: 8, p: 1 }).toString('base64url');
return `scrypt$16384$8$1$${salt}$${key}`;
}
export function validatePasswordHash(value) {
if (!/^scrypt\$16384\$8\$1\$[A-Za-z0-9_-]{22}\$[A-Za-z0-9_-]{86}$/.test(value || '')) throw new Error('PASSWORD_HASH must be generated by scripts/hash-password.mjs');
}
export function verifyPassword(password, hash) {
if (typeof password !== 'string' || password.length > 1024) return false;
validatePasswordHash(hash);
const [, , , , salt, expected] = hash.split('$');
return timingSafeEqual(scryptSync(password, salt, 64, { N: 16384, r: 8, p: 1 }), Buffer.from(expected, 'base64url'));
}
+252
View File
@@ -0,0 +1,252 @@
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.limit == null ? `今日主运营尝试:${data.daily_budget.attempts} 次 · 暂无每日固定次数上限,持续记录用量。` : `今日主运营尝试:${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);
if (data.daily_usage) {
const usage = el('section', undefined, 'report-section'); usage.append(el('h2', '今日运行用量'), el('p', '仅统计已结束的定时运营;输入包含缓存输入。套餐不是按 token 逐笔收费,实际额外账单另行核对。', 'muted'));
for (const [key, label] of [['subscription', '订阅'], ['api', 'API 备用']]) { const v = data.daily_usage[key] || {}; usage.append(el('p', `${label} · ${v.runs ?? 0} 轮 · 输入 ${v.input_tokens ?? 0}(其中缓存 ${v.cached_input_tokens ?? 0})· 输出 ${v.output_tokens ?? 0} token`)); } host.append(usage);
}
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; }
})();
+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"><nav class="view-tabs" aria-label="浏览内容"><button id="show-overview" class="active" type="button" aria-pressed="true">运营全景</button><button id="show-execution" type="button" aria-pressed="false">运行与历史</button><button id="show-report" type="button" aria-pressed="false">运营报告</button><button id="show-evolution" type="button" aria-pressed="false">演变路径</button><button id="show-daily" type="button" aria-pressed="false">每日计划与复盘</button></nav><section id="overview-view"><div class="page-heading"><div><p class="eyebrow">XINGQI · OPERATIONS</p><h1>星栖运营全景</h1><p class="muted">方向、作品、研究和预算,放在一起看。</p></div></div><div id="overview-content"></div></section><section id="execution-view" hidden><div class="page-heading"><div><p class="eyebrow">XINGQI · EXECUTION</p><h1>运行与历史</h1><p class="muted">区分实际运行、等待执行和已结束的工作。</p></div></div><div id="execution-content"></div></section><div id="report-view" hidden><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><section id="daily-view" hidden><div class="page-heading"><div><p class="eyebrow">PAWPLAY · DAILY</p><h1>每日计划与复盘</h1><p class="muted">按日期查看计划、执行、证据与下一步。</p></div></div><div id="daily-content"></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
+8
View File
@@ -0,0 +1,8 @@
import { hashPassword } from '../password.mjs';
let input = '';
for await (const chunk of process.stdin) {
input += chunk;
if (input.length > 2048) throw new Error('Password input too long');
}
process.stdout.write(hashPassword(input.replace(/\r?\n$/, '')) + '\n');
+214
View File
@@ -0,0 +1,214 @@
import http from 'node:http';
import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
import { readFile, readdir, realpath } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { validatePasswordHash, verifyPassword } from './password.mjs';
const root = path.dirname(fileURLToPath(import.meta.url));
const reportId = /^[A-Za-z0-9][A-Za-z0-9_-]{0,79}$/;
const sessionSeconds = 4 * 60 * 60;
const mime = { '/': 'text/html; charset=utf-8', '/login': 'text/html; charset=utf-8', '/app.js': 'text/javascript; charset=utf-8', '/style.css': 'text/css; charset=utf-8' };
const files = { '/': 'index.html', '/login': 'index.html', '/app.js': 'app.js', '/style.css': 'style.css' };
export function createApp(options = process.env) {
const username = options.USERNAME;
const passwordHash = options.PASSWORD_HASH;
const secret = options.SESSION_SECRET;
const secure = options.COOKIE_SECURE !== 'false';
const origin = options.PUBLIC_ORIGIN;
const dataDir = path.resolve(options.DATA_DIR || './data');
if (!username || username.length > 128) throw new Error('USERNAME is required');
validatePasswordHash(passwordHash);
if (!secret || secret.length < 32) throw new Error('SESSION_SECRET must contain at least 32 characters');
let parsedOrigin;
try { parsedOrigin = new URL(origin); } catch { throw new Error('PUBLIC_ORIGIN is required'); }
if (parsedOrigin.origin !== origin || (secure && parsedOrigin.protocol !== 'https:')) throw new Error('PUBLIC_ORIGIN must be an HTTPS origin without a trailing slash');
if (!secure && !['127.0.0.1', 'localhost', '[::1]'].includes(parsedOrigin.hostname)) throw new Error('Insecure cookies are allowed only for a loopback origin');
const cookieName = secure ? '__Host-pawplay_session' : 'pawplay_session';
const sessions = new Map();
const attempts = new Map();
const sign = value => createHmac('sha256', secret).update(value).digest('base64url');
const equal = (a, b) => timingSafeEqual(createHash('sha256').update(a).digest(), createHash('sha256').update(b).digest());
const cookie = (value, age) => `${cookieName}=${value}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${age}${secure ? '; Secure' : ''}`;
function session(req) {
const value = (req.headers.cookie || '').split(';').map(s => s.trim()).find(s => s.startsWith(cookieName + '='))?.slice(cookieName.length + 1);
if (!value || value.length > 500) return null;
const [payload, signature, extra] = value.split('.');
if (extra || !signature || !equal(signature, sign(payload))) return null;
let record;
try { record = JSON.parse(Buffer.from(payload, 'base64url').toString()); } catch { return null; }
if (!record.id || !Number.isFinite(record.exp) || record.exp <= Date.now() || sessions.get(record.id) !== record.exp) {
if (record.id) sessions.delete(record.id);
return null;
}
return record;
}
async function jsonFile(file, fallback) {
let target;
try { target = await realpath(path.join(dataDir, file)); }
catch (error) { if (error.code === 'ENOENT') return fallback; throw error; }
const actualRoot = await realpath(dataDir);
if (!target.startsWith(actualRoot + path.sep)) throw new Error('Data path escapes DATA_DIR');
return JSON.parse(await readFile(target, 'utf8'));
}
async function reports() {
let names;
try { names = await readdir(path.join(dataDir, 'reports')); }
catch (error) { if (error.code === 'ENOENT') return []; throw error; }
const results = [];
for (const name of names.filter(n => n.endsWith('.json') && reportId.test(n.slice(0, -5)))) {
const value = await jsonFile(`reports/${name}`, null);
if (value && value.id === name.slice(0, -5)) results.push(value);
}
return results.sort((a, b) => String(b.period_end || b.id).localeCompare(String(a.period_end || a.id)));
}
async function dailyRecords() {
let directory;
try { directory = await realpath(path.join(dataDir, 'daily')); }
catch (error) { if (error.code === 'ENOENT') return { records: [], invalid_count: 0 }; throw error; }
const actualRoot = await realpath(dataDir);
if (!directory.startsWith(actualRoot + path.sep)) throw new Error('Data path escapes DATA_DIR');
const names = await readdir(directory);
const fields = ['planned', 'executed', 'evidence', 'costs', 'review', 'tomorrow'];
const records = []; let invalidCount = 0;
for (const name of names.filter(n => /^\d{4}-\d{2}-\d{2}\.json$/.test(n)).sort().reverse()) {
const day = name.slice(0, -5);
const parsed = new Date(day + 'T00:00:00Z');
if (!Number.isFinite(parsed.getTime()) || parsed.toISOString().slice(0, 10) !== day) { invalidCount++; continue; }
try {
const item = await jsonFile(`daily/${name}`, null);
if (!item || Array.isArray(item) || typeof item !== 'object' || item.date !== day ||
fields.some(field => item[field] !== undefined && (!Array.isArray(item[field]) || item[field].some(value => typeof value !== 'string')))) {
invalidCount++; continue;
}
records.push({ date: day, ...Object.fromEntries(fields.map(field => [field, item[field] || []])) });
} catch { invalidCount++; }
}
return { records, invalid_count: invalidCount };
}
function json(res, status, body, extra = {}) {
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', ...extra });
res.end(JSON.stringify(body));
}
async function readBody(req) {
let body = '', bytes = 0;
for await (const chunk of req) {
bytes += chunk.length;
if (bytes <= 8192) body += chunk;
}
if (bytes > 8192) throw Object.assign(new Error('Body too large'), { status: 413 });
try {
const value = JSON.parse(body);
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Invalid JSON object');
return value;
} catch { throw Object.assign(new Error('Invalid JSON'), { status: 400 }); }
}
const server = http.createServer(async (req, res) => {
res.setHeader('Cache-Control', 'no-store');
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('X-Frame-Options', 'DENY');
res.setHeader('Referrer-Policy', 'no-referrer');
res.setHeader('Content-Security-Policy', "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'");
res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
try {
const rawPath = (req.url || '/').split('?')[0];
let route;
try { route = decodeURIComponent(rawPath); } catch { return json(res, 400, { error: '请求路径无效' }); }
if (route.includes('\\') || route.includes('\0') || route.split('/').some(p => p === '.' || p === '..')) return json(res, 400, { error: '请求路径无效' });
if (req.method === 'GET' && route === '/healthz') return json(res, 200, { ok: true });
if (req.method === 'GET' && route === '/api/session') return json(res, 200, { authenticated: !!session(req) });
if (req.method === 'POST') {
if (req.headers.origin !== origin || req.headers['sec-fetch-site'] === 'cross-site') return json(res, 403, { error: '请求来源无效' });
if (route === '/api/login') {
const now = Date.now(), ip = req.socket.remoteAddress;
for (const [key, entry] of attempts) if (entry.expires <= now) attempts.delete(key);
const attempt = attempts.get(ip) || { count: 0, expires: now + 15 * 60 * 1000 };
if (attempt.count >= 8) return json(res, 429, { error: '尝试过于频繁,请稍后再试' }, { 'Retry-After': '900' });
if (!attempts.has(ip) && attempts.size >= 2048) attempts.delete(attempts.keys().next().value);
attempt.count++; attempts.set(ip, attempt);
if (!req.headers['content-type']?.startsWith('application/json')) return json(res, 415, { error: '需要 JSON 请求' });
const body = await readBody(req);
const validPassword = verifyPassword(body.password, passwordHash);
if (typeof body.username !== 'string' || !equal(body.username, username) || !validPassword) return json(res, 401, { error: '账号或密码错误' });
attempts.delete(ip);
for (const [id, exp] of sessions) if (exp <= now) sessions.delete(id);
if (sessions.size >= 128) sessions.delete(sessions.keys().next().value);
const record = { id: randomBytes(24).toString('base64url'), exp: now + sessionSeconds * 1000 };
sessions.set(record.id, record.exp);
const payload = Buffer.from(JSON.stringify(record)).toString('base64url');
return json(res, 200, { ok: true }, { 'Set-Cookie': cookie(payload + '.' + sign(payload), sessionSeconds) });
}
if (route === '/api/logout') {
const current = session(req);
if (current) sessions.delete(current.id);
return json(res, 200, { ok: true }, { 'Set-Cookie': cookie('', 0) });
}
}
if (route.startsWith('/api/')) {
if (!session(req)) return json(res, 401, { error: '请先登录' });
if (req.method === 'GET' && route === '/api/execution') return json(res, 200, await jsonFile('ops-status.json', { schema: 1, observed_at: null, units: [], running_tasks: [], history: [] }));
if (req.method === 'GET' && route === '/api/overview') {
const strategy = await jsonFile('ops-strategy.json', {}), market = await jsonFile('market-latest.json', {});
const short = (v, n = 300) => typeof v === 'string' ? v.slice(0, n) : '';
const video = v => ({ bvid: /^BV[0-9A-Za-z]+$/.test(v?.bvid || '') ? v.bvid : '', title: short(v?.title, 150), owner: short(v?.owner, 80), views: Number.isFinite(v?.views) ? v.views : null, likes: Number.isFinite(v?.likes) ? v.likes : null, comments: Number.isFinite(v?.comments) ? v.comments : null });
return json(res, 200, { strategy: { mission: short(strategy.mission), pillars: Array.isArray(strategy.pillars) ? strategy.pillars.slice(0, 6).map(v => short(v)) : [], cadence: short(strategy.cadence), phase: short(strategy.phase), budget_note: short(strategy.budget_note), budget: Array.isArray(strategy.budget) ? strategy.budget.slice(0, 8).map(v => ({ name: short(v?.name, 50), percent: Number.isFinite(v?.percent) ? v.percent : null })) : [] }, market: { observed_at: short(market.observed_at), ranking: Array.isArray(market.ranking) ? market.ranking.slice(0, 5).map(video) : [], account_videos: Array.isArray(market.account_videos) ? market.account_videos.slice(0, 20).map(video) : [] } });
}
if (req.method === 'GET' && route === '/api/workqueue') {
const value = await jsonFile('workqueue.json', { schema: 1, tasks: [] });
if (value?.schema !== 1 || !Array.isArray(value.tasks)) return json(res, 500, { error: '工作队列格式无效' });
const valid = new Set(['pending', 'running', 'done', 'blocked']);
const categories = new Set(['content', 'engineering', 'research', 'community', 'management']);
const tasks = value.tasks.filter(t => t && typeof t.id === 'string' && typeof t.title === 'string' && valid.has(t.status))
.map(t => ({ title: t.id.startsWith('interaction-') ? '核查新互动' : t.title.slice(0, 100), status: t.status,
category: categories.has(t.category) ? t.category : 'management', attempts: Number.isInteger(t.attempts) ? t.attempts : 0, updated_at: typeof t.updated_at === 'string' ? t.updated_at : null }));
return json(res, 200, { tasks });
}
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/daily') return json(res, 200, await dailyRecords());
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' } });
const ledger = await jsonFile('ledger.json', { entries: [] });
return json(res, 200, { config, ledger, reports: list.map(({ id, title, period_start, period_end, published_at }) => ({ id, title, period_start, period_end, published_at })), latest: list[0] || null });
}
if (req.method === 'GET' && route.startsWith('/api/reports/')) {
const download = route.endsWith('/download');
const id = route.slice('/api/reports/'.length, download ? -'/download'.length : undefined);
if (!reportId.test(id)) return json(res, 400, { error: '报告编号无效' });
const report = await jsonFile(`reports/${id}.json`, null);
if (!report || report.id !== id) return json(res, 404, { error: '报告不存在' });
return json(res, 200, report, download ? { 'Content-Disposition': `attachment; filename="${id}.json"` } : {});
}
return json(res, 404, { error: '接口不存在' });
}
if (req.method === 'GET' && Object.hasOwn(files, route)) {
const body = await readFile(path.join(root, 'public', files[route]));
res.writeHead(200, { 'Content-Type': mime[route] });
return res.end(body);
}
return json(res, 404, { error: '页面不存在' });
} catch (error) {
if (!res.headersSent) json(res, error.status || 500, { error: error.status ? error.message : '暂时无法读取报告,请稍后重试' });
else res.end();
}
});
server.requestTimeout = 15000;
server.headersTimeout = 10000;
server.maxRequestsPerSocket = 200;
return server;
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
try {
const server = createApp();
server.listen(Number(process.env.PORT || 8080), '0.0.0.0', () => console.log('PawPlay operations portal listening'));
} catch (error) { console.error(error.message); process.exitCode = 1; }
}
+196
View File
@@ -0,0 +1,196 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { once } from 'node:events';
import { mkdtemp, mkdir, writeFile, rm, symlink } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import http from 'node:http';
import { createApp } from '../server.mjs';
import { hashPassword, verifyPassword } from '../password.mjs';
const password = 'a-test-password-with-enough-entropy';
const passwordHash = hashPassword(password);
const origin = 'http://localhost:9999';
const config = { USERNAME: 'owner', PASSWORD_HASH: passwordHash, SESSION_SECRET: 'test-secret-with-more-than-thirty-two-characters', PUBLIC_ORIGIN: origin, COOKIE_SECURE: 'false' };
async function fixture(t, options = {}) {
const directory = await mkdtemp(path.join(tmpdir(), 'pawplay-ops-test-'));
await mkdir(path.join(directory, 'reports'));
await writeFile(path.join(directory, 'config.json'), JSON.stringify({ title: 'PRIVATE-REPORT-TITLE', budget: { billing_period: 'pending' } }));
await writeFile(path.join(directory, 'ledger.json'), JSON.stringify({ entries: [{ amount: null, basis: 'cash' }] }));
await writeFile(path.join(directory, 'reports/2026-W45.json'), JSON.stringify({ id: '2026-W45', title: 'PRIVATE-WEEK', period_end: '2026-11-08', costs: { cash_usd: null } }));
const server = createApp({ ...config, DATA_DIR: directory, ...options });
server.listen(0, '127.0.0.1'); await once(server, 'listening');
const base = `http://127.0.0.1:${server.address().port}`;
t.after(async () => { server.closeAllConnections(); await new Promise(resolve => server.close(resolve)); await rm(directory, { recursive: true, force: true }); });
const request = (url, opts) => fetch(base + url, opts);
const login = async (pwd = password) => request('/api/login', { method: 'POST', headers: { Origin: origin, 'Content-Type': 'application/json' }, body: JSON.stringify({ username: 'owner', password: pwd }) });
return { server, directory, base, request, login };
}
test('fails closed without configured credentials or HTTPS production origin', () => {
assert.throws(() => createApp({}), /USERNAME/);
assert.throws(() => createApp({ ...config, SESSION_SECRET: 'short' }), /SESSION_SECRET/);
assert.throws(() => createApp({ ...config, COOKIE_SECURE: 'true' }), /HTTPS/);
assert.throws(() => createApp({ ...config, PUBLIC_ORIGIN: 'http://example.org' }), /loopback/);
assert.throws(() => createApp({ ...config, PASSWORD_HASH: 'plaintext' }), /PASSWORD_HASH/);
});
test('password hashing uses random salts and checks a supplied password', () => {
const another = hashPassword(password); assert.notEqual(another, passwordHash);
assert.equal(verifyPassword(password, another), true);
assert.equal(verifyPassword('wrong', another), false);
});
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/evolution', '/api/workqueue', '/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/);
}
const health = await request('/healthz'); assert.deepEqual(await health.json(), { ok: true });
assert.equal((await request('/')).status, 200);
assert.equal((await request('/reports/2026-W45.json')).status, 404);
});
test('work queue exposes only safe progress fields behind login', async t => {
const { directory, request, login } = await fixture(t);
await writeFile(path.join(directory, 'workqueue.json'), JSON.stringify({ schema: 1, tasks: [
{id:'interaction-private-a', title:'PRIVATE MESSAGE CONTENT', status:'pending', details:'PRIVATE MESSAGE', checkpoint:'PRIVATE CONTENT', attempts:1},
{id:'pilot',title:'动画试镜',status:'running',details:'SECRET',attempts:2,updated_at:'2026-11-04T01:00:00Z'}
] }));
assert.equal((await request('/api/workqueue')).status, 401);
const auth = await login(); const cookie = auth.headers.get('set-cookie').split(';')[0];
const response = await request('/api/workqueue', { headers: { cookie } });
const body = await response.text(); assert.doesNotMatch(body, /PRIVATE|SECRET/);
const data = JSON.parse(body); assert.equal(data.tasks[0].title, '核查新互动'); assert.equal(data.tasks[1].status, 'running');
});
test('login protects API and download, preserves unknown money, logout revokes copied cookie', async t => {
const { request, login } = await fixture(t);
const response = await login(); assert.equal(response.status, 200);
const setCookie = response.headers.get('set-cookie'); assert.match(setCookie, /HttpOnly/); assert.match(setCookie, /SameSite=Lax/);
const cookie = setCookie.split(';')[0];
const r = await request('/api/dashboard', { headers: { Cookie: cookie } });
assert.equal(r.status, 200); const d = await r.json(); assert.equal(d.latest.title, 'PRIVATE-WEEK'); assert.equal(d.ledger.entries[0].amount, null);
const download = await request('/api/reports/2026-W45/download', { headers: { Cookie: cookie } });
assert.equal(download.status, 200); assert.match(download.headers.get('content-disposition'), /attachment; filename="2026-W45.json"/);
assert.equal((await download.json()).costs.cash_usd, null);
const logout = await request('/api/logout', { method: 'POST', headers: { Cookie: cookie, Origin: origin } }); assert.equal(logout.status, 200);
assert.equal((await request('/api/dashboard', { headers: { Cookie: cookie } })).status, 401);
});
test('secure deployment cookie uses __Host prefix and Secure', async t => {
const { request } = await fixture(t, { COOKIE_SECURE: 'true', PUBLIC_ORIGIN: 'https://pawplay-ops.example.org' });
const r = await request('/api/login', { method: 'POST', headers: { Origin: 'https://pawplay-ops.example.org', 'Content-Type': 'application/json' }, body: JSON.stringify({ username: 'owner', password }) });
assert.equal(r.status, 200); assert.match(r.headers.get('set-cookie'), /^__Host-pawplay_session=/); assert.match(r.headers.get('set-cookie'), /; Secure/); assert.doesNotMatch(r.headers.get('set-cookie'), /Domain=/);
});
test('tampered session cannot access API', async t => {
const { request, login } = await fixture(t); const r = await login();
const cookie = r.headers.get('set-cookie').split(';')[0];
const mutated = cookie.slice(0, -1) + (cookie.endsWith('A') ? 'B' : 'A');
assert.equal((await request('/api/dashboard', { headers: { Cookie: mutated } })).status, 401);
});
test('cross-origin login/logout are refused and repeated bad passwords are throttled', async t => {
const { request, login } = await fixture(t);
for (const endpoint of ['/api/login', '/api/logout']) {
assert.equal((await request(endpoint, { method: 'POST', headers: { Origin: 'https://attacker.invalid' }, body: '{}' })).status, 403);
assert.equal((await request(endpoint, { method: 'POST', body: '{}' })).status, 403);
}
for (let i = 0; i < 8; i++) assert.equal((await login('wrong')).status, 401);
const denied = await login(); assert.equal(denied.status, 429); assert.equal(denied.headers.get('retry-after'), '900');
});
test('raw and encoded path traversal and symlinks never expose files outside data directory', async t => {
const { request, login, server, directory } = await fixture(t);
const r = await login(); const cookie = r.headers.get('set-cookie').split(';')[0];
for (const target of ['/api/reports/%2e%2e%2fconfig', '/api/reports/a%2fb', '/api/reports/%252e%252e', '/api/reports/%00']) {
const response = await request(target, { headers: { Cookie: cookie } }); assert.equal(response.status, 400);
}
const rawStatus = await new Promise(resolve => {
const request = http.request({ host: '127.0.0.1', port: server.address().port, path: '/api/reports/../../config.json', headers: { Cookie: cookie } }, response => { response.resume(); resolve(response.statusCode); }); request.end();
}); assert.equal(rawStatus, 400);
const outside = await mkdtemp(path.join(tmpdir(), 'pawplay-outside-')); t.after(() => rm(outside, { recursive: true, force: true }));
await writeFile(path.join(outside, 'secret.json'), JSON.stringify({ id: 'escape', secret: 'OUTSIDE-SECRET' }));
await symlink(path.join(outside, 'secret.json'), path.join(directory, 'reports/escape.json'));
const response = await request('/api/reports/escape', { headers: { Cookie: cookie } }); assert.equal(response.status, 500); assert.doesNotMatch(await response.text(), /OUTSIDE|secret|\/tmp/);
});
test('invalid JSON values and oversized bodies are rejected', async t => {
const { request } = await fixture(t);
const headers = { Origin: origin, 'Content-Type': 'application/json' };
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: [] });
});
test('daily records require authentication, sort by date, validate input and remain read-only', async t => {
const { directory, request, login } = await fixture(t);
await mkdir(path.join(directory, 'daily'));
const write = (name, value) => writeFile(path.join(directory, 'daily', name), value);
await write('2026-11-02.json', JSON.stringify({ date: '2026-11-02', planned: ['<script>private</script>'], costs: ['未知'] }));
await write('2026-11-03.json', JSON.stringify({ date: '2026-11-03', executed: ['完成'], evidence: ['证据'] }));
await write('2026-11-04.json', '{broken');
await write('2026-11-05.json', JSON.stringify({ date: '2026-11-05', planned: 'wrong type' }));
await write('2026-02-30.json', JSON.stringify({ date: '2026-02-30' }));
await write('ignored.json', 'bad');
const anonymous = await request('/api/daily'); assert.equal(anonymous.status, 401); assert.doesNotMatch(await anonymous.text(), /private|完成/);
assert.equal((await request('/daily/2026-11-02.json')).status, 404);
const cookie = (await login()).headers.get('set-cookie').split(';')[0];
const headers = { Cookie: cookie };
const result = await request('/api/daily', { headers }); assert.equal(result.status, 200);
const body = await result.json(); assert.equal(body.invalid_count, 3);
assert.deepEqual(body.records.map(r => r.date), ['2026-11-03', '2026-11-02']);
assert.deepEqual(body.records[0].planned, []); assert.deepEqual(body.records[0].executed, ['完成']);
assert.deepEqual(body.records[1].costs, ['未知']);
assert.equal((await request('/api/daily', { method: 'POST', headers: { ...headers, Origin: origin }, body: '{}' })).status, 404);
await rm(path.join(directory, 'daily'), { recursive: true });
assert.deepEqual(await (await request('/api/daily', { headers })).json(), { records: [], invalid_count: 0 });
});
test('daily symlinks cannot disclose files outside DATA_DIR', async t => {
const { directory, request, login } = await fixture(t);
const outside = await mkdtemp(path.join(tmpdir(), 'pawplay-daily-outside-')); t.after(() => rm(outside, { recursive: true, force: true }));
await mkdir(path.join(directory, 'daily'));
await writeFile(path.join(outside, 'secret.json'), JSON.stringify({ date: '2026-11-03', executed: ['OUTSIDE-SECRET'] }));
await symlink(path.join(outside, 'secret.json'), path.join(directory, 'daily/2026-11-03.json'));
const cookie = (await login()).headers.get('set-cookie').split(';')[0];
const response = await request('/api/daily', { headers: { Cookie: cookie } });
assert.equal(response.status, 200); const text = await response.text(); assert.doesNotMatch(text, /OUTSIDE-SECRET/);
assert.deepEqual(JSON.parse(text), { records: [], invalid_count: 1 });
});
test('overview is authenticated and exposes only selected fields', async t => {
const { directory, request, login } = await fixture(t);
await writeFile(path.join(directory, 'ops-strategy.json'), JSON.stringify({ mission:'星栖', private:'SECRET' }));
await writeFile(path.join(directory, 'market-latest.json'), JSON.stringify({ observed_at:'2026-11-04', ranking:[{bvid:'https://evil.test',title:'榜单',views:1,private:'SECRET'}],account_videos:[] }));
assert.equal((await request('/api/overview')).status, 401);
const cookie = (await login()).headers.get('set-cookie').split(';')[0];
const response = await request('/api/overview', { headers:{cookie} }); const text = await response.text();
assert.doesNotMatch(text, /SECRET|evil/); const data = JSON.parse(text);
assert.equal(data.strategy.mission, '星栖'); assert.equal(data.market.ranking[0].bvid, '');
});
test('execution status is private and absent data degrades safely', async t => {
const { directory, request, login } = await fixture(t);
await writeFile(path.join(directory, 'ops-status.json'), JSON.stringify({schema:1,observed_at:'2026-11-04T04:00:00Z',units:[],running_tasks:[],history:[{title:'任务',status:'complete'}]}));
assert.equal((await request('/api/execution')).status, 401);
const cookie = (await login()).headers.get('set-cookie').split(';')[0];
const data = await (await request('/api/execution', { headers:{cookie} })).json();
assert.equal(data.history[0].title, '任务'); assert.equal(data.history[0].status, 'complete');
});
+21
View File
@@ -0,0 +1,21 @@
# 运营周报
每周五北京时间 20:00 生成报告,覆盖上周五 20:00 到本周五 20:00。阶段快照只覆盖截至生成时的部分周期,历史周报不覆盖改写。
```bash
python3 ops/report.py --data-dir "$PAWPLAY_OPS_DATA" --snapshot
python3 ops/report.py --data-dir "$PAWPLAY_OPS_DATA" --notify
python3 -m unittest discover -s ops -p 'test_*.py' -v
```
数据放在仓库外的私有目录,不加入 Git 或镜像。运营门户只读挂载该目录;格式见 `ops-portal/README.md`。`collector.json` 为本机采集配置,不由网站 API 返回,可包含 `repository`、`thread_id`、`codex_sessions_dir`、`backup_dir`、`bark_script` 和 `portal_url`。只保存模型用量汇总,不把对话、密钥或浏览器会话作为报告内容。
`findings.json` 的 `checked_at` 标注证据核验时间;`highlights` 可用字符串或 `{date,text}` 事件。过期成果不会重复计为本周完成。任务和风险持续保留,直到其状态有证据更新。
套餐按 200 美元/月、跨月实际时间和项目分摊比例记固定成本。周额度重置不产生新周费,套餐内 token 不按 API 单价重复计费。缓存输入、推理输出作为用量单列,不重复相加;日志计数不等于周额度百分比。额外 API、实际现金支出、已结算收入、预估收入分别记账。未知值使用 `null`,不当作零成本或零收入。
收入条目使用 `direction: income`、`basis: settled`,支出使用 `direction: expense`;`date` 使用含时区的时间戳。不同货币不直接相减;未确认汇率和完整支出前不宣称净利润。当前免费自研 App 的原创介绍正常推进,创作激励适用性按当前账号及具体稿件后台核验,不能仅凭旧公告预设无收益。
安装两个 systemd user unit 前,配置受保护的 `~/.config/pawplay-ops/report.env`,其中 `PAWPLAY_REPO` 指向项目目录,`PAWPLAY_OPS_DATA` 指向私有数据目录。`Persistent=true` 会在错过计划时间后补跑最近一期;不会追补所有历史周次。配置 `backup_dir` 时,生成报告同时将报告和账本复制到独立备份目录。
周报是数据归档任务,不会自动投稿、购买服务、生成视频或修改应用。发布稿件后,在 `content.json` 登记 BV 号;当前播放数据采集未接入,有已发布稿件时相关指标保留待核验。平台结算数据须按实际到账或后台结果记账。
+14
View File
@@ -0,0 +1,14 @@
[Unit]
Description=PawPlay weekly operations report and archive
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
EnvironmentFile=%h/.config/pawplay-ops/report.env
ExecStart=/usr/bin/python3 ${PAWPLAY_REPO}/ops/report.py --data-dir ${PAWPLAY_OPS_DATA} --notify
UMask=0077
TimeoutStartSec=180
NoNewPrivileges=true
PrivateTmp=true
+11
View File
@@ -0,0 +1,11 @@
[Unit]
Description=PawPlay report every Friday at 20:00 Asia/Shanghai
[Timer]
OnCalendar=Fri *-*-* 20:00:00 Asia/Shanghai
Persistent=true
AccuracySec=1min
Unit=pawplay-weekly-report.service
[Install]
WantedBy=timers.target
+300
View File
@@ -0,0 +1,300 @@
#!/usr/bin/env python3
"""Create a source-backed PawPlay weekly report without an LLM request."""
import argparse
import calendar
import datetime as dt
import json
import os
from pathlib import Path
import shutil
import subprocess
import tempfile
from zoneinfo import ZoneInfo
TZ = ZoneInfo('Asia/Shanghai')
def read_json(path, default=None):
try:
return json.loads(Path(path).read_text())
except FileNotFoundError:
return default
def save_json(path, value):
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
with tempfile.NamedTemporaryFile('w', dir=path.parent, delete=False, encoding='utf-8') as f:
json.dump(value, f, ensure_ascii=False, indent=2)
f.write('\n')
staging = f.name
os.replace(staging, path)
def subscription_allocation(budget, start, end):
"""Prorate calendar days; token counts are never a second subscription bill."""
price = budget.get('subscription_usd')
share = budget.get('project_allocation_fraction')
period = budget.get('billing_period')
if price is None or share is None or period not in ('monthly', 'weekly'):
return None
if not isinstance(start, dt.datetime):
start = dt.datetime.combine(start, dt.time(), TZ)
if not isinstance(end, dt.datetime):
end = dt.datetime.combine(end, dt.time(), TZ)
total = 0.0
cursor = start.astimezone(TZ)
end = end.astimezone(TZ)
while cursor < end:
day = cursor.date()
divisor = calendar.monthrange(day.year, day.month)[1] if period == 'monthly' else 7
next_midnight = dt.datetime.combine(day + dt.timedelta(days=1), dt.time(), TZ)
step_end = min(next_midnight, end)
total += float(price) * float(share) / divisor * ((step_end - cursor).total_seconds() / 86400)
cursor = step_end
return round(total, 2)
def session_metadata(path):
with path.open() as f:
for _ in range(4):
line = f.readline()
if not line:
break
try:
value = json.loads(line)
except ValueError:
continue
if value.get('type') == 'session_meta':
return value.get('payload', {})
return {}
def codex_usage(session_root, thread_id, start, end):
"""Use cumulative counter deltas, scoped to the chat and its descendants."""
if not session_root or not thread_id:
return {'available': False, 'reason': '项目会话来源未配置'}
candidates = []
for path in Path(session_root).rglob('*.jsonl'):
meta = session_metadata(path)
candidates.append((path, meta))
allowed = {thread_id}
changed = True
while changed:
changed = False
for _, meta in candidates:
source = meta.get('source', {})
spawn = source.get('subagent', {}).get('thread_spawn', {}) if isinstance(source, dict) else {}
if spawn.get('parent_thread_id') in allowed and meta.get('id') not in allowed:
allowed.add(meta['id'])
changed = True
totals = {k: 0 for k in ('input_tokens', 'cached_input_tokens', 'output_tokens', 'reasoning_output_tokens', 'total_tokens')}
sessions = 0
for path, meta in candidates:
if meta.get('id') not in allowed:
continue
sessions += 1
previous = dict.fromkeys(totals, 0)
with path.open() as handle:
for line in handle:
try:
event = json.loads(line)
payload = event.get('payload', {})
if event.get('type') != 'event_msg' or payload.get('type') != 'token_count':
continue
current = (payload.get('info') or {}).get('total_token_usage')
if not current:
continue
stamp = dt.datetime.fromisoformat(event['timestamp'].replace('Z', '+00:00'))
if start <= stamp < end:
for key in totals:
totals[key] += max(0, int(current.get(key, 0)) - previous[key])
previous = {k: int(current.get(k, 0)) for k in totals}
except (ValueError, KeyError, TypeError):
continue
return {'available': sessions > 0, 'session_count': sessions, **totals,
'uncached_input_tokens': max(0, totals['input_tokens'] - totals['cached_input_tokens']),
'measurement_note': '会话日志计数含重复处理的上下文和缓存;不是唯一文字数、周额度百分比或API账单。推理token包含于输出计数,不重复相加。'}
def scheduled_codex_usage(directory, start, end):
"""Read bounded cycle summaries separately from the interactive thread."""
if not directory or not Path(directory).is_dir():
return {'available': False, 'reason': '定时运营汇总目录未配置或不可用'}
totals = {k: 0 for k in ('input_tokens', 'cached_input_tokens', 'output_tokens', 'reasoning_output_tokens')}
api_totals = dict.fromkeys(totals, 0)
runs = failed = 0
categories = {}
api_runs = api_uncertain = api_usage_events = 0
for path in Path(directory).glob('*.summary.json'):
try:
record = json.loads(path.read_text())
stamp = record.get('started_at')
when = dt.datetime.fromisoformat(stamp.replace('Z', '+00:00')) if stamp else dt.datetime.strptime(record['id'][:16], '%Y%m%dT%H%M%SZ').replace(tzinfo=dt.timezone.utc)
if when.tzinfo is None or not start <= when < end:
continue
runs += 1
failed += record.get('status') != 'complete'
if record.get('auth') == 'api_key':
api_runs += record.get('api_fallback_used') is True
api_uncertain += record.get('api_fallback_used') is None
for usage in record.get('usage', []):
api_usage_events += 1
for k in api_totals:
api_totals[k] += max(0, int(usage.get(k, 0)))
continue
category = record.get('category') if record.get('category') in ('content', 'engineering', 'research', 'community', 'management') else 'unattributed'
lane = categories.setdefault(category, {'runs': 0, 'elapsed_seconds': 0, 'elapsed_missing': 0, **dict.fromkeys(totals, 0)})
lane['runs'] += 1
if isinstance(record.get('elapsed_seconds'), (int, float)) and record['elapsed_seconds'] >= 0: lane['elapsed_seconds'] += record['elapsed_seconds']
else: lane['elapsed_missing'] += 1
for usage in record.get('usage', []):
for k in totals:
amount = max(0, int(usage.get(k, 0)))
totals[k] += amount
lane[k] += amount
except (ValueError, OSError, KeyError, TypeError, AttributeError):
continue
return {'available': True, 'runs': runs, 'failed_runs': failed, 'api_runs_separate': api_runs, 'api_runs_uncertain': api_uncertain, 'api_observed_usage': api_totals if api_usage_events else None, 'by_category': categories, **totals,
'measurement_note': '来自独立定时会话的运行汇总,与交互会话分开统计;API 备用调用不计入订阅 token,实际成本单独核算。失败或中断可能没有完整用量。'}
def harness_run_usage(path, start, end):
if not Path(path).exists():
return {'available': False, 'reason': '后续 Harness 调用账本尚未创建'}
result = {'available': True, 'runs': 0, 'input_tokens': 0, 'output_tokens': 0, 'cash_settlement_usd': None}
for line in Path(path).read_text().splitlines():
try:
row = json.loads(line)
when = dt.datetime.fromisoformat(row['at_utc'].replace('Z', '+00:00'))
if when.tzinfo is None or not start <= when < end:
continue
result['runs'] += 1
for usage in row.get('usage') or []:
result['input_tokens'] += max(0, int(usage.get('inputTokens', 0)))
result['output_tokens'] += max(0, int(usage.get('outputTokens', 0)))
except (ValueError, KeyError, TypeError, AttributeError):
continue
result['measurement_note'] = '只统计自动账本创建后的调用;实际结算未知,不与历史快照重复计费。'
return result
def build_report(data_dir, snapshot=False, now=None):
now = now or dt.datetime.now(TZ)
now = now.astimezone(TZ)
cutoff = now.replace(hour=20, minute=0, second=0, microsecond=0) - dt.timedelta(days=(now.weekday() - 4) % 7)
if cutoff > now:
cutoff -= dt.timedelta(days=7)
begin_stamp = cutoff if snapshot else cutoff - dt.timedelta(days=7)
end_stamp = now if snapshot else cutoff
start, end = begin_stamp.date(), end_stamp.date()
config = read_json(data_dir / 'config.json', {})
collector = read_json(data_dir / 'collector.json', {})
ledger = read_json(data_dir / 'ledger.json', {'entries': []})
content = read_json(data_dir / 'content.json', {'items': []})
tasks = read_json(data_dir / 'tasks.json', {'items': []})
findings = read_json(data_dir / 'findings.json', {})
api = read_json(data_dir / 'deepseek-usage.json', {})
entries = []
for entry in ledger.get('entries', []):
if entry.get('project') not in config.get('projects', ['pawplay']):
continue
stamp = entry.get('date', '')
try:
value = dt.datetime.fromisoformat(stamp.replace('Z', '+00:00'))
if value.tzinfo is None:
value = value.replace(tzinfo=TZ)
if begin_stamp <= value < end_stamp:
entries.append(entry)
except ValueError:
pass
published = [i for i in content.get('items', []) if i.get('status') == 'published' and i.get('bvid')]
costs = [e for e in entries if e.get('direction') == 'expense' and e.get('currency') == 'USD' and e.get('basis') == 'cash' and e.get('amount') is not None]
incomes = [e for e in entries if e.get('direction') == 'income' and e.get('currency') == 'CNY' and e.get('basis') == 'settled' and e.get('amount') is not None]
allocated = subscription_allocation(config.get('budget', {}), begin_stamp, end_stamp)
usage = codex_usage(collector.get('codex_sessions_dir'), collector.get('thread_id'), begin_stamp, end_stamp)
def observed_this_period(stamp):
try:
value = dt.datetime.fromisoformat(stamp.replace('Z', '+00:00'))
if value.tzinfo is None:
value = value.replace(tzinfo=TZ)
return begin_stamp <= value < end_stamp
except (AttributeError, ValueError):
return False
highlights = []
for item in findings.get('highlights', []):
stamp = item.get('date') if isinstance(item, dict) else findings.get('checked_at')
if observed_this_period(stamp):
highlights.append(item.get('text', '') if isinstance(item, dict) else item)
risks = list(findings.get('risks', []))
if not observed_this_period(findings.get('checked_at')):
risks.append('基础验收记录未在本周期更新,历史成果未重复计作本周完成。')
current = next((i for i in tasks.get('items', []) if i.get('status') == 'in_progress'), None)
identifier = end.strftime('%G-W%V') + ('-snapshot-' + now.strftime('%Y%m%dT%H%M%S') if snapshot else '')
report = {
'id': identifier,
'title': '账号运营' + ('阶段快照' if snapshot else '周报') + ' ' + end.isoformat(),
'period_start': begin_stamp.isoformat(), 'period_end': end_stamp.isoformat(),
'published_at': now.isoformat(), 'partial_period': snapshot, 'kind': 'snapshot' if snapshot else 'weekly',
'summary': ('当前推进:' + current['title']) if current else '根据已归档的项目记录汇总;缺失数据保留待核算。',
'highlights': highlights,
'risks': risks,
'next_actions': [i['title'] for i in tasks.get('items', []) if i.get('status') in ('todo', 'in_progress')][:8],
'decisions': findings.get('decisions', []),
'metrics': {
'published_project_videos': len(published),
'project_video_views': 0 if not published else None,
'project_game_sessions': None,
'codex': usage,
'scheduled_codex': scheduled_codex_usage(collector.get('ops_cycle_summaries_dir'), begin_stamp, end_stamp),
'deepseek': api or {'status': '未采集'},
'harness_new_runs': harness_run_usage(data_dir / 'harness-runs.jsonl', begin_stamp, end_stamp),
'measurement_notes': ['只统计运营账本登记稿件,不计入账户其他历史内容。', '当前会话包含PawPlay、运营基础设施和虚拟人设研究,token仅能按该共享会话汇总,未精确归因到单个方向。定时任务作为独立会话分别统计。', '游戏离线运行且未接入统计,不能推导活跃、留存或转化率。', '已发布稿件存在时,播放数据需接入稿件快照采集,当前不填造数值。']
},
'costs': {
'cash_usd': round(sum(e['amount'] for e in costs), 2) if config.get('cash_tracking_complete') else None,
'confirmed_recorded_cash_usd': round(sum(e['amount'] for e in costs), 2),
'allocated_usd': allocated, 'estimated_usd': None,
'electricity_kwh': None, 'electricity_cny': None,
'notes': ['固定套餐按计费周期的日历天数分摊;项目占比待确认时不报项目分摊金额。', '不按token再次给套餐内使用计费。DeepSeek/API如另计费,需用用量及实际费率/账单入账。', '电费需功率积分和电价,NAS/VPS及硬件折旧需确认项目分摊口径;不填虚构零成本。']
},
'revenue': {'settled_cny': round(sum(e['amount'] for e in incomes), 2) if incomes else None, 'estimated_cny': None,
'notes': ['播放量不是结算收入,自有产品推广稿件是否纳入激励需单独验证。', '未结算或未归因到PawPlay的收益不算项目收入。']},
'sources': {'findings_checked_at': findings.get('checked_at'), 'ledger_entries_in_period': len(entries), 'content_ledger': 'content.json', 'usage_source': '本机会话计数(只保存汇总,不发布对话)'},
}
repo = collector.get('repository')
if repo:
r = subprocess.run(['git', '-C', repo, 'rev-parse', '--short', 'HEAD'], capture_output=True, text=True)
report['sources']['repository_commit'] = r.stdout.strip() if r.returncode == 0 else None
target = data_dir / 'reports' / (identifier + '.json')
# A finalized weekly report is immutable. Manual corrections require a new revision.
if target.exists():
return target, False
save_json(target, report)
return target, True
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--data-dir', required=True, type=Path)
parser.add_argument('--snapshot', action='store_true')
parser.add_argument('--notify', action='store_true')
args = parser.parse_args()
target, created = build_report(args.data_dir, args.snapshot)
print(json.dumps({'created': created, 'report': target.name}, ensure_ascii=False))
collector = read_json(args.data_dir / 'collector.json', {})
if created and collector.get('backup_dir'):
backup = Path(collector['backup_dir']) / target.stem
backup.mkdir(parents=True, exist_ok=True, mode=0o700)
for name in ('config.json', 'ledger.json', 'content.json', 'tasks.json', 'findings.json', 'deepseek-usage.json'):
if (args.data_dir / name).exists():
shutil.copy2(args.data_dir / name, backup / name)
shutil.copy2(target, backup / target.name)
if created and args.notify:
script = collector.get('bark_script')
if script:
subprocess.run(['python3', script, 'PawPlay 周报已归档,可登录运营网站查看预算、收益及下周工作。', '--title', 'PawPlay:每周运营报告', '--url', collector.get('portal_url', '')], check=True)
if __name__ == '__main__':
main()
+80
View File
@@ -0,0 +1,80 @@
import datetime as dt
import importlib.util
from pathlib import Path
import tempfile
import unittest
spec = importlib.util.spec_from_file_location('report', Path(__file__).with_name('report.py'))
report = importlib.util.module_from_spec(spec)
spec.loader.exec_module(report)
class AccountingTests(unittest.TestCase):
def test_monthly_allocation_across_month_boundary(self):
budget = {'subscription_usd': 200, 'billing_period': 'monthly', 'project_allocation_fraction': 0.5}
value = report.subscription_allocation(budget, dt.date(2026, 10, 29), dt.date(2026, 11, 5))
self.assertEqual(value, round((200 / 31 * 3 + 200 / 30 * 4) * 0.5, 2))
def test_unknown_period_or_share_is_not_zero(self):
self.assertIsNone(report.subscription_allocation({'subscription_usd': 200}, dt.date(2026, 11, 2), dt.date(2026, 11, 9)))
def test_friday_twenty_hour_cutoff(self):
with tempfile.TemporaryDirectory() as directory:
path, _ = report.build_report(Path(directory), now=dt.datetime(2026, 11, 6, 20, tzinfo=report.TZ))
value = report.read_json(path)
self.assertEqual(value['period_start'], '2026-10-30T20:00:00+08:00')
self.assertEqual(value['period_end'], '2026-11-06T20:00:00+08:00')
def test_cross_month_proration_includes_partial_days(self):
budget = {'subscription_usd': 200, 'billing_period': 'monthly', 'project_allocation_fraction': 1}
value = report.subscription_allocation(budget, dt.datetime(2026, 10, 30, 20, tzinfo=report.TZ), dt.datetime(2026, 11, 6, 20, tzinfo=report.TZ))
self.assertEqual(value, round(200 / 31 * 28 / 24 + 200 / 30 * 140 / 24, 2))
def test_duplicate_usage_events_are_not_counted_twice(self):
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / 'test.jsonl'
meta = {'type': 'session_meta', 'payload': {'id': 'pawplay'}}
event = {'timestamp': '2026-11-03T08:00:00Z', 'type': 'event_msg', 'payload': {'type': 'token_count', 'info': {'total_token_usage': {'input_tokens': 100, 'cached_input_tokens': 80, 'output_tokens': 10, 'reasoning_output_tokens': 4, 'total_tokens': 110}}}}
import json
path.write_text('\n'.join(json.dumps(e) for e in [meta, event, event]))
result = report.codex_usage(directory, 'pawplay', dt.datetime(2026, 11, 2, tzinfo=report.TZ), dt.datetime(2026, 11, 9, tzinfo=report.TZ))
self.assertEqual(result['total_tokens'], 110)
self.assertEqual(result['uncached_input_tokens'], 20)
self.assertEqual(result['output_tokens'], 10)
def test_final_reports_are_immutable(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
now = dt.datetime(2026, 11, 9, 9, tzinfo=report.TZ)
first, created = report.build_report(root, now=now)
self.assertTrue(created)
original = first.read_bytes()
second, created = report.build_report(root, now=now + dt.timedelta(hours=1))
self.assertFalse(created)
self.assertEqual(original, second.read_bytes())
def test_scheduled_usage_is_period_scoped_and_preserves_failed_runs(self):
import json
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
(root / 'one.summary.json').write_text(json.dumps({'id':'20261103T141341Z-a','status':'complete','usage':[{'input_tokens':100,'cached_input_tokens':80,'output_tokens':10}]}))
(root / 'two.summary.json').write_text(json.dumps({'id':'20261104T141341Z-b','status':'failed','usage':[]}))
(root / 'paid.summary.json').write_text(json.dumps({'id':'20261104T151341Z-d','status':'complete','auth':'api_key','api_fallback_used':True,'usage':[{'input_tokens':33,'output_tokens':5}]}))
(root / 'old.summary.json').write_text(json.dumps({'id':'20261003T141341Z-c','status':'complete','usage':[{'input_tokens':900}]}))
value=report.scheduled_codex_usage(root,dt.datetime(2026,11,3,tzinfo=report.TZ),dt.datetime(2026,11,5,tzinfo=report.TZ))
self.assertEqual((value['runs'],value['failed_runs'],value['input_tokens'],value['cached_input_tokens']), (3,1,100,80))
self.assertEqual(value['api_runs_separate'],1);self.assertEqual(value['api_observed_usage']['input_tokens'],33)
def test_harness_ledger_counts_only_selected_period(self):
import json
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / 'harness-runs.jsonl'
entries = [{'at_utc':'2026-11-03T14:00:00Z','usage':[{'inputTokens':10,'outputTokens':4}]}, {'at_utc':'2026-10-03T14:00:00Z','usage':[{'inputTokens':100}]}]
path.write_text('\n'.join(json.dumps(e) for e in entries))
value=report.harness_run_usage(path,dt.datetime(2026,11,3,tzinfo=report.TZ),dt.datetime(2026,11,5,tzinfo=report.TZ))
self.assertEqual((value['runs'],value['input_tokens'],value['output_tokens']),(1,10,4))
self.assertIsNone(value['cash_settlement_usd'])
if __name__ == '__main__':
unittest.main()
+11
View File
@@ -0,0 +1,11 @@
*
!Dockerfile
!nginx.conf
!index.html
!style.css
!game.js
!sw.js
!manifest.webmanifest
!icon.svg
!apple-touch-icon.png
!icon-512.png
+1
View File
@@ -0,0 +1 @@
.qa/
+5
View File
@@ -0,0 +1,5 @@
FROM nginx:1.27-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --chmod=0644 index.html style.css game.js sw.js manifest.webmanifest icon.svg apple-touch-icon.png icon-512.png /usr/share/nginx/html/
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s CMD wget -qO- http://127.0.0.1:8080/healthz || exit 1
+61
View File
@@ -0,0 +1,61 @@
# PawPlay 网页版
原生 Canvas 2D 实现的鱼池、红点、羽毛触摸游戏。免费、无广告、无账号,不依赖外部字体、图片、音视频文件或第三方 SDK。静态文件可直接部署。
## 本地运行
在仓库根目录执行:
```bash
python3 -m http.server 18731 --bind 127.0.0.1 --directory web
```
打开 `http://127.0.0.1:18731/`。正式部署需 HTTPS;通过平板访问局域网明文 HTTP 时,Service Worker 不可用。
## 玩法
- 轻触或拖动屏幕产生水波、粒子或风。支持多指。
- 长按任一上角爪印两秒打开家长面板;移动过远、释放或切换后台会取消长按。键盘用户可聚焦爪印后按住空格或 Enter 两秒。
- 家长面板切换场景、音效,继续游玩或进入全屏。设置只在当前设备本地保存;存储不可用时仍可玩。
- 首次用户交互解锁音效;切换后台暂停动画和音频。支持时请求屏幕常亮;浏览器拒绝时遵循系统休眠设置。
- 在 iPad Safari 使用“分享 → 添加到主屏幕”可获得更完整的显示区域。系统手势由设备控制,网页无法屏蔽。
## 离线与更新
家长面板显示“已准备好,可断网重新打开”后,应用壳和三个模式已缓存。没有缓存完成就离线,或系统主动清理网站数据时,需要重新联网加载。
`sw.js` 一次完整缓存当前版本的所有资源,安装失败时删除本次不完整缓存。新 Worker 不强制替换正在游玩的旧 Worker;关闭旧游戏窗口后,新版本才会激活。导航和静态资源只从同一版本的缓存读取,不做接口或跨站运行时缓存。
发布新资源时同时修改 `sw.js` 的 `VERSION`、资源查询版本,以及 `index.html` 中 JS/CSS 的查询版本。验证新缓存完成后再发布;不要只替换同一版本下的某一个资源。
## Docker
```bash
docker build -t pawplay-web ./web
docker run --rm -p 18731:8080 pawplay-web
```
容器监听 `8080`,健康检查为 `/healthz`。镜像只复制游戏资源,不含测试截图、源码目录、密钥或运营数据。Nginx 对 JS/CSS/SVG 启用 gzip;首页和 Worker 不使用长期 HTTP 缓存。
## 浏览器验证
使用已安装的 Playwright 运行(脚本自行启动并关闭临时 HTTP 服务):
```bash
node web/qa.cjs
```
如 Playwright 未在模块搜索路径内,用 `PLAYWRIGHT_MODULE` 指向已安装的模块目录;`CHROME_BIN`、`WEBKIT_BIN` 可指定兼容的浏览器。模块版本必须匹配浏览器缓存版本。结果和截图保存在被 Git 忽略的 `web/.qa/`。
脚本验证短按防误触、两秒长按、三模式切换、设置持久化、横竖屏无滚动、关闭 HTTP 源站后关闭页面并重新打开,以及 Chromium 双指输入。检查无外部请求和页面异常,并统计实际资源字节数、gzip 字节数。WebKit 的自动化离线开关可能在 Worker 接管请求之前报内部错误,因此以真实关闭源站验证缓存读取。
本轮验证:Chromium 与 WebKit 均通过上述菜单、横竖屏、设置持久化和源站关闭后重开测试;Chromium 另通过双指输入。完整游戏资源合计 62,166 字节(约 61 KiB),逐文件 gzip 合计约 40 KiB;其中 PNG 在正式 Nginx 中不额外 gzip,实际传输会有少量差异。HTTP 头和 TLS 握手开销不计入此数。
验证范围仅为桌面 Chromium/WebKit 引擎。没有 iPad 真机数据;不能据此声称已通过 iPad Safari、主屏幕模式、真实音频输出、引导式访问、屏幕休眠或真实猫咪试玩验收。
## 实现边界
场景复用 Android 版的配色和交互规律,以 JavaScript/Canvas 重写绘制与物理逻辑,并非运行 APK。画布像素比最多 2,总像素数最多约 400 万;粒子最多 160、水波最多 35,以限制高分辨率设备开销。不作所有设备稳定 60fps 的承诺。
网页没有应用层行为统计或账号标识。托管服务可能保留常规 HTTP 访问日志,实际部署时应按托管配置说明。
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

+324
View File
@@ -0,0 +1,324 @@
(() => {
'use strict';
const canvas = document.querySelector('#pond');
const ctx = canvas.getContext('2d', { alpha: false });
const panel = document.querySelector('#settings');
const hint = document.querySelector('#hint');
const TAU = Math.PI * 2;
const colors = ['#ff7a59', '#ffc857', '#fff1dc', '#66e0d4', '#ff9fb2', '#7fb7ff', '#ffa45b'];
const random = (min, max) => min + Math.random() * (max - min);
const clamp = (x, min, max) => Math.min(max, Math.max(min, x));
const readSetting = key => { try { return localStorage.getItem(key); } catch { return null; } };
const saveSetting = (key, value) => { try { localStorage.setItem(key, value); } catch { /* Private browsing/storage pressure may prevent persistence. */ } };
let mode = readSetting('pawplay-mode') || 'fish';
if (!['fish', 'laser', 'feather'].includes(mode)) mode = 'fish';
let soundEnabled = readSetting('pawplay-sound') !== 'off';
let audio, lastSound = -1, wakeLock;
let width, height, unit, time = 0, previous = 0, raf = 0;
let background, grain = [], fish = [], feathers = [], dot;
let particles = [], ripples = [], touches = [], trails = [];
let lastMoveFx = 0;
const pointers = new Map();
function circle(x, y, r, fill) {
ctx.fillStyle = fill;
ctx.beginPath(); ctx.arc(x, y, Math.max(.1, r), 0, TAU); ctx.fill();
}
function unlockSound() {
if (!soundEnabled) return;
try {
const Audio = window.AudioContext || window.webkitAudioContext;
if (!Audio) return;
if (!audio) audio = new Audio();
if (audio.state === 'suspended') audio.resume().catch(() => {});
} catch { /* Continue silently when audio is unavailable. */ }
}
function playSound(kind = mode) {
if (!soundEnabled || !audio || audio.state !== 'running') return;
const now = audio.currentTime;
if (now - lastSound < .1) return;
lastSound = now;
const oscillator = audio.createOscillator();
const gain = audio.createGain();
oscillator.type = kind === 'feather' ? 'sine' : 'triangle';
const frequency = kind === 'fish' ? 370 : kind === 'laser' ? 680 : 220;
oscillator.frequency.setValueAtTime(frequency, now);
oscillator.frequency.exponentialRampToValueAtTime(frequency * .55, now + .17);
gain.gain.setValueAtTime(.0001, now);
gain.gain.exponentialRampToValueAtTime(.025, now + .015);
gain.gain.exponentialRampToValueAtTime(.0001, now + .2);
oscillator.connect(gain); gain.connect(audio.destination);
oscillator.start(now); oscillator.stop(now + .22);
oscillator.onended = () => { oscillator.disconnect(); gain.disconnect(); };
}
async function keepAwake() {
if (!('wakeLock' in navigator) || document.hidden || wakeLock) return;
try {
wakeLock = await navigator.wakeLock.request('screen');
wakeLock.addEventListener('release', () => { wakeLock = null; });
} catch { /* Screen sleep settings remain controlled by the device. */ }
}
function initialize() {
const box = canvas.getBoundingClientRect();
width = Math.max(1, box.width); height = Math.max(1, box.height);
const dpr = Math.min(window.devicePixelRatio || 1, 2, Math.sqrt(4000000 / (width * height)));
canvas.width = Math.round(width * dpr); canvas.height = Math.round(height * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
unit = clamp(Math.min(width, height) / 700, .65, 1.35);
const light = mode === 'feather';
if (mode === 'laser') {
background = ctx.createRadialGradient(width * .54, height * .42, 0, width * .5, height * .5, Math.max(width, height) * .8);
background.addColorStop(0, '#2a2033'); background.addColorStop(.55, '#17111f'); background.addColorStop(1, '#08060d');
} else {
background = ctx.createLinearGradient(0, 0, width * .35, height);
background.addColorStop(0, light ? '#fffaee' : '#0c4653');
background.addColorStop(.58, light ? '#f7e8d2' : '#0a3742');
background.addColorStop(1, light ? '#edd5b8' : '#061e27');
}
grain = Array.from({ length: light ? 110 : 70 }, () => ({ x: random(0, width), y: random(0, height), r: random(.6, 2) * unit, phase: random(0, TAU) }));
const count = width > height ? 7 : 6;
fish = Array.from({ length: count }, (_, i) => ({
x: width * (.15 + .7 * (i % 3) / 2), y: height * (.2 + .6 * Math.floor(i / 3) / Math.ceil(count / 3)),
size: random(34, 46) * unit, color: colors[i], angle: random(0, TAU), vx: 0, vy: 0,
tx: random(width * .12, width * .88), ty: random(height * .12, height * .88),
retarget: random(1, 4), phase: random(0, TAU), boost: 0, hidden: 0,
}));
feathers = ['#147b76', '#d4543f', '#c08628'].map((color, i) => ({ x: width * (.24 + i * .26), y: height * (.38 + i * .09), size: (64 + i * 7) * unit, angle: (-32 + i * 22) / 180 * Math.PI, vx: 0, vy: 0, spin: 0, phase: random(0, TAU), color }));
dot = { x: width / 2, y: height / 2, vx: 0, vy: 0, tx: width * .3, ty: height * .3, pause: 0, retarget: 2 };
particles = []; ripples = []; touches = []; trails = [];
document.body.dataset.mode = mode;
canvas.setAttribute('aria-label', `猫咪触摸游戏:${{ fish: '鱼池', laser: '红点', feather: '羽毛' }[mode]}。轻触屏幕有反馈,长按角落爪印两秒打开家长设置。`);
}
function effect(x, y, color, count = 8) {
ripples.push({ x, y, age: 0, color });
if (ripples.length > 35) ripples.shift();
for (let i = 0; i < count; i++) {
const angle = random(0, TAU), speed = random(30, 180) * unit;
particles.push({ x, y, vx: Math.cos(angle) * speed, vy: Math.sin(angle) * speed, life: random(.5, 1.1), age: 0, size: random(2, 5) * unit, color, star: i % 3 === 0 });
}
if (particles.length > 160) particles.splice(0, particles.length - 160);
}
function interact(x, y, down) {
touches.push({ x, y, age: 0 });
if (touches.length > 12) touches.shift();
if (down || time - lastMoveFx > .075) {
effect(x, y, mode === 'feather' ? '#a67b53' : mode === 'laser' ? '#ffb4a6' : '#bff7f1', down ? 7 : 1);
lastMoveFx = time;
}
if (down) {
playSound();
if (mode === 'fish') {
const caught = fish.find(f => f.hidden <= 0 && Math.hypot(f.x - x, f.y - y) < f.size * 1.9);
if (caught) { caught.hidden = random(1.1, 1.8); effect(caught.x, caught.y, caught.color, 24); }
} else if (mode === 'laser' && Math.hypot(dot.x - x, dot.y - y) < 75 * unit) {
effect(dot.x, dot.y, '#ffd7ce', 20);
dot.x = random(width * .15, width * .85); dot.y = random(height * .15, height * .85);
dot.vx = dot.vy = 0; dot.pause = .06;
}
}
}
function update(dt) {
for (const p of particles) { p.age += dt; p.x += p.vx * dt; p.y += p.vy * dt; p.vx *= 1 - dt * .8; p.vy += (mode === 'feather' ? 32 : -24) * dt * unit; }
particles = particles.filter(p => p.age < p.life);
ripples.forEach(p => { p.age += dt; }); ripples = ripples.filter(p => p.age < .9);
touches.forEach(p => { p.age += dt; }); touches = touches.filter(p => p.age < .65);
if (mode === 'fish') {
for (const f of fish) {
if (f.hidden > 0) { f.hidden -= dt; if (f.hidden <= 0) { f.x = -f.size; f.y = random(height * .15, height * .85); f.vx = 100 * unit; f.tx = width / 2; } continue; }
f.retarget -= dt;
if (f.retarget <= 0 || Math.hypot(f.x - f.tx, f.y - f.ty) < 40 * unit) {
f.tx = random(width * .12, width * .88); f.ty = random(height * .12, height * .88); f.retarget = random(2.2, 5.5);
}
if (Math.random() < dt * .13) f.boost = 1;
f.boost = Math.max(0, f.boost - dt * .8);
const a = Math.atan2(f.ty - f.y, f.tx - f.x), speed = (55 + 22 * Math.sin(f.phase)) * unit * (1 + f.boost * 1.7);
f.vx += (Math.cos(a) * speed - f.vx) * dt * 1.3;
f.vy += (Math.sin(a) * speed - f.vy) * dt * 1.3;
for (const t of touches) {
const dx = f.x - t.x, dy = f.y - t.y, d = Math.hypot(dx, dy);
if (d > 1 && d < 190 * unit) { const force = 1100 * unit * (1 - d / (190 * unit)); f.vx += dx / d * force * dt; f.vy += dy / d * force * dt; }
}
for (const other of fish) {
if (other === f || other.hidden > 0) continue;
const dx = f.x - other.x, dy = f.y - other.y, d = Math.hypot(dx, dy), margin = (f.size + other.size) * 1.5;
if (d > 1 && d < margin) { f.vx += dx / d * (margin - d) * dt * 1.8; f.vy += dy / d * (margin - d) * dt * 1.8; }
}
const speedNow = Math.hypot(f.vx, f.vy), maxSpeed = 320 * unit;
if (speedNow > maxSpeed) { f.vx *= maxSpeed / speedNow; f.vy *= maxSpeed / speedNow; }
f.x += f.vx * dt; f.y += f.vy * dt;
if (f.x < -f.size || f.x > width + f.size || f.y < -f.size || f.y > height + f.size) { f.tx = width / 2; f.ty = height / 2; f.retarget = 2; }
const wanted = Math.atan2(f.vy, f.vx), difference = Math.atan2(Math.sin(wanted - f.angle), Math.cos(wanted - f.angle));
f.angle += difference * Math.min(1, dt * 5); f.phase += dt * 4;
}
} else if (mode === 'feather') {
feathers.forEach((f, i) => {
f.vx += (Math.sin(time * .47 + f.phase) * 9 * unit - f.vx) * dt * .9;
f.vy += ((Math.cos(time * .38 + f.phase * 1.3) * 6 - 2) * unit - f.vy) * dt * .9;
for (const t of touches) {
const dx = f.x - t.x, dy = f.y - t.y, d = Math.hypot(dx, dy);
if (d > 1 && d < 320 * unit) { const force = (1 - d / (320 * unit)) * 950 * unit; f.vx += dx / d * force * dt; f.vy += (dy / d * force - 90 * unit) * dt; f.spin += Math.sin(i * 3 + t.x) * dt * 2; }
}
f.x += f.vx * dt; f.y += f.vy * dt; f.angle += (f.spin + Math.sin(time * .4 + f.phase) * .04) * dt; f.spin *= 1 - dt * .55;
const margin = Math.min(f.size, Math.min(width, height) * .2);
if (f.x < margin || f.x > width - margin) { f.x = clamp(f.x, margin, width - margin); f.vx *= -.7; }
if (f.y < margin || f.y > height - margin) { f.y = clamp(f.y, margin, height - margin); f.vy *= -.7; }
});
} else {
trails.forEach(p => { p.age += dt; }); trails = trails.filter(p => p.age < .34);
trails.push({ x: dot.x, y: dot.y, age: 0 }); if (trails.length > 26) trails.shift();
dot.pause -= dt;
if (dot.pause <= 0) {
let vx = (dot.tx - dot.x) * 3.6 + Math.sin(time * 3.4) * 40 * unit;
let vy = (dot.ty - dot.y) * 3.6 + Math.cos(time * 3.4) * 40 * unit;
const speed = Math.hypot(vx, vy), maxSpeed = 720 * unit;
if (speed > maxSpeed) { vx *= maxSpeed / speed; vy *= maxSpeed / speed; }
dot.vx += (vx - dot.vx) * dt * 6.5; dot.vy += (vy - dot.vy) * dt * 6.5;
dot.x += dot.vx * dt; dot.y += dot.vy * dt;
dot.retarget -= dt;
if (Math.hypot(dot.tx - dot.x, dot.ty - dot.y) < 22 * unit || dot.retarget <= 0) {
dot.tx = random(width * .12, width * .88); dot.ty = random(height * .12, height * .88); dot.pause = random(.2, .75); dot.retarget = random(1.2, 3.4);
}
}
}
}
function drawFish(f) {
if (f.hidden > 0) return;
ctx.save(); ctx.translate(f.x, f.y); ctx.rotate(f.angle);
const s = f.size, tail = Math.sin(f.phase) * s * .17;
ctx.shadowColor = '#0005'; ctx.shadowBlur = 10 * unit; ctx.shadowOffsetY = 8 * unit;
ctx.fillStyle = f.color;
ctx.beginPath(); ctx.moveTo(-s * .7, 0); ctx.quadraticCurveTo(-s * 1.8, -s * .55 + tail, -s * 1.65, tail); ctx.quadraticCurveTo(-s * 1.8, s * .55 + tail, -s * .7, 0); ctx.fill();
ctx.beginPath(); ctx.ellipse(0, 0, s, s * .43, 0, 0, TAU); ctx.fill();
ctx.shadowColor = 'transparent';
ctx.save(); ctx.beginPath(); ctx.ellipse(0, 0, s, s * .43, 0, 0, TAU); ctx.clip();
ctx.fillStyle = '#fff4'; ctx.beginPath(); ctx.ellipse(-s * .2, -s * .15, s * .65, s * .17, -.1, 0, TAU); ctx.fill();
for (let i = 0; i < 3; i++) { ctx.fillStyle = i % 2 ? '#fff8' : '#8f433b65'; ctx.beginPath(); ctx.ellipse((i - 1) * s * .45, (i % 2 ? 1 : -1) * s * .1, s * .25, s * .24, .3, 0, TAU); ctx.fill(); }
ctx.restore();
ctx.fillStyle = f.color + 'bb';
for (const side of [-1, 1]) { ctx.beginPath(); ctx.moveTo(s * .1, side * s * .28); ctx.quadraticCurveTo(-s * .15, side * s * .95, -s * .45, side * s * .5); ctx.closePath(); ctx.fill(); }
circle(s * .69, -s * .2, s * .085, '#172d32'); circle(s * .71, -s * .22, s * .026, '#fff');
circle(s * .69, s * .2, s * .075, '#172d32'); circle(s * .71, s * .18, s * .024, '#fff');
ctx.restore();
}
function drawFeather(f) {
ctx.save(); ctx.translate(f.x, f.y); ctx.rotate(f.angle);
const h = f.size * 1.38, w = f.size * .4;
ctx.shadowColor = '#6b4a3220'; ctx.shadowBlur = 9; ctx.shadowOffsetX = 8 * unit; ctx.shadowOffsetY = 10 * unit;
ctx.beginPath(); ctx.moveTo(0, -h); ctx.bezierCurveTo(-w * 1.5, -h * .5, -w, h * .5, 0, h * .8); ctx.bezierCurveTo(w * .9, h * .4, w * 1.3, -h * .55, 0, -h);
ctx.fillStyle = f.color; ctx.fill(); ctx.shadowColor = 'transparent';
ctx.save(); ctx.clip(); ctx.lineWidth = .8 * unit;
for (let i = 0; i < 25; i++) { const y = -h + i * h * 1.8 / 25; ctx.strokeStyle = i % 2 ? '#fff7' : '#fff3'; ctx.beginPath(); ctx.moveTo(-w * 1.4, y + h * .12); ctx.lineTo(0, y); ctx.lineTo(w * 1.4, y + h * .16); ctx.stroke(); }
ctx.restore(); ctx.strokeStyle = '#fff5dc'; ctx.lineWidth = 2.5 * unit; ctx.lineCap = 'round'; ctx.beginPath(); ctx.moveTo(0, -h * .95); ctx.quadraticCurveTo(-w * .07, h * .1, 0, h * 1.1); ctx.stroke(); ctx.restore();
}
function draw() {
ctx.fillStyle = background; ctx.fillRect(0, 0, width, height);
for (const g of grain) circle(g.x + Math.sin(time * .2 + g.phase) * 6 * unit, (g.y - time * 5 * unit + height * 1000) % height, g.r, mode === 'feather' ? '#b29a7424' : '#d4f7df20');
if (mode === 'fish') {
ctx.lineWidth = 13 * unit; ctx.strokeStyle = '#a5f2de09';
for (let i = 0; i < 5; i++) { ctx.beginPath(); for (let x = -40; x <= width + 40; x += 30) { const y = height * i / 4 + Math.sin(x / (160 * unit) + time * .27 + i) * 38 * unit; if (x === -40) ctx.moveTo(x, y); else ctx.lineTo(x, y); } ctx.stroke(); }
for (const f of fish) drawFish(f);
} else if (mode === 'feather') {
const sun = ctx.createRadialGradient(width * .8, 0, 0, width * .8, 0, Math.max(width, height) * .7);
sun.addColorStop(0, '#fff7d866'); sun.addColorStop(1, '#fff7d800'); ctx.fillStyle = sun; ctx.fillRect(0, 0, width, height);
for (const f of feathers) drawFeather(f);
} else {
ctx.lineWidth = 1; ctx.strokeStyle = '#ffe4c80b';
for (let x = -height; x < width + height; x += 58 * unit) { ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x - height, height); ctx.stroke(); }
for (const p of trails) { ctx.globalAlpha = (1 - p.age / .34) * .1; circle(p.x, p.y, 25 * unit, '#ff5545'); } ctx.globalAlpha = 1;
const radius = 110 * unit;
const glow = ctx.createRadialGradient(dot.x, dot.y, 0, dot.x, dot.y, radius);
glow.addColorStop(0, '#ff564988'); glow.addColorStop(.3, '#ff3a2e44'); glow.addColorStop(1, '#ff2a2000');
circle(dot.x, dot.y, radius, glow); circle(dot.x, dot.y, 17 * unit, '#ff352a'); circle(dot.x, dot.y, 10 * unit, '#ffd7ce'); circle(dot.x - 2.5 * unit, dot.y - 2.5 * unit, 4.6 * unit, '#fff');
}
for (const p of ripples) { ctx.globalAlpha = (1 - p.age / .9) * .65; ctx.strokeStyle = p.color; ctx.lineWidth = 1.7 * unit; ctx.beginPath(); ctx.arc(p.x, p.y, (10 + p.age * 100) * unit, 0, TAU); ctx.stroke(); }
for (const p of particles) {
ctx.globalAlpha = 1 - p.age / p.life;
if (p.star) { ctx.save(); ctx.translate(p.x, p.y); ctx.rotate(p.age * 2); ctx.fillStyle = p.color; ctx.beginPath(); for (let i = 0; i < 8; i++) { const a = i * Math.PI / 4, r = p.size * (i % 2 ? .4 : 1.4); ctx.lineTo(Math.cos(a) * r, Math.sin(a) * r); } ctx.closePath(); ctx.fill(); ctx.restore(); }
else circle(p.x, p.y, p.size, p.color);
}
ctx.globalAlpha = 1;
}
function frame(timestamp) {
if (previous && timestamp - previous < 15.8) { raf = requestAnimationFrame(frame); return; }
const dt = Math.min((timestamp - previous) / 1000 || 1 / 60, .035);
previous = timestamp; time += dt;
if (!panel.open) update(dt);
draw(); raf = requestAnimationFrame(frame);
}
canvas.addEventListener('pointerdown', event => {
event.preventDefault(); unlockSound(); keepAwake();
pointers.set(event.pointerId, true); canvas.setPointerCapture(event.pointerId);
interact(event.clientX, event.clientY, true);
});
canvas.addEventListener('pointermove', event => {
if (!pointers.has(event.pointerId)) return;
event.preventDefault(); interact(event.clientX, event.clientY, false);
});
for (const name of ['pointerup', 'pointercancel', 'lostpointercapture']) canvas.addEventListener(name, event => { pointers.delete(event.pointerId); });
canvas.addEventListener('contextmenu', event => event.preventDefault());
let hold;
function cancelHold() {
if (!hold) return;
clearTimeout(hold.timer); hold.button.removeAttribute('data-holding'); hold = null;
}
function startHold(button, event) {
cancelHold();
const x = event.clientX, y = event.clientY;
hold = { button, x, y, timer: setTimeout(() => { cancelHold(); pointers.clear(); panel.showModal(); document.querySelector('#resume').focus(); }, 2000) };
button.setAttribute('data-holding', 'true');
}
document.querySelectorAll('.paw').forEach(button => {
button.addEventListener('pointerdown', event => { event.preventDefault(); unlockSound(); keepAwake(); if (event.isPrimary === false) { cancelHold(); return; } button.setPointerCapture(event.pointerId); startHold(button, event); });
button.addEventListener('pointermove', event => { if (hold && Math.hypot(event.clientX - hold.x, event.clientY - hold.y) > 16) cancelHold(); });
for (const name of ['pointerup', 'pointercancel', 'lostpointercapture', 'blur']) button.addEventListener(name, cancelHold);
button.addEventListener('keydown', event => { if ([' ', 'Enter'].includes(event.key) && !event.repeat) { event.preventDefault(); startHold(button, {}); } });
button.addEventListener('keyup', cancelHold);
button.addEventListener('contextmenu', event => event.preventDefault());
});
window.addEventListener('blur', () => { cancelHold(); pointers.clear(); });
function syncControls() {
document.querySelectorAll('[data-scene]').forEach(button => button.setAttribute('aria-pressed', String(button.dataset.scene === mode)));
document.querySelector('#sound').setAttribute('aria-checked', String(soundEnabled));
}
document.querySelectorAll('[data-scene]').forEach(button => button.addEventListener('click', () => { mode = button.dataset.scene; saveSetting('pawplay-mode', mode); initialize(); syncControls(); unlockSound(); playSound(); }));
document.querySelector('#sound').addEventListener('click', () => { soundEnabled = !soundEnabled; saveSetting('pawplay-sound', soundEnabled ? 'on' : 'off'); syncControls(); if (soundEnabled) { unlockSound(); playSound(); } else if (audio) audio.suspend().catch(() => {}); });
document.querySelector('#resume').addEventListener('click', () => { panel.close(); previous = performance.now(); unlockSound(); keepAwake(); });
const fullscreen = document.querySelector('#fullscreen');
if (!document.documentElement.requestFullscreen) { fullscreen.textContent = '主屏幕模式更沉浸'; fullscreen.disabled = true; }
else fullscreen.addEventListener('click', async () => { try { if (document.fullscreenElement) await document.exitFullscreen(); else await document.documentElement.requestFullscreen(); } catch { fullscreen.textContent = '请添加到主屏幕'; } });
document.addEventListener('fullscreenchange', () => { fullscreen.textContent = document.fullscreenElement ? '退出全屏' : '进入全屏'; });
document.addEventListener('visibilitychange', () => {
cancelHold(); pointers.clear();
if (document.hidden) { cancelAnimationFrame(raf); raf = 0; if (audio) audio.suspend().catch(() => {}); }
else { previous = performance.now(); if (!raf) raf = requestAnimationFrame(frame); if (audio && soundEnabled) audio.resume().catch(() => {}); keepAwake(); }
});
window.addEventListener('resize', initialize);
window.addEventListener('pagehide', () => { cancelHold(); pointers.clear(); });
syncControls(); initialize(); raf = requestAnimationFrame(frame);
setTimeout(() => hint.classList.add('hidden'), 6500);
async function prepareOffline() {
const status = document.querySelector('#offline');
if (!('serviceWorker' in navigator) || !window.isSecureContext) { status.textContent = '此浏览器需联网游玩'; return; }
try {
await navigator.serviceWorker.register('./sw.js');
await Promise.race([navigator.serviceWorker.ready, new Promise((_, reject) => setTimeout(() => reject(new Error('offline setup timeout')), 30000))]);
status.textContent = '已准备好,可断网重新打开';
document.querySelector('#offline-dot').classList.add('ready');
} catch { status.textContent = '尚未缓存完成,请联网重试'; }
}
prepareOffline();
})();
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><rect width="512" height="512" rx="112" fill="#0b3943"/><circle cx="256" cy="260" r="177" fill="#124e56"/><g fill="#f4d7a2"><ellipse cx="163" cy="206" rx="33" ry="44" transform="rotate(-26 163 206)"/><ellipse cx="241" cy="159" rx="34" ry="45" transform="rotate(-6 241 159)"/><ellipse cx="322" cy="181" rx="33" ry="44" transform="rotate(21 322 181)"/><ellipse cx="368" cy="247" rx="29" ry="39" transform="rotate(38 368 247)"/><path d="M158 319c6-44 54-98 94-94 40 3 81 52 88 94 11 58-48 43-87 40-40-3-103 20-95-40"/></g><circle cx="353" cy="114" r="14" fill="#ed8e6f"/></svg>

After

Width:  |  Height:  |  Size: 637 B

+39
View File
@@ -0,0 +1,39 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#0b3943">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="description" content="鱼池、红点和羽毛,三个会回应爪子的触摸小游戏。免费,无广告。">
<link rel="manifest" href="./manifest.webmanifest">
<link rel="icon" href="./icon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="./apple-touch-icon.png">
<link rel="stylesheet" href="./style.css?v=1.0.0">
<title>猫爪游乐场 · PawPlay</title>
<script defer src="./game.js?v=1.0.0"></script>
</head>
<body data-mode="fish">
<canvas id="pond" aria-label="猫咪触摸游戏:鱼池。轻触屏幕有反馈,长按角落爪印两秒打开家长设置。"></canvas>
<button class="paw left" aria-label="长按两秒打开家长设置" title="长按两秒打开家长设置"><svg viewBox="0 0 64 64" aria-hidden="true"><ellipse cx="17" cy="21" rx="7" ry="10" transform="rotate(-24 17 21)"/><ellipse cx="32" cy="14" rx="7" ry="10"/><ellipse cx="47" cy="21" rx="7" ry="10" transform="rotate(24 47 21)"/><path d="M15 43c0-11 10-19 17-19s17 8 17 19c0 12-11 7-17 7s-17 5-17-7"/></svg></button>
<button class="paw right" aria-label="长按两秒打开家长设置" title="长按两秒打开家长设置"><svg viewBox="0 0 64 64" aria-hidden="true"><ellipse cx="17" cy="21" rx="7" ry="10" transform="rotate(-24 17 21)"/><ellipse cx="32" cy="14" rx="7" ry="10"/><ellipse cx="47" cy="21" rx="7" ry="10" transform="rotate(24 47 21)"/><path d="M15 43c0-11 10-19 17-19s17 8 17 19c0 12-11 7-17 7s-17 5-17-7"/></svg></button>
<aside id="hint" role="status"><span>猫爪游乐场</span>轻触屏幕一起玩 · 长按角落爪印 2 秒进入设置</aside>
<dialog id="settings" aria-labelledby="settings-title">
<div class="eyebrow">PAWPLAY / 家长设置</div>
<h1 id="settings-title">今天,玩哪一个?</h1>
<p class="intro">陪它轻轻玩一会儿。</p>
<div class="modes" role="group" aria-label="选择游戏">
<button data-scene="fish" aria-pressed="true"><span class="mode-icon fish-icon">≈</span><strong>鱼池</strong><small>游一游,扑一下</small></button>
<button data-scene="laser" aria-pressed="false"><span class="mode-icon laser-icon">●</span><strong>红点</strong><small>停一停,再追上</small></button>
<button data-scene="feather" aria-pressed="false"><span class="mode-icon feather-icon">⌁</span><strong>羽毛</strong><small>爪子带起一阵风</small></button>
</div>
<div class="setting-row"><div><strong>轻柔音效</strong><small>首次触摸后开启声音</small></div><button id="sound" role="switch" aria-checked="true" aria-label="轻柔音效"><span></span></button></div>
<div class="setting-row"><div><strong>离线游玩</strong><small id="offline" role="status">正在准备离线内容…</small></div><span class="status-dot" id="offline-dot"></span></div>
<div class="actions"><button id="fullscreen">进入全屏</button><button id="resume" class="primary">继续玩</button></div>
<details><summary>玩法与添加到主屏幕</summary><p>屏幕放平,贴好保护膜,由人陪着玩。猫咪不感兴趣时,换个模式或换成实体玩具。屏幕互动不能替代日常陪伴。</p><p>iPad:用 Safari 打开,点“分享”→“添加到主屏幕”。完成离线准备后,可断网重新打开。系统清理缓存后,需要联网加载一次。</p><p>网页无法屏蔽系统手势。需要防止误退出时,可由家长开启 iPad 的“引导式访问”或 Android 的“屏幕固定”。</p><p>免费 · 无广告 · 无账号 · 设置仅保存在这台设备。网页托管服务可能保留常规访问日志。</p></details>
<footer>猫爪游乐场 · PawPlay <span>网页版 1.0.0</span></footer>
</dialog>
<noscript><div class="noscript">请开启 JavaScript 后游玩猫爪游乐场。</div></noscript>
</body>
</html>
+1
View File
@@ -0,0 +1 @@
{"id":"./","name":"猫爪游乐场 · PawPlay","short_name":"猫爪游乐场","description":"鱼池、红点、羽毛,三个会回应爪子的触摸小游戏。","lang":"zh-CN","start_url":"./","scope":"./","display":"standalone","background_color":"#0b3943","theme_color":"#0b3943","icons":[{"src":"./icon.svg","sizes":"any","type":"image/svg+xml","purpose":"any"},{"src":"./apple-touch-icon.png","sizes":"192x192","type":"image/png","purpose":"any"},{"src":"./icon-512.png","sizes":"512x512","type":"image/png","purpose":"any maskable"}]}
+17
View File
@@ -0,0 +1,17 @@
server {
listen 8080;
server_name _;
root /usr/share/nginx/html;
index index.html;
server_tokens off;
gzip on;
gzip_types text/css application/javascript application/manifest+json image/svg+xml;
add_header Cache-Control 'no-cache' always;
add_header X-Content-Type-Options nosniff always;
add_header Referrer-Policy strict-origin-when-cross-origin always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self'; connect-src 'self'; worker-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'" always;
location = /healthz { access_log off; default_type text/plain; return 200 'ok'; }
location = /sw.js { try_files $uri =404; }
location = /index.html { try_files $uri =404; }
location / { try_files $uri $uri/ =404; }
}
+102
View File
@@ -0,0 +1,102 @@
// Self-contained browser QA. The temporary HTTP source is shut down for the
// offline test: WebKit's emulated offline mode can fail before SW dispatch.
const { chromium, webkit } = require(process.env.PLAYWRIGHT_MODULE || 'playwright');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const zlib = require('node:zlib');
const http = require('node:http');
const output = path.join(__dirname, '.qa');
fs.mkdirSync(output, {recursive:true});
async function openSettings(page, side = 'left') {
const button = page.locator('.paw.' + side);
const box = await button.boundingBox();
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down();
await page.waitForTimeout(2150);
await page.mouse.up();
await page.waitForFunction(() => document.querySelector('#settings').open);
}
async function verify(browserType, name, launchOptions) {
const assets = new Set(['index.html','game.js','style.css','manifest.webmanifest','sw.js','icon.svg','apple-touch-icon.png','icon-512.png']);
const types = {'.html':'text/html','.js':'application/javascript','.css':'text/css','.svg':'image/svg+xml','.png':'image/png','.webmanifest':'application/manifest+json'};
const source = http.createServer((request, response) => {
const file = new URL(request.url, 'http://local').pathname.slice(1) || 'index.html';
if (!assets.has(file)) { response.writeHead(404); return response.end(); }
response.setHeader('Content-Type', types[path.extname(file)] || 'application/octet-stream');
response.end(fs.readFileSync(path.join(__dirname,file)));
});
await new Promise(resolve => source.listen(0, '127.0.0.1', resolve));
const base = `http://127.0.0.1:${source.address().port}/`;
const stopSource = async () => { source.closeAllConnections(); await new Promise(resolve => source.close(resolve)); };
const browser = await browserType.launch({headless:true, ...launchOptions});
const context = await browser.newContext({viewport:{width:1024,height:768},deviceScaleFactor:2,hasTouch:true});
const page = await context.newPage();
const errors = [], external = [];
context.on('page', p => p.on('pageerror', e => errors.push(e.message)));
page.on('pageerror', e => errors.push(e.message));
context.on('request', r => {if(!r.url().startsWith(new URL(base).origin)) external.push(r.url());});
try {
await page.goto(base);
await page.waitForFunction(() => document.querySelector('#offline').textContent.includes('已准备好'), {timeout:30000});
await page.waitForFunction(() => !!navigator.serviceWorker.controller);
await page.locator('.paw.left').click();
assert.equal(await page.locator('#settings').evaluate(e => e.open), false, 'single tap must not open settings');
await openSettings(page);
for (const scene of ['laser', 'feather', 'fish']) {
await page.locator(`[data-scene=${scene}]`).click();
assert.equal(await page.locator('body').getAttribute('data-mode'), scene);
assert.equal(await page.locator(`[data-scene=${scene}]`).getAttribute('aria-pressed'), 'true');
}
await page.locator('#sound').click();
assert.equal(await page.locator('#sound').getAttribute('aria-checked'), 'false');
await page.locator('#resume').click();
await page.setViewportSize({width:390,height:844});
await page.touchscreen.tap(195, 300);
assert.equal(await page.evaluate(() => document.documentElement.scrollHeight > innerHeight || document.documentElement.scrollWidth > innerWidth), false, 'portrait must not scroll');
await page.screenshot({path:path.join(output, name+'-portrait.png')});
await openSettings(page, 'right');
await page.locator('[data-scene=feather]').click();
await page.locator('#resume').click();
await page.reload();
assert.equal(await page.locator('body').getAttribute('data-mode'), 'feather', 'mode preference persists');
assert.equal(await page.locator('#sound').getAttribute('aria-checked'), 'false', 'sound preference persists');
await stopSource();
await page.close();
const offlinePage = await context.newPage();
await offlinePage.goto(base, {waitUntil:'load'});
assert.equal(await offlinePage.title(), '猫爪游乐场 · PawPlay');
assert.equal(await offlinePage.locator('body').getAttribute('data-mode'), 'feather');
await openSettings(offlinePage);
await offlinePage.locator('[data-scene=laser]').click();
await offlinePage.locator('#resume').click();
await offlinePage.setViewportSize({width:844,height:390});
assert.equal(await offlinePage.evaluate(() => document.documentElement.scrollHeight > innerHeight || document.documentElement.scrollWidth > innerWidth), false, 'landscape must not scroll');
await offlinePage.screenshot({path:path.join(output, name+'-offline-landscape.png')});
if (name === 'chromium') {
const cdp = await context.newCDPSession(offlinePage);
await offlinePage.evaluate(() => {window.touchPointers=[];document.querySelector('#pond').addEventListener('pointerdown',e=>window.touchPointers.push(e.pointerId));});
await cdp.send('Input.dispatchTouchEvent', {type:'touchStart',touchPoints:[{x:250,y:150,id:1},{x:500,y:200,id:2}]});
await cdp.send('Input.dispatchTouchEvent', {type:'touchMove',touchPoints:[{x:270,y:170,id:1},{x:520,y:210,id:2}]});
await cdp.send('Input.dispatchTouchEvent', {type:'touchEnd',touchPoints:[]});
assert.equal(await offlinePage.evaluate(() => new Set(window.touchPointers).size), 2, 'two simultaneous touches reach the canvas');
}
assert.deepEqual(errors, [], 'no page errors');
assert.deepEqual(external, [], 'no external requests');
return {browser:name,offline_reopen:true,menu_hold:true,short_tap_guard:true,three_modes:true,portrait_landscape_no_scroll:true,preferences:true,multi_touch:name==='chromium'?'two simultaneous pointers passed':'single touch passed; simultaneous touch not exercised',errors};
} finally { await browser.close(); if(source.listening) await stopSource(); }
}
(async () => {
const files = ['index.html','style.css','game.js','sw.js','manifest.webmanifest','icon.svg','apple-touch-icon.png','icon-512.png'];
const sizes = files.map(name => {const b=fs.readFileSync(path.join(__dirname,name));return {name,bytes:b.length,gzip_bytes:zlib.gzipSync(b).length};});
const total = sizes.reduce((n,f)=>n+f.bytes,0), gzip=sizes.reduce((n,f)=>n+f.gzip_bytes,0);
assert(total < 500*1024); assert(gzip < 200*1024);
const results = [await verify(chromium,'chromium',{executablePath:process.env.CHROME_BIN || '/usr/bin/google-chrome'})];
results.push(await verify(webkit,'webkit', process.env.WEBKIT_BIN ? {executablePath:process.env.WEBKIT_BIN} : {}));
const result = {checked_at:new Date().toISOString(),sizes,total_bytes:total,gzip_bytes:gzip,results,limits:'Desktop browser engines only. No iPad hardware, standalone-mode, Guided Access, screen-lock, or real-cat validation.'};
fs.writeFileSync(path.join(output,'result.json'),JSON.stringify(result,null,2)+'\n');
console.log(JSON.stringify(result,null,2));
})().catch(e=>{console.error(e);process.exitCode=1;});
+1
View File
@@ -0,0 +1 @@
:root{color-scheme:light;font-family:ui-rounded,"PingFang SC","Microsoft YaHei",system-ui,sans-serif;color:#352b29;background:#0b3943;-webkit-tap-highlight-color:transparent}*{box-sizing:border-box}html,body{margin:0;width:100%;height:100%;overflow:hidden;overscroll-behavior:none}body{position:fixed;inset:0;touch-action:manipulation;user-select:none;-webkit-user-select:none}canvas{display:block;width:100%;height:100%;touch-action:none}.paw{position:fixed;top:max(12px,env(safe-area-inset-top));width:60px;height:60px;padding:17px;border:1px solid #ffffff15;border-radius:50%;background:#ffffff08;color:#d8f3e66b;touch-action:none;cursor:pointer;overflow:hidden}.paw.left{left:max(12px,env(safe-area-inset-left))}.paw.right{right:max(12px,env(safe-area-inset-right))}.paw svg{display:block;width:100%;height:100%;fill:currentColor}.paw[data-holding]{color:#fff;border-color:#ffffffaa;background:#ffffff25}.paw[data-holding]:after{content:"";position:absolute;bottom:0;left:0;width:100%;height:100%;background:#ffffff30;transform-origin:bottom;animation:hold 2s linear}@keyframes hold{from{transform:scaleY(0)}to{transform:scaleY(1)}}body[data-mode=feather] .paw{color:#805d3b88;border-color:#805d3b22;background:#ffffff40}button{font:inherit;cursor:pointer}button:focus-visible,summary:focus-visible{outline:3px solid #e7a773;outline-offset:4px}#hint{position:fixed;bottom:max(28px,env(safe-area-inset-bottom));left:50%;transform:translateX(-50%);max-width:calc(100% - 40px);width:max-content;border:1px solid #ffffff20;background:#09242bce;color:#f4f5e9cc;border-radius:20px;padding:13px 24px;text-align:center;font-size:12px;line-height:1.7;pointer-events:none;transition:opacity .8s}#hint span{display:block;font-size:15px;font-weight:600;letter-spacing:2px;color:#fff}#hint.hidden{opacity:0}dialog{position:fixed;margin:auto;border:0;border-radius:28px;background:#fff9ef;color:#352b29;padding:32px;width:min(540px,calc(100% - 28px));max-height:calc(100dvh - max(28px,env(safe-area-inset-top)) - max(28px,env(safe-area-inset-bottom)));overflow-y:auto;overscroll-behavior:contain;box-shadow:0 24px 100px #0005;touch-action:pan-y;user-select:text;-webkit-user-select:text}dialog::backdrop{background:#06151d9c;backdrop-filter:blur(8px)}.eyebrow{font-size:10px;color:#947d68;font-weight:700;letter-spacing:2px}h1{font-size:28px;margin:14px 0 6px;letter-spacing:-1px}.intro{font-size:13px;color:#9a8576;margin:0 0 25px}.modes{display:grid;grid-template-columns:repeat(3,1fr);gap:10px}.modes button{border:1px solid #e8dece;border-radius:18px;padding:15px 3px 16px;background:#fffdf7;color:#665346;min-width:0}.modes button[aria-pressed=true]{border:2px solid #387e74;background:#eaf3e9;padding:14px 2px 15px}.mode-icon{display:grid;place-items:center;width:44px;height:44px;margin:0 auto 10px;border-radius:50%;font-size:32px}.fish-icon{background:#d7eee5;color:#327a70}.laser-icon{background:#fae2dc;color:#eb6857;font-size:23px}.feather-icon{background:#f5e9c8;color:#a87625}.modes strong,.modes small{display:block}.modes strong{font-size:15px}.modes small{font-size:10px;color:#998675;margin-top:7px}.setting-row{display:flex;justify-content:space-between;align-items:center;gap:15px;padding:18px 0;border-bottom:1px solid #e9e1d3}.setting-row strong{font-size:13px;font-weight:600}.setting-row small{display:block;font-size:11px;color:#998675;margin-top:5px}#sound{width:44px;height:26px;border:0;background:#3e8274;border-radius:20px;padding:3px;flex-shrink:0}#sound span{display:block;width:20px;height:20px;background:#fff;border-radius:50%;transform:translateX(18px);transition:transform .2s}#sound[aria-checked=false]{background:#c7bfb1}#sound[aria-checked=false] span{transform:translateX(0)}.status-dot{height:8px;width:8px;border-radius:50%;background:#c5ad85;margin-right:16px}.status-dot.ready{background:#518e70}.actions{display:flex;gap:10px;margin:24px 0 19px}.actions button{width:50%;padding:13px 8px;border-radius:14px;background:transparent;border:1px solid #dbcfbc;font-size:14px;color:#78604c}.actions button.primary{background:#387e74;color:#fff;border-color:#387e74}.actions button:disabled{opacity:.6;cursor:default}details{font-size:11px;line-height:1.85;color:#998675}summary{cursor:pointer;color:#78604c}footer{display:flex;justify-content:space-between;gap:12px;color:#ad9d8c;font-size:10px;border-top:1px solid #e9e1d3;padding-top:18px;margin-top:20px}.noscript{position:fixed;inset:20px;color:white}body[data-mode=feather] #hint{background:#6f523bd9}@media(max-width:400px){dialog{padding:24px 20px}.modes{gap:7px}h1{font-size:25px}}@media(max-height:520px){dialog{padding:22px 28px}h1{font-size:23px}.intro{margin-bottom:12px}.modes button{padding:9px 3px}.modes button[aria-pressed=true]{padding:8px 2px}.mode-icon{width:30px;height:30px;font-size:23px;margin-bottom:5px}.setting-row{padding:12px 0}.actions{margin:15px 0}footer{margin-top:12px;padding-top:12px}}
+31
View File
@@ -0,0 +1,31 @@
const VERSION = 'pawplay-web-1.0.0';
const ROOT = new URL('./', self.location.href).href;
const SHELL = new URL('index.html', ROOT).href;
const ASSETS = ['index.html', 'game.js?v=1.0.0', 'style.css?v=1.0.0', 'manifest.webmanifest', 'icon.svg', 'apple-touch-icon.png', 'icon-512.png'].map(path => new URL(path, ROOT).href);
self.addEventListener('install', event => {
event.waitUntil((async () => {
const cache = await caches.open(VERSION);
try { await cache.addAll(ASSETS); }
catch (error) { await caches.delete(VERSION); throw error; }
})());
});
// A new version waits until existing game windows close, keeping one complete
// shell/version per session. Never force-activate over an in-progress game.
self.addEventListener('activate', event => {
event.waitUntil((async () => {
for (const key of await caches.keys()) {
if (key.startsWith('pawplay-web-') && key !== VERSION) await caches.delete(key);
}
await self.clients.claim();
})());
});
self.addEventListener('fetch', event => {
if (event.request.method !== 'GET') return;
const url = new URL(event.request.url);
if (!url.href.startsWith(ROOT)) return;
if (event.request.mode === 'navigate') {
event.respondWith((async () => (await (await caches.open(VERSION)).match(SHELL)) || fetch(event.request))());
} else if (ASSETS.includes(url.href)) {
event.respondWith((async () => (await (await caches.open(VERSION)).match(event.request)) || fetch(event.request))());
}
});