feat(thought-lab): 新增万花筒与闵可夫斯基画板两个思想实验室

新增两个独立思想实验室:
1. 万花筒模拟器:支持可调参数物理万花筒效果,配置方案SQLite持久化
2. 闵可夫斯基画板:支持世界线绘制、洛伦兹变换、光锥因果判定
同时添加自动绑定实验室后端的逻辑,以及配套的单元测试与前端页面
This commit is contained in:
yangxiangyuan
2026-08-06 17:41:43 +08:00
parent ea379116cc
commit 876a3b8768
17 changed files with 9979 additions and 0 deletions
+22
View File
@@ -144,6 +144,28 @@ const bindRoutes = (app) => {
// 4) 其余 /api/thought_lab 接口:默认导航鉴权
app.use('/api/thought_lab', navAuthApi)
// 5) lab 后端自动绑定:labs/<lab_id>/index.js 若导出 bindRoutes 则独立注册
// (注册在 API 守卫之后,自动继承导航鉴权;各 lab 互不干扰)
bindLabBackends(app)
}
const bindLabBackends = (app) => {
try {
if (!fs.existsSync(LABS_DIR)) return
const dirs = fs.readdirSync(LABS_DIR, { withFileTypes: true })
for (const d of dirs) {
if (!d.isDirectory()) continue
const entry = path.join(LABS_DIR, d.name, 'index.js')
if (!fs.existsSync(entry)) continue
try {
const mod = require(entry)
if (mod && typeof mod.bindRoutes === 'function') mod.bindRoutes(app)
} catch (e) {
try { console.error(`[thought_lab] 绑定 lab 后端失败: ${d.name} ${String(e.message || e)}`) } catch {}
}
}
} catch {}
}
module.exports = { bindRoutes, listLabs, loadLabConfig }
@@ -0,0 +1,17 @@
{
"_comment": "思想实验室 · 万花筒模拟器:Canvas 万花筒(纸片形状/颜色、旋转次数、棱镜镜像与旋扭、镜筒宽度全部可调),配置方案经 sqlite 持久化到本文件夹的 kaleidoscope.db,支持多套方案保存/加载/删除,刷新不丢。本 lab 完全独立;数据库、OSS 等后台逻辑统一放在本文件夹内。",
"id_comment": "lab 唯一标识(snake_case),与文件夹名保持一致",
"id": "kaleidoscope",
"name_comment": "首页卡片显示名称",
"name": "万花筒模拟器",
"icon_comment": "首页卡片图标(emoji)",
"icon": "🌈",
"desc_comment": "一句话简介,作为卡片 tooltip 展示",
"desc": "纸片/棱镜/镜筒可调 · 方案 sqlite 持久化",
"sort_order_comment": "卡片排序,数值越小越靠前",
"sort_order": 70,
"enabled_comment": "是否启用(false 时列表接口不返回该 lab)",
"enabled": true,
"ui_mode_comment": "弹窗交互模式:debug=交互详情弹窗(3s 自动关闭);normal=成功降级 Toast(2s),失败仍弹窗",
"ui_mode": "debug"
}
@@ -0,0 +1,108 @@
// ============================================================
// kaleidoscope/db.js - 万花筒方案 SQLite 持久化(lab 独立,不共享其他模块)
// 数据库文件:本文件夹下 kaleidoscope.db(WAL 模式)
// 表:kaleidoscope_schemes —— 保存多套配置方案(参数 JSON + 随机种子)
// 时间字段统一北京时间(Asia/Shanghai)
// ============================================================
const path = require('path')
const Database = require('better-sqlite3')
const DB_PATH = path.join(__dirname, 'kaleidoscope.db')
let db = null
// 北京时间格式化:YYYY-MM-DD HH:mm:ss
const BJ_FMT = new Intl.DateTimeFormat('zh-CN', {
timeZone: 'Asia/Shanghai',
hour12: false,
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit'
})
const nowBeijing = () => {
const parts = BJ_FMT.formatToParts(new Date())
const get = t => (parts.find(p => p.type === t) || {}).value || ''
return `${get('year')}-${get('month')}-${get('day')} ${get('hour')}:${get('minute')}:${get('second')}`
}
const getDB = () => {
if (db) return db
db = new Database(DB_PATH)
db.pragma('journal_mode = WAL')
db.pragma('synchronous = NORMAL')
initSchema(db)
return db
}
const initSchema = (database) => {
database.exec(`
CREATE TABLE IF NOT EXISTS kaleidoscope_schemes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
params TEXT NOT NULL,
seed INTEGER NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
`)
database.exec(`
CREATE INDEX IF NOT EXISTS idx_kaleido_schemes_updated_at ON kaleidoscope_schemes(updated_at DESC)
`)
}
const rowToScheme = (row) => {
if (!row) return null
let params = {}
try { params = JSON.parse(row.params) } catch {}
return {
id: row.id,
name: row.name,
params,
seed: row.seed,
created_at: row.created_at,
updated_at: row.updated_at
}
}
const listSchemes = () => {
const rows = getDB().prepare(`
SELECT id, name, params, seed, created_at, updated_at
FROM kaleidoscope_schemes
ORDER BY updated_at DESC
LIMIT 200
`).all()
return rows.map(rowToScheme)
}
const getScheme = (id) => {
const row = getDB().prepare(`
SELECT id, name, params, seed, created_at, updated_at
FROM kaleidoscope_schemes WHERE id = ?
`).get(id)
return rowToScheme(row)
}
const createScheme = ({ name, params, seed }) => {
const now = nowBeijing()
const result = getDB().prepare(`
INSERT INTO kaleidoscope_schemes (name, params, seed, created_at, updated_at)
VALUES (?, ?, ?, ?, ?)
`).run(name, JSON.stringify(params), seed, now, now)
return getScheme(Number(result.lastInsertRowid))
}
const updateScheme = (id, { name, params, seed }) => {
const now = nowBeijing()
const result = getDB().prepare(`
UPDATE kaleidoscope_schemes
SET name = ?, params = ?, seed = ?, updated_at = ?
WHERE id = ?
`).run(name, JSON.stringify(params), seed, now, id)
return result.changes > 0 ? getScheme(id) : null
}
const deleteScheme = (id) => {
const result = getDB().prepare(`DELETE FROM kaleidoscope_schemes WHERE id = ?`).run(id)
return result.changes > 0
}
module.exports = { getDB, listSchemes, getScheme, createScheme, updateScheme, deleteScheme }
@@ -0,0 +1,110 @@
// ============================================================
// kaleidoscope/index.js - 万花筒模拟器 lab 后端路由
// 挂载点:/api/thought_lab/kaleidoscope/*
// 由 thought_lab/index.js 自动扫描绑定(注册在 nav_gate API 守卫之后,继承导航鉴权)
// 数据库逻辑见同目录 db.js(独立 kaleidoscope.db)
// ============================================================
const fs = require('fs')
const path = require('path')
const db = require('./db')
const CONFIG_PATH = path.join(__dirname, 'config.json')
const NAME_MAX = 60
const PARAMS_MAX_BYTES = 10 * 1024
const readConfig = () => {
try {
if (fs.existsSync(CONFIG_PATH)) return JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'))
} catch {}
return {}
}
// 原型污染防护(项目统一风格)
const PROTO_KEYS = new Set(['__proto__', 'constructor', 'prototype', '__defineGetter__', '__defineSetter__', '__lookupGetter__', '__lookupSetter__'])
const sanitizeObject = (obj, depth = 0) => {
if (depth > 10) return {}
if (obj === null || typeof obj !== 'object') return obj
if (Array.isArray(obj)) return obj.map(item => sanitizeObject(item, depth + 1))
const clean = {}
for (const [key, val] of Object.entries(obj)) {
if (PROTO_KEYS.has(key)) continue
clean[key] = sanitizeObject(val, depth + 1)
}
return clean
}
// 校验并规范化入参,非法返回 { error }
const parsePayload = (body) => {
const name = String((body && body.name) || '').trim()
if (!name) return { error: '方案名称不能为空' }
if (name.length > NAME_MAX) return { error: `名称超过 ${NAME_MAX} 字符` }
let params = body && body.params
if (typeof params !== 'object' || params === null || Array.isArray(params)) return { error: 'params 必须是 JSON 对象' }
params = sanitizeObject(params)
if (Buffer.byteLength(JSON.stringify(params), 'utf8') > PARAMS_MAX_BYTES) return { error: 'params 超过 10KB 限制' }
const seed = Number.isFinite(Number(body && body.seed)) ? Math.trunc(Number(body.seed)) : 0
return { name, params, seed }
}
const bindRoutes = (app) => {
const B = '/api/thought_lab/kaleidoscope'
// 元信息:ui_mode 等(前端弹窗双模式依据)
app.get(B + '/meta', (req, res) => {
const cfg = readConfig()
return res.json({ ok: true, ui_mode: String(cfg.ui_mode || 'debug') })
})
// 方案列表
app.get(B + '/schemes', (req, res) => {
try {
return res.json({ ok: true, schemes: db.listSchemes() })
} catch (e) {
return res.status(500).json({ ok: false, error: 'internal_error' })
}
})
// 新建方案
app.post(B + '/schemes', (req, res) => {
const p = parsePayload(req.body)
if (p.error) return res.status(400).json({ ok: false, error: p.error })
try {
const scheme = db.createScheme(p)
return res.json({ ok: true, scheme })
} catch (e) {
return res.status(500).json({ ok: false, error: 'internal_error' })
}
})
// 更新方案
app.put(B + '/schemes/:id', (req, res) => {
const id = Number(req.params.id)
if (!Number.isFinite(id)) return res.status(400).json({ ok: false, error: 'invalid id' })
const p = parsePayload(req.body)
if (p.error) return res.status(400).json({ ok: false, error: p.error })
try {
const scheme = db.updateScheme(id, p)
if (!scheme) return res.status(404).json({ ok: false, error: 'not_found' })
return res.json({ ok: true, scheme })
} catch (e) {
return res.status(500).json({ ok: false, error: 'internal_error' })
}
})
// 删除方案
app.delete(B + '/schemes/:id', (req, res) => {
const id = Number(req.params.id)
if (!Number.isFinite(id)) return res.status(400).json({ ok: false, error: 'invalid id' })
try {
const ok = db.deleteScheme(id)
if (!ok) return res.status(404).json({ ok: false, error: 'not_found' })
return res.json({ ok: true })
} catch (e) {
return res.status(500).json({ ok: false, error: 'internal_error' })
}
})
}
module.exports = { bindRoutes }
@@ -0,0 +1,15 @@
{
"_comment": "思想实验室 · 闵可夫斯基画板:『闵可夫斯基空间里的 Inkscape』——在时空画布上画世界线(钢笔/节点工具,线段按类时/类光/类空实时着色),用快度滑块做洛伦兹变换(主动/被动双视图 + 扫掠动画),点击事件展开光锥并对两事件做因果判定。本 lab 完全独立;未来若需要数据库、OSS 等后台逻辑,统一放在本文件夹内。",
"id_comment": "lab 唯一标识(snake_case),与文件夹名保持一致",
"id": "minkowski_inkscape",
"name_comment": "首页卡片显示名称",
"name": "闵可夫斯基画板",
"icon_comment": "首页卡片图标(emoji)",
"icon": "📐",
"desc_comment": "一句话简介,作为卡片 tooltip 展示",
"desc": "世界线绘制 · 洛伦兹 boost · 光锥因果判定",
"sort_order_comment": "卡片排序,数值越小越靠前",
"sort_order": 60,
"enabled_comment": "是否启用(false 时列表接口不返回该 lab)",
"enabled": true
}