Compare commits
14 Commits
v1.0.1
..
a5b0b5c9e8
| Author | SHA1 | Date | |
|---|---|---|---|
| a5b0b5c9e8 | |||
| 204387efa2 | |||
| ac440f5299 | |||
| dde9742f9c | |||
| 7fc9d4d843 | |||
| 713c3cd0a2 | |||
| 830d5b722a | |||
| ad86a1661b | |||
| 84fc19c965 | |||
| 20aea13054 | |||
| 10c4487463 | |||
| 4a99cd94a7 | |||
| 22f9a3b8ad | |||
| 42f4c427d2 |
@@ -20,3 +20,7 @@ app/keystore/
|
|||||||
*.iml
|
*.iml
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
|
# Local Python caches
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
*
|
||||||
|
!package.json
|
||||||
|
!server.mjs
|
||||||
|
!password.mjs
|
||||||
|
!public/
|
||||||
|
!public/**
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
data/
|
||||||
|
node_modules/
|
||||||
|
*.log
|
||||||
|
test-artifacts/
|
||||||
@@ -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"]
|
||||||
@@ -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;更新需通过原有挂载数据目录,部署需单独执行。
|
||||||
@@ -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"}
|
||||||
|
}
|
||||||
@@ -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'));
|
||||||
|
}
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
const $ = selector => document.querySelector(selector);
|
||||||
|
const el = (tag, text, className) => {
|
||||||
|
const node = document.createElement(tag);
|
||||||
|
if (text !== undefined) node.textContent = text;
|
||||||
|
if (className) node.className = className;
|
||||||
|
return node;
|
||||||
|
};
|
||||||
|
let dashboard;
|
||||||
|
let currentReportId;
|
||||||
|
const labels = { views: '视频播放', likes: '点赞', followers: '关注人数', downloads: 'APK 下载', visits: '网页访问', videos_published: '已发布视频', comments: '评论', favorites: '收藏', coins: '投币', watch_time: '观看时长', completion_rate: '完播率', revenue: '收益', value: '数值', unit: '单位', source: '来源', status: '状态', note: '备注', observed_at: '观测时间', project: '项目', account: '账号', account_followers: '账号关注人数', scheduled_codex: '定时运营 Codex 用量', harness_new_runs: '后续 DeepSeek Harness 用量', codex: '项目会话 Codex 用量', deepseek: 'DeepSeek 历史用量快照' };
|
||||||
|
const basisLabels = { cash: '实际现金', allocated: '套餐分摊', estimate: '预估', settled: '已结算' };
|
||||||
|
const categoryLabels = { subscription: '套餐', api: 'API', electricity: '电费', hosting: '托管', revenue: '收入', tokens: 'Token 用量' };
|
||||||
|
function valueText(value) {
|
||||||
|
if (value === null || value === undefined || value === '') return '待核实';
|
||||||
|
if (typeof value === 'boolean') return value ? '是' : '否';
|
||||||
|
if (Array.isArray(value)) return value.map(valueText).join(';') || '暂无记录';
|
||||||
|
if (typeof value === 'object') return Object.entries(value).map(([k, v]) => `${labels[k] || k}:${valueText(v)}`).join(' · ') || '暂无记录';
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
function money(value, currency = 'USD') {
|
||||||
|
if (typeof value !== 'number' || !Number.isFinite(value)) return '待核实';
|
||||||
|
if (currency === 'BILIBILI_COIN') return `${value} B站硬币`;
|
||||||
|
if (typeof currency !== 'string' || !/^[A-Z]{3}$/.test(currency)) return `${value} ${String(currency)}`;
|
||||||
|
return new Intl.NumberFormat('zh-CN', { style: 'currency', currency, maximumFractionDigits: 2 }).format(value);
|
||||||
|
}
|
||||||
|
function dateTime(value) {
|
||||||
|
if (!value) return '待确认';
|
||||||
|
if (!String(value).includes('T')) return String(value);
|
||||||
|
const date = new Date(value);
|
||||||
|
if (!Number.isFinite(date.getTime())) return String(value);
|
||||||
|
return new Intl.DateTimeFormat('zh-CN', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' }).format(date);
|
||||||
|
}
|
||||||
|
async function api(url, options = {}) {
|
||||||
|
const response = await fetch(url, { credentials: 'same-origin', ...options, headers: { 'Content-Type': 'application/json', ...options.headers } });
|
||||||
|
const data = await response.json();
|
||||||
|
if (response.status === 401 && url !== '/api/login') showLogin();
|
||||||
|
if (!response.ok) throw new Error(data.error || '请求失败,请稍后再试');
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
function showLogin() {
|
||||||
|
$('#loading').hidden = true; $('#dashboard-view').hidden = true; $('#login-view').hidden = false;
|
||||||
|
$('#password').value = '';
|
||||||
|
}
|
||||||
|
function card(title, amount, note, accent = false) {
|
||||||
|
const node = el('article', undefined, `stat-card${accent ? ' accent' : ''}`);
|
||||||
|
node.append(el('p', title, 'stat-label'), el('strong', amount), el('p', note, 'stat-note'));
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
function listSection(title, items, style = '') {
|
||||||
|
const section = el('section', undefined, `report-section ${style}`);
|
||||||
|
section.append(el('h2', title));
|
||||||
|
if (!Array.isArray(items) || !items.length) { section.append(el('p', '暂无记录', 'muted')); return section; }
|
||||||
|
const list = el('ul');
|
||||||
|
for (const item of items) list.append(el('li', valueText(item)));
|
||||||
|
section.append(list); return section;
|
||||||
|
}
|
||||||
|
function budgetPanel() {
|
||||||
|
const budget = dashboard.config?.budget || {};
|
||||||
|
const section = el('section', undefined, 'budget-note');
|
||||||
|
const header = el('div', undefined, 'section-heading');
|
||||||
|
header.append(el('h2', '预算口径'), el('span', '套餐内 Token 不重复计费', 'tag'));
|
||||||
|
const period = { monthly: '月', weekly: '周', pending: '待确认周期' }[budget.billing_period] || '待确认周期';
|
||||||
|
const amount = typeof budget.subscription_usd === 'number' ? `${money(budget.subscription_usd)} / ${period}` : '套餐价格待确认';
|
||||||
|
const fraction = typeof budget.project_allocation_fraction === 'number' ? `${Math.round(budget.project_allocation_fraction * 100)}%` : '待归因';
|
||||||
|
section.append(header, el('p', `账户套餐:${amount} · 本项目分摊比例:${fraction}`), el('p', '套餐成本按确认的周期和项目占比分摊。套餐内 Token 用量单独记录,不再加算为现金支出;额外 API、托管和电费按实际凭据入账。账户总用量不等于本项目用量。', 'muted'));
|
||||||
|
return section;
|
||||||
|
}
|
||||||
|
function renderReport(report) {
|
||||||
|
currentReportId = report?.id;
|
||||||
|
const content = $('#report-content'); content.replaceChildren();
|
||||||
|
const download = $('#download-report');
|
||||||
|
if (!report) {
|
||||||
|
$('#report-title').textContent = '第一份报告,正在准备'; $('#report-period').textContent = '每周进展与收支将在这里归档。';
|
||||||
|
download.hidden = true; content.append(budgetPanel()); return;
|
||||||
|
}
|
||||||
|
$('#report-title').textContent = report.title || report.id;
|
||||||
|
$('#report-period').textContent = `${dateTime(report.period_start)} — ${dateTime(report.period_end)} · 北京时间${report.partial_period || report.kind === 'snapshot' ? ' · 截至当前的部分周期' : ''}`;
|
||||||
|
download.href = `/api/reports/${encodeURIComponent(report.id)}/download`; download.hidden = false;
|
||||||
|
for (const link of $('#report-nav').children) link.classList.toggle('selected', link.dataset.id === report.id);
|
||||||
|
const intro = el('section', undefined, 'summary'); intro.append(el('span', '本周摘要', 'eyebrow'), el('p', valueText(report.summary))); content.append(intro);
|
||||||
|
const costs = report.costs || {}, revenue = report.revenue || {};
|
||||||
|
const stats = el('div', undefined, 'stats');
|
||||||
|
const cashNote = typeof costs.confirmed_recorded_cash_usd === 'number' ? `已确认记录 ${money(costs.confirmed_recorded_cash_usd)};未覆盖支出仍待核实` : '仅计实际发生的美元支出';
|
||||||
|
stats.append(card('本周实际现金支出', money(costs.cash_usd), cashNote), card('本周套餐分摊', money(costs.allocated_usd), '固定费用归因,与现金支出分开'), card('已结算收入', money(revenue.settled_cny, 'CNY'), '以平台结算或到账为依据', true), card('预估收入', money(revenue.estimated_cny, 'CNY'), '尚未结算,不计作已实现收益'));
|
||||||
|
content.append(stats, budgetPanel());
|
||||||
|
if (report.metrics && Object.keys(report.metrics).length) {
|
||||||
|
const section = el('section', undefined, 'report-section'); section.append(el('h2', '运营数据'));
|
||||||
|
const metrics = el('dl', undefined, 'metric-grid');
|
||||||
|
for (const [name, value] of Object.entries(report.metrics)) { const row = el('div'); row.append(el('dt', labels[name] || name), el('dd', valueText(value))); metrics.append(row); }
|
||||||
|
section.append(metrics); content.append(section);
|
||||||
|
}
|
||||||
|
const columns = el('div', undefined, 'report-columns'); columns.append(listSection('本周完成', report.highlights), listSection('下一步', report.next_actions)); content.append(columns);
|
||||||
|
if (report.risks?.length) content.append(listSection('需要关注', report.risks, 'attention'));
|
||||||
|
if (report.decisions?.length) content.append(listSection('运营决策', report.decisions));
|
||||||
|
}
|
||||||
|
function renderLedger() {
|
||||||
|
const host = $('#ledger'); host.replaceChildren();
|
||||||
|
const entries = dashboard.ledger?.entries || [];
|
||||||
|
if (!entries.length) { host.append(el('p', '暂无已入账记录。未取得数据的项目保持待核实,不按零成本处理。', 'empty')); return; }
|
||||||
|
const wrapper = el('div', undefined, 'table-wrap'), table = el('table'), head = el('thead'), header = el('tr');
|
||||||
|
for (const title of ['日期', '项目', '类型', '金额', '口径', '依据']) header.append(el('th', title));
|
||||||
|
head.append(header); const body = el('tbody');
|
||||||
|
for (const item of [...entries].sort((a, b) => String(b.date).localeCompare(String(a.date)))) {
|
||||||
|
const row = el('tr');
|
||||||
|
for (const value of [item.date, item.label, categoryLabels[item.category] || item.category, money(item.amount, item.currency || 'USD'), basisLabels[item.basis] || item.basis, item.source || '待补充']) row.append(el('td', valueText(value)));
|
||||||
|
body.append(row);
|
||||||
|
}
|
||||||
|
table.append(head, body); wrapper.append(table); host.append(wrapper);
|
||||||
|
}
|
||||||
|
function showView(view) {
|
||||||
|
for (const name of ['overview', 'execution', 'report', 'evolution', 'daily']) $(`#${name}-view`).hidden = view !== name;
|
||||||
|
for (const [selector, active] of [['#show-overview', view === 'overview'], ['#show-execution', view === 'execution'], ['#show-report', view === 'report'], ['#show-evolution', view === 'evolution'], ['#show-daily', view === 'daily']]) {
|
||||||
|
$(selector).classList.toggle('active', active); $(selector).setAttribute('aria-pressed', String(active));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function openOverview() {
|
||||||
|
showView('overview'); history.replaceState(null, '', '/');
|
||||||
|
const host = $('#overview-content'); host.replaceChildren(el('p', '正在读取运营全景…', 'muted'));
|
||||||
|
try {
|
||||||
|
const [data, queue] = await Promise.all([api('/api/overview'), api('/api/workqueue')]); host.replaceChildren(); const strategy = data.strategy || {}, market = data.market || {};
|
||||||
|
const hero = el('section', undefined, 'report-section'); hero.append(el('h2', '方向与当前阶段'), el('p', strategy.mission || '方向待补充'), el('p', strategy.phase || '', 'muted')); const pillars = el('ul'); for (const item of strategy.pillars || []) pillars.append(el('li', item)); hero.append(pillars, el('p', strategy.cadence || '', 'muted')); host.append(hero);
|
||||||
|
const works = el('section', undefined, 'report-section'); works.append(el('h2', '已发布作品'), el('p', `采样时间:${market.observed_at ? dateTime(market.observed_at) : '暂无'};播放量不是曝光或完播率。`, 'muted'));
|
||||||
|
for (const v of market.account_videos || []) { const p = el('p'); if (v.bvid) { const a = el('a', v.title); a.href = `https://www.bilibili.com/video/${v.bvid}/`; a.target = '_blank'; a.rel = 'noopener noreferrer'; p.append(a); } else p.append(el('span', v.title)); p.append(document.createTextNode(` · 播放 ${v.views ?? '未知'} · 点赞 ${v.likes ?? '未知'} · 评论 ${v.comments ?? '未知'}`)); works.append(p); } host.append(works);
|
||||||
|
const budget = el('section', undefined, 'report-section'); budget.append(el('h2', '计划投入'), el('p', (strategy.budget || []).map(x => `${x.name} ${x.percent}%`).join(' · ')), el('p', strategy.budget_note || '', 'muted')); host.append(budget);
|
||||||
|
const work = el('section', undefined, 'report-section'); work.append(el('h2', '持续工作')); const cats = {content:'内容',engineering:'工程',research:'研究',community:'社区',management:'管理'}, status = {pending:'待继续',running:'进行中',done:'已完成',blocked:'需要处理'}, list = el('ul'); for (const t of queue.tasks || []) if (t.status !== 'done') list.append(el('li', `${cats[t.category] || '管理'} · ${t.title} · ${status[t.status] || t.status}`)); work.append(list); host.append(work);
|
||||||
|
const trends = el('section', undefined, 'report-section'); trends.append(el('h2', 'B 站公开榜单快照'), el('p', '单时点观察,基于标题和公开数据,不能代表趋势或保证效果。', 'muted')); const ul = el('ul'); for (const v of market.ranking || []) { const li = el('li'); if (v.bvid) { const a = el('a', v.title); a.href = `https://www.bilibili.com/video/${v.bvid}/`; a.target = '_blank'; a.rel = 'noopener noreferrer'; li.append(a); } else li.append(el('span', v.title)); li.append(document.createTextNode(` · ${v.owner} · ${v.views ?? '未知'} 播放`)); ul.append(li); } trends.append(ul); host.append(trends);
|
||||||
|
} catch (error) { host.replaceChildren(el('p', error.message, 'error')); }
|
||||||
|
}
|
||||||
|
async function openExecution(updateHistory = true) {
|
||||||
|
showView('execution'); if (updateHistory) history.replaceState(null, '', '/?view=execution');
|
||||||
|
const host = $('#execution-content'); host.replaceChildren(el('p', '正在读取运行状态…', 'muted'));
|
||||||
|
try {
|
||||||
|
const [data, queue] = await Promise.all([api('/api/execution'), api('/api/workqueue')]); host.replaceChildren();
|
||||||
|
const sampled = Date.parse(data.observed_at || ''); const stale = !Number.isFinite(sampled) || Date.now() - sampled > 3 * 60 * 1000;
|
||||||
|
host.append(el('p', `状态采样:${data.observed_at ? dateTime(data.observed_at) : '暂无'}${stale ? ' · 状态可能过期,请检查采集服务' : ' · 约每两分钟刷新'}`, stale ? 'error' : 'muted'));
|
||||||
|
const now = el('section', undefined, 'report-section'); now.append(el('h2', '当前执行'));
|
||||||
|
const cycle = (data.units || []).find(x => x.id === 'cycle'); const active = cycle && ['active','activating'].includes(cycle.state);
|
||||||
|
if (data.daily_budget) now.append(el('p', `今日主运营尝试:${data.daily_budget.attempts} / ${data.daily_budget.limit};达到上限后等待次日,避免耗尽共享额度。`, 'muted'));
|
||||||
|
now.append(el('p', stale ? '快照已过期,不能确定当前运行情况。' : active ? '主运营进程正在运行。' : cycle?.state === 'inactive' ? '主运营当前空闲,等待可执行任务或预算恢复。' : `主运营状态:${cycle?.state || '未知'}`));
|
||||||
|
for (const t of data.running_tasks || []) now.append(el('p', `领取任务:${t.title} · ${t.updated_at ? dateTime(t.updated_at) : '时间未知'}`)); host.append(now);
|
||||||
|
const schedules = el('section', undefined, 'report-section'); schedules.append(el('h2', '服务与计划'), el('p', '有待执行任务且额度允许时,守护程序约每两分钟尝试启动;下方为额外的固定时刻。', 'muted')); const list = el('ul');
|
||||||
|
for (const u of data.units || []) list.append(el('li', `${u.label} · ${u.state === 'activating' || u.state === 'active' ? '正在运行' : u.state === 'inactive' ? '当前空闲' : u.state || '未知'} · 定时器${u.timer_state === 'active' ? '已启用' : u.timer_state || '未知'} · 下次固定时刻 ${u.next_run || '未知'} · 上次结果 ${u.result || '暂无'}`)); schedules.append(list); host.append(schedules);
|
||||||
|
const tasks = el('section', undefined, 'report-section'); tasks.append(el('h2', '任务列表')); const status = {pending:'待执行或等待退避',running:'已领取',done:'已完成',blocked:'已阻塞'}; const ul = el('ul'); for (const t of queue.tasks || []) ul.append(el('li', `${t.title} · ${status[t.status] || t.status} · 尝试 ${t.attempts} 次 · ${t.updated_at ? dateTime(t.updated_at) : '时间未知'}`)); tasks.append(ul); host.append(tasks);
|
||||||
|
const past = el('section', undefined, 'report-section'); past.append(el('h2', '主运营执行历史'), el('p', '记录来自每轮执行汇总;进程成功不代表整个任务已经完成。详细产出和复盘见每日计划与复盘。', 'muted'));
|
||||||
|
const results = {complete:'进程成功',failed:'失败',timeout:'超时',incomplete:'未记录进度',preflight_failed:'前置检查失败',unknown:'状态未知'}; const rows = el('ul'); for (const x of data.history || []) rows.append(el('li', `${dateTime(x.started_at)} · ${x.title} · ${results[x.status] || '未知'}${x.elapsed_seconds === null ? '' : ` · ${x.elapsed_seconds} 秒`}`)); if (!(data.history || []).length) rows.append(el('li', '暂无执行汇总')); past.append(rows); host.append(past);
|
||||||
|
} catch (error) { host.replaceChildren(el('p', error.message, 'error')); }
|
||||||
|
}
|
||||||
|
async function openDaily() {
|
||||||
|
showView('daily');
|
||||||
|
const host = $('#daily-content'); host.replaceChildren(el('p', '正在读取每日记录…', 'muted'));
|
||||||
|
history.replaceState(null, '', '/?view=daily');
|
||||||
|
try {
|
||||||
|
const data = await api('/api/daily');
|
||||||
|
host.replaceChildren();
|
||||||
|
try {
|
||||||
|
const queue = await api('/api/workqueue');
|
||||||
|
const work = el('section', undefined, 'report-section');
|
||||||
|
work.append(el('h2', '持续工作'));
|
||||||
|
const status = { pending: '待继续', running: '进行中', done: '已完成', blocked: '需要处理' };
|
||||||
|
const tasks = Array.isArray(queue.tasks) ? queue.tasks : [];
|
||||||
|
if (!tasks.length) work.append(el('p', '暂无任务。', 'muted'));
|
||||||
|
else { const list = el('ul'); for (const task of tasks) list.append(el('li', `${task.title} · ${status[task.status] || task.status} · ${task.updated_at ? dateTime(task.updated_at) : '时间待确认'}`)); work.append(list); }
|
||||||
|
host.append(work);
|
||||||
|
} catch { host.append(el('p', '暂时无法读取持续工作状态。', 'error')); }
|
||||||
|
if (data.invalid_count) host.append(el('p', `${data.invalid_count} 条记录无法读取,请检查数据文件。`, 'error'));
|
||||||
|
if (!data.records.length) host.append(el('p', '暂无每日记录。', 'empty'));
|
||||||
|
for (const record of data.records) {
|
||||||
|
const day = el('details', undefined, 'report-section');
|
||||||
|
const heading = el('summary', record.date); day.append(heading);
|
||||||
|
for (const [title, field] of [['已计划', 'planned'], ['实际执行', 'executed'], ['证据', 'evidence'], ['成本', 'costs'], ['复盘', 'review'], ['明日任务', 'tomorrow']]) day.append(listSection(title, record[field]));
|
||||||
|
host.append(day);
|
||||||
|
}
|
||||||
|
} catch (error) { host.replaceChildren(el('p', error.message, 'error')); }
|
||||||
|
}
|
||||||
|
function renderEvolution(data) {
|
||||||
|
const host = $('#evolution-content'); host.replaceChildren();
|
||||||
|
const events = Array.isArray(data?.events) ? data.events : [];
|
||||||
|
if (!events.length) { host.append(el('p', '还没有演变记录。后续探索与决策会在这里归档。', 'empty')); return; }
|
||||||
|
const timeline = el('div', undefined, 'timeline');
|
||||||
|
for (const event of events) {
|
||||||
|
const item = el('details', undefined, 'evolution-event');
|
||||||
|
const summary = el('summary');
|
||||||
|
const heading = el('span', undefined, 'event-heading');
|
||||||
|
const precision = { datetime: '精确时间', date: '日期', approximate: '约略时间', unknown: '时间待确认' }[event.occurred_at_precision] || event.occurred_at_precision;
|
||||||
|
const timing = event.occurred_at ? `发生:${dateTime(event.occurred_at)}${precision ? `(${precision})` : ''} · ` : (precision ? `发生时间:${precision} · ` : '');
|
||||||
|
heading.append(el('strong', valueText(event.change || event.trigger || event.id)), el('span', `${timing}记录:${dateTime(event.recorded_at)} · ${event.retrospective === true ? '早期补录' : event.retrospective === false ? '实时记录' : '记录方式待确认'}`, 'muted'));
|
||||||
|
summary.append(heading); item.append(summary);
|
||||||
|
const fields = el('dl', undefined, 'event-fields');
|
||||||
|
for (const [label, value] of [['起因', event.trigger], ['假设', event.hypothesis], ['改变', event.change], ['证据', event.evidence], ['已知成本', event.cost?.known], ['待确认成本', event.cost?.unknown], ['决策', event.decision], ['下一步', event.next_step], ['关联版本与资料', event.artifact_refs]]) {
|
||||||
|
const row = el('div'); row.append(el('dt', label), el('dd', valueText(value))); fields.append(row);
|
||||||
|
}
|
||||||
|
item.append(fields); timeline.append(item);
|
||||||
|
}
|
||||||
|
host.append(timeline);
|
||||||
|
}
|
||||||
|
async function openEvolution() {
|
||||||
|
showView('evolution');
|
||||||
|
const host = $('#evolution-content'); host.replaceChildren(el('p', '正在读取演变记录…', 'muted'));
|
||||||
|
history.replaceState(null, '', `/?view=evolution${currentReportId ? `&report=${encodeURIComponent(currentReportId)}` : ''}`);
|
||||||
|
try { renderEvolution(await api('/api/evolution')); }
|
||||||
|
catch (error) { host.replaceChildren(el('p', error.message, 'error')); }
|
||||||
|
}
|
||||||
|
async function openReport(id) {
|
||||||
|
showView('report');
|
||||||
|
$('#global-error').textContent = '';
|
||||||
|
try { const report = await api(`/api/reports/${encodeURIComponent(id)}`); renderReport(report); history.replaceState(null, '', `/?report=${encodeURIComponent(id)}`); }
|
||||||
|
catch (error) { $('#global-error').textContent = error.message; }
|
||||||
|
}
|
||||||
|
async function loadDashboard() {
|
||||||
|
dashboard = await api('/api/dashboard');
|
||||||
|
$('#loading').hidden = true; $('#login-view').hidden = true; $('#dashboard-view').hidden = false;
|
||||||
|
document.title = dashboard.config?.title || 'PawPlay 运营台';
|
||||||
|
const nav = $('#report-nav'); nav.replaceChildren();
|
||||||
|
for (const report of dashboard.reports) {
|
||||||
|
const link = el('a'); link.href = `/?report=${encodeURIComponent(report.id)}`; link.dataset.id = report.id;
|
||||||
|
link.append(el('strong', report.id), el('span', report.title || '每周运营报告'));
|
||||||
|
link.addEventListener('click', event => { event.preventDefault(); openReport(report.id); }); nav.append(link);
|
||||||
|
}
|
||||||
|
if (!dashboard.reports.length) nav.append(el('p', '还没有历史周报', 'muted'));
|
||||||
|
renderLedger();
|
||||||
|
const params = new URL(location.href).searchParams;
|
||||||
|
const selected = params.get('report');
|
||||||
|
const initialView = params.get('view');
|
||||||
|
if (selected && dashboard.reports.some(r => r.id === selected)) await openReport(selected); else renderReport(dashboard.latest);
|
||||||
|
if (initialView === 'evolution') await openEvolution();
|
||||||
|
if (initialView === 'daily') await openDaily();
|
||||||
|
if (initialView === 'execution') await openExecution(false);
|
||||||
|
if (!selected && !initialView) await openOverview();
|
||||||
|
}
|
||||||
|
$('#show-execution').addEventListener('click', () => openExecution());
|
||||||
|
setInterval(() => { if (dashboard && !$('#execution-view').hidden) openExecution(false); }, 60000);
|
||||||
|
$('#show-overview').addEventListener('click', openOverview);
|
||||||
|
$('#show-evolution').addEventListener('click', openEvolution);
|
||||||
|
$('#show-daily').addEventListener('click', openDaily);
|
||||||
|
$('#show-report').addEventListener('click', () => { showView('report'); history.replaceState(null, '', currentReportId ? `/?report=${encodeURIComponent(currentReportId)}` : '/'); });
|
||||||
|
$('#login-form').addEventListener('submit', async event => {
|
||||||
|
event.preventDefault(); const button = event.currentTarget.querySelector('button'); button.disabled = true; $('#login-error').textContent = '';
|
||||||
|
try { await api('/api/login', { method: 'POST', body: JSON.stringify({ username: $('#username').value, password: $('#password').value }) }); $('#password').value = ''; await loadDashboard(); }
|
||||||
|
catch (error) { $('#login-error').textContent = error.message; } finally { button.disabled = false; }
|
||||||
|
});
|
||||||
|
$('#logout').addEventListener('click', async () => {
|
||||||
|
try { await api('/api/logout', { method: 'POST', body: '{}' }); dashboard = null; $('#report-content').replaceChildren(); $('#evolution-content').replaceChildren(); $('#daily-content').replaceChildren(); currentReportId = undefined; showView('report'); $('#ledger').replaceChildren(); $('#report-nav').replaceChildren(); showLogin(); }
|
||||||
|
catch (error) { $('#global-error').textContent = error.message; }
|
||||||
|
});
|
||||||
|
(async () => {
|
||||||
|
try { const status = await api('/api/session'); if (status.authenticated) await loadDashboard(); else showLogin(); }
|
||||||
|
catch (error) { showLogin(); $('#login-error').textContent = error.message; }
|
||||||
|
})();
|
||||||
@@ -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
@@ -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');
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
@@ -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 号;当前播放数据采集未接入,有已发布稿件时相关指标保留待核验。平台结算数据须按实际到账或后台结果记账。
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -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
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
.qa/
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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();
|
||||||
|
})();
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
@@ -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 |
@@ -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>
|
||||||
@@ -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"}]}
|
||||||
@@ -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
@@ -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;});
|
||||||
@@ -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}}
|
||||||
@@ -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))());
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user