feat(thought-lab): 新增万花筒与闵可夫斯基画板两个思想实验室
新增两个独立思想实验室: 1. 万花筒模拟器:支持可调参数物理万花筒效果,配置方案SQLite持久化 2. 闵可夫斯基画板:支持世界线绘制、洛伦兹变换、光锥因果判定 同时添加自动绑定实验室后端的逻辑,以及配套的单元测试与前端页面
This commit is contained in:
@@ -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 }
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user