{"allowContribute":false,"item":{"aiFields":{"content":"📂 科技 | 🏷️ CubeLV、插件系統、Schema、JSONB、TypeScript、前端架構、plugin開發\n\n深入解析 CubeLV 的動態插件與 Schema 實作機制：以 PostgreSQL JSONB 儲存動態欄位、TypeScript 裝飾器 class 即 schema（一份程式碼同時定義型別、驗證白名單與表單元件）、Host 框架模式達成前端動態渲染（Sidebar/卡片模板/表單生成），並附搬遷管理、知識回顧面板、卡片模板三個完整插件案例的撰寫模式與常見踩坑提醒。"},"content":"\u003e 整理自 2026-06-27 與 Claude 的架構對話，接續「CubeLV 系統架構深度解析」\n\n* * *\n\n## 一、PostgreSQL：JSONB 存所有動態欄位\n\n核心問題：每個 plugin 的 itemType 欄位都不同，傳統 RDBMS 要 `ALTER TABLE` 才能加欄位。\n\n### 實際 table 結構\n\n```sql\nCREATE TABLE items (\n  -- ═══ 所有 itemType 共用的核心欄位（固定 column） ═══\n  id            TEXT PRIMARY KEY,\n  member_id     TEXT NOT NULL,\n  item_type     TEXT NOT NULL,\n  name          TEXT,\n  pre_parent_id TEXT,\n  is_public     BOOLEAN DEFAULT false,\n  deleted_at    TIMESTAMPTZ,\n  usn           BIGINT DEFAULT 0,\n\n  -- ═══ 一個 JSONB 裝所有動態欄位 ═══\n  fields        JSONB NOT NULL DEFAULT '{}'\n);\n```\n\n為什麼用 JSONB 而非 EAV？EAV 查一個 item 要 JOIN N 次，JSONB 一次 SELECT 全拿，且支援 GIN index。\n\n### Item Server 寫入時的欄位分離\n\n```\npayload:\n{ itemType: \"MOVE_SHOPPING_ITEM\", budgetAmount: 20000, hackedField: \"惡意\" }\n         │\n         ▼\nItem Server:\n  ① 查 schema MOVE_SHOPPING_ITEM.fields 白名單\n  ② 交叉比對 → hackedField 不在白名單 → 丟掉 🔒\n  ③ INSERT INTO items (id, item_type, name, fields)\n     fields = '{\"budgetAmount\":20000}'\n```\n\n* * *\n\n## 二、Schema 註冊：TypeScript class 即 schema\n\n```typescript\n@itemType('MOVE_SHOPPING_ITEM', '搬遷購物項目')\nexport class MoveShoppingItem extends BaseItem {\n    category?: string = '日用品';\n    budgetAmount?: number = 0;\n}\n```\n\n一個 class 同時定義三件事：前端 TS 型別、Item Server 驗證白名單、前端表單元件（string→text input，boolean→toggle，number→數字輸入）。\n\n`plugin_publish` 時從 class 反射出 schema JSON → `INSERT INTO item_type_schemas` → S3 mirror + runtime invalidate。\n\n* * *\n\n## 三、前端動態渲染\n\nHost 框架模式：plugin 只宣告（`registerSection`、`registerFolderType`、`children`），host 負責實際渲染 sidebar、列表、表單。卡片模板系統用 `cardFieldsDef` + `templateHtml`（`{{欄位名}}` 變數）+ `templateCss`（主題變數 `var(--xxx)`）。\n\n* * *\n\n## 四、完整流程\n\n```\n① AI 寫 plugin code → schemas + Plugin.tsx + View.tsx\n② plugin_publish → bundle + validator + schema 註冊\n③ session 啟動 → .schemas/ 自動出現 + plugin root 可用\n④ vault_create_items → Item Server 驗證 → PG\n⑤ App 渲染 → 載入 bundle → sidebar + 列表 + 自訂 view\n```\n\n* * *\n\n## 五、如何確保動態資料正確與程式安全載入：五層安全鏈\n\n\u003ctable style=\"min-width: 75px;\"\u003e\u003ccolgroup\u003e\u003ccol style=\"min-width: 25px;\"\u003e\u003ccol style=\"min-width: 25px;\"\u003e\u003ccol style=\"min-width: 25px;\"\u003e\u003c/colgroup\u003e\u003ctbody\u003e\u003ctr\u003e\u003cth colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e層級\u003c/p\u003e\u003c/th\u003e\u003cth colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e防護內容\u003c/p\u003e\u003c/th\u003e\u003cth colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e失敗後果\u003c/p\u003e\u003c/th\u003e\u003c/tr\u003e\u003ctr\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e第 0 層：tsc\u003c/p\u003e\u003c/td\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e型別正確、模組存在\u003c/p\u003e\u003c/td\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003ebundle 產不出來\u003c/p\u003e\u003c/td\u003e\u003c/tr\u003e\u003ctr\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e第 1 層：esbuild\u003c/p\u003e\u003c/td\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003eimport 解析、語法\u003c/p\u003e\u003c/td\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e編譯失敗\u003c/p\u003e\u003c/td\u003e\u003c/tr\u003e\u003ctr\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e第 2 層：\u003ca target=\"_blank\" rel=\"noopener noreferrer\" class=\"editor-link\" href=\"http://validator.sh\"\u003evalidator.sh\u003c/a\u003e\u003c/p\u003e\u003c/td\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003eeval()、icon 合法、MarkdownEditor、CRUD\u003c/p\u003e\u003c/td\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003ecommit 不給過\u003c/p\u003e\u003c/td\u003e\u003c/tr\u003e\u003ctr\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e第 3 層：plugin_publish\u003c/p\u003e\u003c/td\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e伺服器端再驗證 + schema 註冊\u003c/p\u003e\u003c/td\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e不回寫 DB\u003c/p\u003e\u003c/td\u003e\u003c/tr\u003e\u003ctr\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e第 4 層：Item Server\u003c/p\u003e\u003c/td\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e寫入時欄位白名單 + 型別驗證\u003c/p\u003e\u003c/td\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e惡意欄位丟棄\u003c/p\u003e\u003c/td\u003e\u003c/tr\u003e\u003ctr\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e第 5 層：Frontend Runtime\u003c/p\u003e\u003c/td\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003egeneric 過濾 + 預設值 + ErrorBoundary + bwrap + 0555\u003c/p\u003e\u003c/td\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e單一 plugin 崩潰不影響 host\u003c/p\u003e\u003c/td\u003e\u003c/tr\u003e\u003c/tbody\u003e\u003c/table\u003e\n\n每一層的失敗都是「有明確錯誤訊息、可修復、可重試」的，不會出現靜默失敗。\n\n### Schema 演進相容性\n\nJSONB 的核心優勢：新增欄位有預設值撐著（`budgetAmount?: number = 0`），刪除欄位有多餘資料無害（留在 JSONB 裡但前端 class 不讀）。不需要 `ALTER TABLE`、不需要 migration script。\n\n* * *\n\n* * *\n\n## 六、為什麼可以動態載入 TypeScript 插件？\n\n這是最容易被誤解的一環：**瀏覽器從來不載入** `.ts` **檔**。整個鏈路是：\n\n```\nTypeScript 原始碼 (.tsx)\n       │\n       ▼  esbuild 編譯\n單一 self-contained JavaScript bundle (bundle.js)\n       │\n       ▼  plugin_publish → S3\nHost App 在執行期 fetch → 注入 DOM → 插件註冊 → onload()\n```\n\n### 6a. 為什麼 TS 不能直接在瀏覽器跑？\n\n瀏覽器只認 ECMAScript 標準語法。TypeScript 的型別標註、裝飾器（`@plugin()`）、JSX、路徑別名（`@cubelv/sdk`）瀏覽器全看不懂。\n\n### 6b. esbuild 編譯\n\n```bash\nnode /app/config/esbuild-plugin-bundle.mjs \\\n  plugins/搬遷管理/frontend/MovePlugin.tsx \\\n  plugins/搬遷管理/frontend/bundle.js\n```\n\ntsconfig.json 關鍵設定：`target: es2022`、`jsx: react-jsx`、`experimentalDecorators: true`、`noEmit: true`（tsc 只做型別檢查，esbuild 負責產出）。\n\n### 6c. bundle.js 內部結構\n\n```javascript\n/* cubelv-bundle source-sha:138ffe637beda193 plugin:搬遷管理 */\nvar __plugin__ = (() =\u003e {\n  // ① 輔助函式\n  // ② CSS 內嵌注入（\u003cstyle data-plugin-css=\"...\"\u003e）\n  // ③ Plugin class 與所有依賴（已 tree-shaking）\n  // ④ 模組匯出：var ce = {}; ae(ce, { MovePlugin: () =\u003e k });\n  // ⑤ registerPlugin(\"搬遷管理\", ce.MovePlugin);\n})();\n```\n\n五個部分：banner（版本驗證）→ CSS 內嵌（無額外請求）→ 業務邏輯 → 模組匯出（不汙染全域）→ registerPlugin（向 host 註冊）。\n\n### 6d. Host 載入流程\n\n```\nApp 啟動 → 讀 PLUGIN 清單 → 對每個啟用 plugin：\n  fetch bundle.js (from S3/cache) → 驗證 banner\n  → bwrap sandbox 執行 IIFE → CSS 注入 DOM → registerPlugin()\n→ Host 讀 registry → new Plugin() → onload()\n  → registerSection / registerFolderType → 渲染 UI\n```\n\n### 6e. CSS 為何內嵌？\n\n一個 bundle = 一個完整插件，下載一次全到位。`data-plugin-css` 屬性讓 host 能精準移除/更新樣式。\n\n### 6f. tsconfig 設計意圖\n\n`@cubelv/sdk` 只有 `.d.ts` 型別宣告無實作 — 跟 webpack externals 同概念。SDK 是 host 提供的平台 API，plugin 只帶自己的業務邏輯。\n\n### 6g. 為什麼能「動態」載入？\n\n五個前提：self-contained IIFE、間接註冊機制、CSS 作用域隔離、bwrap sandbox、資料程式分離。\n\n* * *\n\n* * *\n\n## 七、原始碼儲存架構：S3 / Vault Mirror / Git 三層\n\nplugin 的 `.ts` / `.tsx` 原始碼同時存在三個地方，各自扮演不同角色：\n\n```\n┌─────────────────────────────────────────────────┐\n│ 📁 vault/plugins/{插件名}/   ← AI 鍛造工作區       │\n│                                                 │\n│  frontend/                                      │\n│  ├── Plugin.tsx / View.tsx / *.css              │\n│  ├── bundle.js          ← 編譯產物               │\n│  ├── schemas/           ← itemType 定義          │\n│  └── stores/            ← config accessor        │\n│  tsconfig.json                                  │\n│  .git/                  ← 本地版本控制            │\n│                                                 │\n│  角色：forge 時讀寫，完成後唯讀鏡像（從 S3 同步）  │\n└─────────────────────────────────────────────────┘\n\n┌─────────────────────────────────────────────────┐\n│ ☁️ S3（AWS ap-northeast-1）                      │\n│                                                 │\n│  bundle.js  ← Host App 執行期 fetch              │\n│  *.ts / *.tsx / *.css  ← plugin_publish 上傳     │\n│                                                 │\n│  角色：正本來源（canonical source of truth）       │\n│  只存最新版本，不存 Git 歷史                       │\n└─────────────────────────────────────────────────┘\n\n┌─────────────────────────────────────────────────┐\n│ 🗄️ PostgreSQL（item_type_schemas table）         │\n│                                                 │\n│  從 @itemType class 反射的 schema JSON：          │\n│  { \"itemType\": \"MOVE_SHOPPING_ITEM\",            │\n│    \"fields\": { \"budgetAmount\": {\"type\":\"number\"} │\n│              , \"category\": {\"type\":\"string\"} } } │\n│                                                 │\n│  角色：Item Server 驗證白名單 + 前端表單生成依據    │\n└─────────────────────────────────────────────────┘\n```\n\n### 7a. Git 是純本地開發工具\n\n每個插件底下有獨立的 `.git/`，但**沒有 remote**（不連 GitHub、不 push）：\n\n```bash\n$ git -C plugins/搬遷管理/.git remote -v\n（無輸出 — 沒有 remote）\n\n$ git -C plugins/搬遷管理/.git config --list\nuser.name=文鴻 黃-6a3ea4880213c1c27aa965\nuser.email=6a3ea4880213c1c27aa965@cubelv.com\n```\n\nGit 在這裡是**鍛造流程的品質閘門**：validator 過了才 commit，commit 了才能 publish。\n\n### 7b. Git 歷史不上 S3\n\n```\nplugin_publish 上傳到 S3：\n  ✅ bundle.js / *.ts / *.tsx / *.css / tsconfig.json\n  ❌ .git/（整個目錄不上傳）\n\n結果：\n  S3 上只有最新版本的「檔案內容」\n  本地 Git 保留所有 commit 歷史（但 session 重啟後 mirror 從 S3 同步，歷史會消失）\n```\n\nS3 的角色是「現在線上的版本是什麼」，Git 的角色是「開發過程中改了什麼」。兩者分工明確。\n\n### 7c. 跨 session 的行為\n\n```\nSession A（鍛造 + 多次修改）：\n  commit v1 → commit v2 → commit v3 → plugin_publish\n  → S3 收到 v3 的檔案\n  → 本地 Git 保留 v1, v2, v3 歷史\n\nSession B（新 session，從 S3 重新同步）：\n  vault/plugins/搬遷管理/\n    ├── frontend/*.tsx   ← 從 S3 拉回（v3 內容）\n    ├── bundle.js        ← 從 S3 拉回\n    └── .git/            ← 空的（歷史消失）\n```\n\nGit 歷史是 session-local 的暫存開發紀錄，S3 是跨 session 的永久正本。\n\n* * *\n\n* * *\n\n## 八、鍛造環境工具鏈：Container 預裝 CLI\n\n鍛造 plugin 的 CLI Worker Sandbox **出廠自帶所有必要工具**，不需要 `npm install`：\n\n```\nContainer 預裝工具：\n\n  /usr/bin/git                                    → git 2.30.2\n  /usr/local/bin/node                             → node v20.18.1\n  /usr/local/bin/esbuild                          → esbuild 0.28.1\n  /app/config/node_modules/typescript/bin/tsc     → tsc 6.0.3\n\n  鍛造腳本：\n  /app/config/esbuild-plugin-bundle.mjs            → 打包腳本\n  /app/config/plugin-validator.sh                  → 安全檢查\n```\n\n### 各工具在流程中的角色\n\n```\nedit 程式碼           → AI 用 Edit 工具直接寫（不需 CLI）\n    │\n    ▼\ntsc（型別檢查）       → /app/config/.../tsc\n    │                   tsconfig.json: \"noEmit\": true\n    │                   ← 只報型別錯誤，不產出 .js\n    ▼\nesbuild（編譯打包）   → node /app/config/esbuild-plugin-bundle.mjs\n    │                   所有 .tsx → 單一 bundle.js\n    ▼\nvalidator.sh         → bash /app/config/plugin-validator.sh\n    │                   檢查 eval()、icon、MarkdownEditor、CRUD\n    ▼\ngit commit           → git add -A \u0026\u0026 git commit\n    │                   純本地，無 remote\n    ▼\nplugin_publish       → MCP tool（server 端處理）\n                       S3 上傳 + PG schema 註冊 + runtime invalidate\n```\n\n### 為什麼 tsc 和 esbuild 都要？\n\n\u003ctable style=\"min-width: 75px;\"\u003e\u003ccolgroup\u003e\u003ccol style=\"min-width: 25px;\"\u003e\u003ccol style=\"min-width: 25px;\"\u003e\u003ccol style=\"min-width: 25px;\"\u003e\u003c/colgroup\u003e\u003ctbody\u003e\u003ctr\u003e\u003cth colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e​\u003c/p\u003e\u003c/th\u003e\u003cth colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003etsc\u003c/p\u003e\u003c/th\u003e\u003cth colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003eesbuild\u003c/p\u003e\u003c/th\u003e\u003c/tr\u003e\u003ctr\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e做什麼\u003c/p\u003e\u003c/td\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e型別檢查\u003c/p\u003e\u003c/td\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e編譯 + 打包\u003c/p\u003e\u003c/td\u003e\u003c/tr\u003e\u003ctr\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e產出\u003c/p\u003e\u003c/td\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e無（\u003ccode\u003enoEmit: true\u003c/code\u003e）\u003c/p\u003e\u003c/td\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003ebundle.js\u003c/p\u003e\u003c/td\u003e\u003c/tr\u003e\u003ctr\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e速度\u003c/p\u003e\u003c/td\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e慢（完整型別推導）\u003c/p\u003e\u003c/td\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e極快（Go 寫的）\u003c/p\u003e\u003c/td\u003e\u003c/tr\u003e\u003ctr\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e失敗時\u003c/p\u003e\u003c/td\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e型別錯誤 → 停在這裡\u003c/p\u003e\u003c/td\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003eimport 解析失敗\u003c/p\u003e\u003c/td\u003e\u003c/tr\u003e\u003c/tbody\u003e\u003c/table\u003e\n\n兩者分工：tsc 確保程式碼「語意正確」，esbuild 確保「能編譯成 JS」。\n\n* * *\n\n* * *\n\n## 九、Plugin Code 與 Schema 的 Harness：兩條註冊線如何接在一起\n\n這是整個動態插件系統最核心的整合層：**bundle.js 裡的 class 和 PG 裡的 schema JSON，在執行期是怎麼對齊的。**\n\n### 9a. 從 bundle.js 原始碼看 @itemType decorator 的真相\n\n`@itemType` 編譯成 JavaScript 後長這樣（來自搬遷管理 bundle.js）：\n\n```javascript\n// MoveShoppingItem class（原始碼已被 esbuild 壓縮）\nvar N = class extends S.BaseItem {\n  constructor() {\n    super();\n    this.category = \"日用品\";       // ← 預設值變成 constructor 賦值\n    this.budgetAmount = 0;\n    this.priority = \"一般\";\n    this.isPurchased = false;\n    this.dueDate = \"\";\n    this.notes = \"\";\n  }\n};\n\n// ← 這是 @itemType decorator 的編譯結果：\nN = w([(0, D.itemType)(\"MOVE_SHOPPING_ITEM\", \"搬遷購物項目\")], N);\n//      └─────────────┬─────────────┘  └──────┬──────┘\n//           itemType 字串名稱              顯示名稱\n```\n\n這行 decorator 在 **class 定義的瞬間立即執行**，做兩件事：\n\n1.  `N.__itemType__ = 'MOVE_SHOPPING_ITEM'` — 把 itemType 字串寫在 class 靜態屬性上\n    \n2.  `globalItemTypeRegistry['MOVE_SHOPPING_ITEM'] = N` — 把 class 註冊到全域對照表\n    \n\n### 9b. 兩條平行註冊線，靠 itemType 字串橋接\n\n```\n═══════════════════════════════════════════════════════════\n線 A：執行期 class 註冊（bundle.js 載入的瞬間）\n═══════════════════════════════════════════════════════════\n\nbundle.js 載入 → IIFE 執行 → class 定義 → @itemType 觸發\n                                           │\n                          ┌────────────────┘\n                          ▼\n              globalItemTypeRegistry = {\n                'MOVE_SHOPPING_ITEM':       class MoveShoppingItem,\n                'MOVE_SHOPPING_FOLDER':     class MoveShoppingFolder,\n                'MOVE_BUDGET_SECTION_CONFIG': class MoveBudgetSectionConfig,\n                ...\n              }\n                          │\n                          ▼\n              onload() 執行時善用這個 registry：\n\n                registerConfig(MoveBudgetSectionConfig)\n                  → host 讀 class.__itemType__\n                  → 知道它對應 'MOVE_BUDGET_SECTION_CONFIG'\n                  → 用這個字串去 store 建立/查詢 singleton\n\n                registerFolderType(MoveShoppingFolder, {\n                  children: [{ class: MoveShoppingItem, ... }]\n                })\n                  → host 讀 class.__itemType__\n                  → 知道 children 的 itemType 是 'MOVE_SHOPPING_ITEM'\n                  → 渲染列表時用這個字串查 store\n\n\n═══════════════════════════════════════════════════════════\n線 B：PG schema 註冊（plugin_publish 時）\n═══════════════════════════════════════════════════════════\n\nplugin_publish → 從 class 反射出 schema JSON → PG:\n\n  item_type_schemas table:\n  ┌──────────────────────────┬────────────────────────┐\n  │ item_type                │ fields (JSONB)         │\n  ├──────────────────────────┼────────────────────────┤\n  │ MOVE_SHOPPING_ITEM       │ { category: string,    │\n  │                          │   budgetAmount: number │\n  │                          │   ... }                │\n  │ MOVE_SHOPPING_FOLDER     │ {}                     │\n  │ MOVE_BUDGET_SECTION_...  │ { totalBudget: number  │\n  │                          │   ... }                │\n  └──────────────────────────┴────────────────────────┘\n                          │\n                          ▼\n              用途：\n                • Item Server 寫入驗證（白名單比對）\n                • Host 自動生成表單（string→input, number→數字...）\n                • Host 知道這個 itemType 有哪些欄位\n\n\n═══════════════════════════════════════════════════════════\n橋接點：'MOVE_SHOPPING_ITEM' 這個字串\n═══════════════════════════════════════════════════════════\n\n  同一個字串同時存在於兩個完全獨立的系統：\n\n    線 A（執行期）：class.__itemType__ = 'MOVE_SHOPPING_ITEM'\n                   → 讓 host 知道「這個 class 就是這個 itemType」\n\n    線 B（PG）：item_type_schemas['MOVE_SHOPPING_ITEM']\n               → 讓 Item Server 知道「這個 itemType 有哪些合法欄位」\n\n    Store 查詢：useItemsByType('MOVE_SHOPPING_ITEM', folderId)\n               → 從 store 拿資料 → 對照 registry 找到 class → 型別匹配\n\n    Item Server：vault_create_items({ itemType: 'MOVE_SHOPPING_ITEM', ... })\n                → 查 PG schema → 欄位白名單驗證 → 寫入\n```\n\n### 9c. 完整 harness 時序\n\n```\nbundle.js 載入\n    │\n    ├→ ① @itemType decorator 觸發（每個 class 定義時）\n    │     MoveShoppingItem.__itemType__ = 'MOVE_SHOPPING_ITEM'\n    │     itemTypeRegistry['MOVE_SHOPPING_ITEM'] = MoveShoppingItem\n    │\n    ├→ ② registerPlugin 觸發\n    │     pluginRegistry['搬遷管理'] = MovePlugin\n    │\n    └→ ③ Host 呼叫 plugin.onload()\n          │\n          ├→ registerConfig(MoveBudgetSectionConfig)\n          │     → host 讀 class.__itemType__\n          │     → 用字串在 store 建立/查詢 singleton\n          │     → singleton 的欄位結構來自 PG schema（線 B）\n          │\n          ├→ registerFolderType(MoveShoppingFolder, { ... })\n          │     → host 讀 class.__itemType__\n          │     → 知道 folder 的 itemType 是 'MOVE_SHOPPING_FOLDER'\n          │\n          └→ children: [{ class: MoveShoppingItem, ... }]\n                → host 讀 class.__itemType__\n                → 知道子項目的 itemType 是 'MOVE_SHOPPING_ITEM'\n                → 渲染列表：useItemsByType('MOVE_SHOPPING_ITEM', folderId)\n                → 點擊開表單：host 查 PG schema → 自動生成 input\n                → 寫入：Item Server 查 PG schema → 白名單驗證\n```\n\n### 9d. 對比：有 plugin code vs 純卡片模板\n\n\u003ctable style=\"min-width: 75px;\"\u003e\u003ccolgroup\u003e\u003ccol style=\"min-width: 25px;\"\u003e\u003ccol style=\"min-width: 25px;\"\u003e\u003ccol style=\"min-width: 25px;\"\u003e\u003c/colgroup\u003e\u003ctbody\u003e\u003ctr\u003e\u003cth colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e​\u003c/p\u003e\u003c/th\u003e\u003cth colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e插件型（搬遷管理）\u003c/p\u003e\u003c/th\u003e\u003cth colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e卡片模板（台北咖啡廳）\u003c/p\u003e\u003c/th\u003e\u003c/tr\u003e\u003ctr\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e\u003cstrong\u003eclass 存在嗎？\u003c/strong\u003e\u003c/p\u003e\u003c/td\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e✅ bundle.js 裡有 class\u003c/p\u003e\u003c/td\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e❌ 沒有 plugin code\u003c/p\u003e\u003c/td\u003e\u003c/tr\u003e\u003ctr\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e\u003cstrong\u003eitemType 誰定義的？\u003c/strong\u003e\u003c/p\u003e\u003c/td\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e\u003ccode\u003e@itemType\u003c/code\u003e decorator 在 class 上\u003c/p\u003e\u003c/td\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e系統內建 \u003ccode\u003eCARD\u003c/code\u003e + \u003ccode\u003eCARD_FOLDER\u003c/code\u003e\u003c/p\u003e\u003c/td\u003e\u003c/tr\u003e\u003ctr\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e\u003cstrong\u003eschema 誰定義的？\u003c/strong\u003e\u003c/p\u003e\u003c/td\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003eclass 屬性 → publish 反射 → PG\u003c/p\u003e\u003c/td\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e\u003ccode\u003ecardFieldsDef\u003c/code\u003e JSON → 直接寫在 CARD_FOLDER 的 fields 裡\u003c/p\u003e\u003c/td\u003e\u003c/tr\u003e\u003ctr\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e\u003cstrong\u003e表單誰生成的？\u003c/strong\u003e\u003c/p\u003e\u003c/td\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003ehost 讀 PG schema\u003c/p\u003e\u003c/td\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003ehost 讀 cardFieldsDef\u003c/p\u003e\u003c/td\u003e\u003c/tr\u003e\u003ctr\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e\u003cstrong\u003e自訂 view？\u003c/strong\u003e\u003c/p\u003e\u003c/td\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e✅ plugin 的 \u003ccode\u003erenderComponent()\u003c/code\u003e\u003c/p\u003e\u003c/td\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e❌ 用宿主內建的卡片模板引擎\u003c/p\u003e\u003c/td\u003e\u003c/tr\u003e\u003ctr\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e\u003cstrong\u003e共用同一套 bridge？\u003c/strong\u003e\u003c/p\u003e\u003c/td\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e✅ 都是 itemType 字串橋接 store / PG\u003c/p\u003e\u003c/td\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e✅ 只是 cardFieldsDef 替代了 plugin schema\u003c/p\u003e\u003c/td\u003e\u003c/tr\u003e\u003c/tbody\u003e\u003c/table\u003e\n\n### 9e. 為什麼兩條線必須分離？\n\n如果 class 和 schema 是同一件事（例如 schema 直接寫在 class 的靜態屬性上），會有兩個致命問題：\n\n1.  **Item Server 無法驗證** — Item Server 是 Node.js 後端，它不載入 bundle.js（那是瀏覽器的事）。PG 裡的 schema JSON 是它唯一能讀到的欄位定義。\n    \n2.  **不載入 bundle 就不知道結構** — 如果 schema 只存在於 class 上，那任何不跑 bundle.js 的環境（後端驗證、CLI、其他 microservice、AI agent 的 MCP tool）都無法知道 itemType 有哪些合法欄位。\n    \n\n所以**必須分兩條線**：bundle.js 負責執行期的 class 身份（線 A），PG 負責跨服務的 schema 定義（線 B）。`@itemType('MOVE_SHOPPING_ITEM')` 裡的那個字串就是兩條線之間的鉸鏈。\n\n\u003e **一句話：**`@itemType('MOVE_SHOPPING_ITEM')` **的 decorator 是 harness 的鉸鏈 — bundle.js 載入時把 class 註冊到執行期 registry（線 A），plugin_publish 時把 schema 寫入 PG（線 B），之後所有操作（查 store、驗證寫入、生成表單）都靠** `'MOVE_SHOPPING_ITEM'` **這個字串在兩條線之間對齊。**\n\n* * *\n\n* * *\n\n# 附錄：插件撰寫實戰案例\n\n以下三個案例來自 CubeLV 實際運行的插件，展示從 schema 定義到 UI 渲染的完整撰寫模式。\n\n* * *\n\n## 案例一：搬遷管理插件（真實資料夾 + 完整 CRUD + 預算追蹤）\n\n### 檔案結構\n\n```\nplugins/搬遷管理/frontend/\n├── MovePlugin.tsx          ← 插件入口\n├── MoveView.tsx            ← 主畫面\n├── move.css                ← 樣式\n├── schemas/\n│   ├── moveSchema.ts               ← itemType 定義\n│   └── moveBudgetSectionConfigSchema.ts\n└── stores/\n    └── moveBudgetSectionConfigStore.ts\n```\n\n### Schema 定義\n\n```typescript\n@itemType('MOVE_SHOPPING_FOLDER', '搬遷購物清單資料夾',\n  { defaultFolder: '我的購物清單' })  // ← 自動建預設資料夾\nexport class MoveShoppingFolder extends BaseItem {}\n\n@itemType('MOVE_SHOPPING_ITEM', '搬遷購物項目')\nexport class MoveShoppingItem extends BaseItem {\n    category?: string = '日用品';\n    budgetAmount?: number = 0;\n    actualAmount?: number = 0;\n    priority?: string = '一般';\n    isPurchased?: boolean = false;\n    dueDate?: string = '';\n    notes?: string = '';\n}\n\n// Singleton config（全 plugin 一份）\n@itemType('MOVE_BUDGET_SECTION_CONFIG', '搬遷預算設定')\nexport class MoveBudgetSectionConfig extends BaseItem {\n    totalBudget?: number = 0;\n    furnitureBudget?: number = 0;\n    applianceBudget?: number = 0;\n    dailyBudget?: number = 0;\n    renovationBudget?: number = 0;\n    otherBudget?: number = 0;\n}\n```\n\n### Config Store（三行程式碼）\n\n```typescript\nconst accessor = createSingletonAccessor(MoveBudgetSectionConfig);\nexport const useMoveBudgetSectionConfig = accessor.use;\nexport const updateMoveBudgetSectionConfig = accessor.update;\n```\n\nSDK 自動處理：查詢/建立 singleton、React 響應式訂閱、寫入自動 sync。\n\n### Plugin 註冊（onload 五步模板）\n\n```typescript\n@plugin('搬遷管理', { dependencies: ['Sidebar'] })\nexport class MovePlugin extends Plugin {\n    async onload() {\n        this.registerPluginRoot('moveManagement');          // ① root\n        this.registerConfig(MoveBudgetSectionConfig);       // ② config\n        this.registerSection({                              // ③ sidebar\n            id: 'moveManagement', title: '搬遷管理',\n            orderAt: 550, useData: useMoveSidebarData,\n        });\n        this.registerFolderType(MoveShoppingFolder, {       // ④ folder\n            folderIcon: 'package',\n            views: [{ type: 'move-dashboard',\n                      creator: (leaf) =\u003e new MoveDashboardView(leaf) }],\n            children: [{                                     // ⑤ children\n                class: MoveShoppingItem, icon: 'shopping-bag',\n                displayText: (item) =\u003e item.name,\n            }],\n        });\n    }\n}\n```\n\n### View 中的五種 SDK 模式\n\n```typescript\n// 模式 A：資料讀取\nconst folderId = useSelectedItemsStore(s =\u003e s.lastFolderId);\nconst items = useItemsByType\u003cMoveShoppingItem\u003e('MOVE_SHOPPING_ITEM', folderId);\nconst budgetConfig = useMoveBudgetSectionConfig();\n\n// 模式 B：完整新增\nawait useItemStore.getState().upsertItem({\n    id: generateObjectID(),\n    itemType: 'MOVE_SHOPPING_ITEM',\n    name, parents: { [folderId]: Date.now() },\n    category, budgetAmount, priority, isPurchased: false,\n}, { needSync: true });\n\n// 模式 C：部分更新（要送完整欄位！）\nawait useItemStore.getState().upsertItem({\n    id, itemType, name, parents,\n    category, budgetAmount, actualAmount, priority,\n    isPurchased: !isPurchased,  // 只翻轉一個 bool，但整包都要送\n    dueDate, notes,\n}, { needSync: true });\n\n// 模式 D：刪除\nawait useItemStore.getState().removeItems([id], { needSync: true });\n\n// 模式 E：PluginTopbar + FAB\n\u003cPluginTopbar rightButtons={[\n    { icon: 'plus', onClick: () =\u003e setAdding(true) },\n    { icon: 'settings', onClick: () =\u003e setBudgetOpen(true), priority: 'menu' },\n]} /\u003e\n\u003cFloatingActionButton id=\"move-add-fab\" icon=\"plus\" onClick={...} /\u003e\n```\n\n* * *\n\n## 案例二：知識回顧面板（Virtual Folder + 跨 itemType 查詢）\n\n### Virtual Folder vs 真實 Folder\n\n\u003ctable style=\"min-width: 75px;\"\u003e\u003ccolgroup\u003e\u003ccol style=\"min-width: 25px;\"\u003e\u003ccol style=\"min-width: 25px;\"\u003e\u003ccol style=\"min-width: 25px;\"\u003e\u003c/colgroup\u003e\u003ctbody\u003e\u003ctr\u003e\u003cth colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e​\u003c/p\u003e\u003c/th\u003e\u003cth colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e真實 Folder（搬遷管理）\u003c/p\u003e\u003c/th\u003e\u003cth colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003eVirtual Folder（知識回顧）\u003c/p\u003e\u003c/th\u003e\u003c/tr\u003e\u003ctr\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e掛載方式\u003c/p\u003e\u003c/td\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e\u003ccode\u003eregisterFolderType\u003c/code\u003e\u003c/p\u003e\u003c/td\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e\u003ccode\u003eregisterVirtualFolderType\u003c/code\u003e\u003c/p\u003e\u003c/td\u003e\u003c/tr\u003e\u003ctr\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e有真實 item？\u003c/p\u003e\u003c/td\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e是，使用者可建 item\u003c/p\u003e\u003c/td\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e否，只是一個 UI 入口\u003c/p\u003e\u003c/td\u003e\u003c/tr\u003e\u003ctr\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003esidebar 清單\u003c/p\u003e\u003c/td\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e列出所有 folder\u003c/p\u003e\u003c/td\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e只有一個虛擬入口\u003c/p\u003e\u003c/td\u003e\u003c/tr\u003e\u003ctr\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003echildren\u003c/p\u003e\u003c/td\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e有宣告子 itemType\u003c/p\u003e\u003c/td\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e無（不裝子 item）\u003c/p\u003e\u003c/td\u003e\u003c/tr\u003e\u003c/tbody\u003e\u003c/table\u003e\n\n### Schema\n\n```typescript\n// Virtual folder：沒有任何自訂欄位\n@itemType('KNOWLEDGE_REVIEW_VIRTUAL_FOLDER', '知識回顧面板入口')\nexport class KnowledgeReviewVirtualFolder extends BaseItem {}\n\n// Config 包含巢狀結構\ninterface ReviewRecord {\n    lastReviewedAt: number;\n    interval: number;       // 1→3→7→30→90\n    nextReviewAt: number;\n    reviewCount: number;\n}\n\n@itemType('KNOWLEDGE_REVIEW_SECTION_CONFIG', '知識回顧面板設定')\nexport class KnowledgeReviewPanelSectionConfig extends BaseItem {\n    reviewRecords?: Record\u003cstring, ReviewRecord\u003e = {};\n    lastInspirationPushAt?: number = 0;\n    dailyReviewLimit?: number = 10;\n}\n```\n\n### Plugin 註冊\n\n```typescript\n@plugin('知識回顧面板', { dependencies: ['Sidebar'] })\nexport class KnowledgeReviewPanelPlugin extends Plugin {\n    onload() {\n        this.registerPluginRoot('knowledgeReview');\n        this.registerSection({\n            id: 'knowledgeReview', title: '知識回顧',\n            orderAt: 350,\n            useData: () =\u003e ({ items: [] }),  // ← sidebar 不顯示清單\n        });\n        this.registerVirtualFolderType(KnowledgeReviewVirtualFolder, {\n            icon: 'brain',\n            useDisplayName: () =\u003e '知識回顧面板',\n            views: [{ type: 'knowledge-review-dashboard',\n                      creator: (leaf) =\u003e new KnowledgeReviewPanelLeafView(leaf) }],\n        });\n    }\n}\n```\n\n### 跨 itemType 查詢 + MarkdownEditor 唯讀渲染\n\n```typescript\n// 讀取所有 NOTE（跨 itemType）\nconst allNotes = useItemStore(\n    useCallback((s) =\u003e (s.byType?.['NOTE'] ?? []), [])\n);\n\n// 渲染筆記內容：只用 MarkdownEditor editable={false}\n\u003cMarkdownEditor content={note.content} editable={false} /\u003e\n```\n\n* * *\n\n## 案例三：卡片模板系統（台北咖啡廳）\n\n不需要 plugin code，透過 CARD_FOLDER 的定義達成。\n\n```json\n{\n  \"cardFieldsDef\": [\n    { \"name\": \"店名\",     \"type\": \"TEXT\"     },\n    { \"name\": \"照片\",     \"type\": \"IMAGE\"    },\n    { \"name\": \"風格\",     \"type\": \"SELECT\",\n      \"options\": [\"北歐\", \"工業\", \"日系\", \"復古\", \"極簡\"] },\n    { \"name\": \"招牌飲品\", \"type\": \"TEXT\"     },\n    { \"name\": \"評分\",     \"type\": \"RATING\"   },\n    { \"name\": \"地址\",     \"type\": \"LOCATION\" },\n    { \"name\": \"官網\",     \"type\": \"URL\"      }\n  ]\n}\n```\n\n模板語法：`{{欄位名}}` → host 自動取代為 `cardFields[\"欄位名\"]`。CSS 必須用 `var(--card)`、`var(--foreground)` 等主題變數確保深色模式。\n\n* * *\n\n## 三個案例模式總結\n\n\u003ctable style=\"min-width: 75px;\"\u003e\u003ccolgroup\u003e\u003ccol style=\"min-width: 25px;\"\u003e\u003ccol style=\"min-width: 25px;\"\u003e\u003ccol style=\"min-width: 25px;\"\u003e\u003c/colgroup\u003e\u003ctbody\u003e\u003ctr\u003e\u003cth colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e模式\u003c/p\u003e\u003c/th\u003e\u003cth colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e案例\u003c/p\u003e\u003c/th\u003e\u003cth colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e適用場景\u003c/p\u003e\u003c/th\u003e\u003c/tr\u003e\u003ctr\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e真實資料夾 + CRUD\u003c/p\u003e\u003c/td\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e搬遷管理\u003c/p\u003e\u003c/td\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e需要使用者建立/管理資料\u003c/p\u003e\u003c/td\u003e\u003c/tr\u003e\u003ctr\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003eVirtual Folder\u003c/p\u003e\u003c/td\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e知識回顧面板\u003c/p\u003e\u003c/td\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e純儀表板/分析面板\u003c/p\u003e\u003c/td\u003e\u003c/tr\u003e\u003ctr\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e卡片模板\u003c/p\u003e\u003c/td\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e台北咖啡廳\u003c/p\u003e\u003c/td\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e收藏結構化實體，每個 folder 不同視覺\u003c/p\u003e\u003c/td\u003e\u003c/tr\u003e\u003c/tbody\u003e\u003c/table\u003e\n\n### 通用撰寫順序\n\n```\n1. schemas/*.ts    → @itemType class\n2. stores/*.ts     → createSingletonAccessor（如有 config）\n3. Plugin.tsx       → onload() 五步註冊\n4. View.tsx         → renderComponent() React 元件\n5. *.css            → var(--xxx) 主題變數\n```\n\n### 常見踩坑\n\n\u003ctable style=\"min-width: 50px;\"\u003e\u003ccolgroup\u003e\u003ccol style=\"min-width: 25px;\"\u003e\u003ccol style=\"min-width: 25px;\"\u003e\u003c/colgroup\u003e\u003ctbody\u003e\u003ctr\u003e\u003cth colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e坑\u003c/p\u003e\u003c/th\u003e\u003cth colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e正確做法\u003c/p\u003e\u003c/th\u003e\u003c/tr\u003e\u003ctr\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003eicon 傳 JSX/字串\u003c/p\u003e\u003c/td\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e必須用 iconRegistry 合法名稱字串\u003c/p\u003e\u003c/td\u003e\u003c/tr\u003e\u003ctr\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e改 item 只送單一欄位\u003c/p\u003e\u003c/td\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e物件型欄位要整包送（DB 存 JSONB 字串）\u003c/p\u003e\u003c/td\u003e\u003c/tr\u003e\u003ctr\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003emarkdown 自刻渲染\u003c/p\u003e\u003c/td\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e一律 \u003ccode\u003e\u0026lt;MarkdownEditor editable={false}\u0026gt;\u003c/code\u003e\u003c/p\u003e\u003c/td\u003e\u003c/tr\u003e\u003ctr\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e跨 itemType 讀資料\u003c/p\u003e\u003c/td\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e\u003ccode\u003euseItemStore(s =\u0026gt; s.byType?.['NOTE'])\u003c/code\u003e\u003c/p\u003e\u003c/td\u003e\u003c/tr\u003e\u003ctr\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003eCSS 寫死顏色\u003c/p\u003e\u003c/td\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e用 \u003ccode\u003evar(--foreground)\u003c/code\u003e 等主題變數\u003c/p\u003e\u003c/td\u003e\u003c/tr\u003e\u003ctr\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003ecardFieldsDef 無 templateHtml\u003c/p\u003e\u003c/td\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e卡片只顯示純文字 key-value\u003c/p\u003e\u003c/td\u003e\u003c/tr\u003e\u003c/tbody\u003e\u003c/table\u003e\n\n​","createdAt":1782532476316,"deletedAt":null,"id":"ad62d0bf638e28487f955e98","isNew":false,"isPublic":true,"itemType":"NOTE","name":"CubeLV 動態 Plugin 與 Schema 實作機制","parents":{"68d768c0cf1ad24f7b8105ed":0},"preParentID":null,"updatedAt":1785390316822,"updatedBy":{"userId":"6a3ea4880213c1c27aa965","userName":"文鴻 黃"},"version":18,"websiteContent":"[{\"originUrl\":\"https://immian.com/\",\"title\":\"巷子內的生活，國內外美食旅遊隨筆\",\"content\":\"1. 旗艦性能 盡在掌中：Canon EOS R7 飛羽／生態／追焦 全面評測！\\n2. 動態攝影的新定義 Canon EOS R3 實拍評測\\n3. Sony RX100m7、6、5、4、3 差別在哪？寫給選擇困難症的 RX100 系列選購攻略\\n4. 寫下全幅無反的新歷史 Sony FE 400mm F2.8 GM 開箱/實拍/測試\\n5. 20 間夜貓咖啡廳懶人包，晚上 10 點後還在營業的深夜咖啡店整理\\n\\n\\n• 2024 年新版的貓砂比較文章又來哩！隨著養分分兩兄弟的時間又多了一年，今年使用過的貓砂品牌又更多了，所以當然是要記錄一…\\n• Tamron 50-400mm F4.5-6.3 F4.5-6.3 Di III VC VXD（A067） Openin…\\n• Tamron 20-40mm F2.8 Di III VXD（A062） Opening 這次分享的是這顆恆定光圈超廣角…\\n\",\"imageUrl\":\"https://immian.com/wp-content/uploads/2024/09/20240909171549_0_09c475-400x270.jpg\"},{\"originUrl\":\"https://fikafikacafe.com\",\"title\":\"Fika Fika Cafe\",\"content\":\"\\n• {{ 'fb_in_app_browser_popup.desc' | translate }}\\n          {{ 'fb_in_app_browser_popup.copy_link' | translate }}\\n• {{ 'in_app_browser_popup.desc' | translate }}\\n• {{word('consent_desc')}} {{word('read_more')}}\\n\",\"imageUrl\":\"https://img.shoplineapp.com/media/image_clips/5c8b169e6b9cd9003b92aeab/original.png?1552619166\"},{\"originUrl\":\"http://validator.sh\",\"title\":\"Validator.sh - Domain Name for Sale\",\"content\":\"1. Domain for Sale\\n2. © 2026 validator.shAll rights reserved.© 2026 validator.shAll rights reserved.validator.sh\\n3. Inquire About This Domain\\n4. Premium Domains For Sale\\n\\n\\n• Your project starts with the right domain. Make a smart investment by acquiring:\\n• Validator.sh is a concise, highly readable domain name built for tools that check, verify, and enforce rules across code, data, and systems. It fits validation engines, API testing platforms, schema checkers, CI pipelines, and security audi...\",\"imageUrl\":\"https://premiumdoms.io/site/assets/images/domain-logos/validator-sh-20260609_183752.png\"},{\"originUrl\":\"http://plugin-validator.sh\",\"title\":null,\"content\":\"抓取失敗：fetch failed\",\"imageUrl\":null}]"},"ownerName":"文鴻 黃","subtree":[{"aiFields":{"content":"📂 科技 | 🏷️ CubeLV、插件系統、Schema、JSONB、TypeScript、前端架構、plugin開發\n\n深入解析 CubeLV 的動態插件與 Schema 實作機制：以 PostgreSQL JSONB 儲存動態欄位、TypeScript 裝飾器 class 即 schema（一份程式碼同時定義型別、驗證白名單與表單元件）、Host 框架模式達成前端動態渲染（Sidebar/卡片模板/表單生成），並附搬遷管理、知識回顧面板、卡片模板三個完整插件案例的撰寫模式與常見踩坑提醒。"},"content":"\u003e 整理自 2026-06-27 與 Claude 的架構對話，接續「CubeLV 系統架構深度解析」\n\n* * *\n\n## 一、PostgreSQL：JSONB 存所有動態欄位\n\n核心問題：每個 plugin 的 itemType 欄位都不同，傳統 RDBMS 要 `ALTER TABLE` 才能加欄位。\n\n### 實際 table 結構\n\n```sql\nCREATE TABLE items (\n  -- ═══ 所有 itemType 共用的核心欄位（固定 column） ═══\n  id            TEXT PRIMARY KEY,\n  member_id     TEXT NOT NULL,\n  item_type     TEXT NOT NULL,\n  name          TEXT,\n  pre_parent_id TEXT,\n  is_public     BOOLEAN DEFAULT false,\n  deleted_at    TIMESTAMPTZ,\n  usn           BIGINT DEFAULT 0,\n\n  -- ═══ 一個 JSONB 裝所有動態欄位 ═══\n  fields        JSONB NOT NULL DEFAULT '{}'\n);\n```\n\n為什麼用 JSONB 而非 EAV？EAV 查一個 item 要 JOIN N 次，JSONB 一次 SELECT 全拿，且支援 GIN index。\n\n### Item Server 寫入時的欄位分離\n\n```\npayload:\n{ itemType: \"MOVE_SHOPPING_ITEM\", budgetAmount: 20000, hackedField: \"惡意\" }\n         │\n         ▼\nItem Server:\n  ① 查 schema MOVE_SHOPPING_ITEM.fields 白名單\n  ② 交叉比對 → hackedField 不在白名單 → 丟掉 🔒\n  ③ INSERT INTO items (id, item_type, name, fields)\n     fields = '{\"budgetAmount\":20000}'\n```\n\n* * *\n\n## 二、Schema 註冊：TypeScript class 即 schema\n\n```typescript\n@itemType('MOVE_SHOPPING_ITEM', '搬遷購物項目')\nexport class MoveShoppingItem extends BaseItem {\n    category?: string = '日用品';\n    budgetAmount?: number = 0;\n}\n```\n\n一個 class 同時定義三件事：前端 TS 型別、Item Server 驗證白名單、前端表單元件（string→text input，boolean→toggle，number→數字輸入）。\n\n`plugin_publish` 時從 class 反射出 schema JSON → `INSERT INTO item_type_schemas` → S3 mirror + runtime invalidate。\n\n* * *\n\n## 三、前端動態渲染\n\nHost 框架模式：plugin 只宣告（`registerSection`、`registerFolderType`、`children`），host 負責實際渲染 sidebar、列表、表單。卡片模板系統用 `cardFieldsDef` + `templateHtml`（`{{欄位名}}` 變數）+ `templateCss`（主題變數 `var(--xxx)`）。\n\n* * *\n\n## 四、完整流程\n\n```\n① AI 寫 plugin code → schemas + Plugin.tsx + View.tsx\n② plugin_publish → bundle + validator + schema 註冊\n③ session 啟動 → .schemas/ 自動出現 + plugin root 可用\n④ vault_create_items → Item Server 驗證 → PG\n⑤ App 渲染 → 載入 bundle → sidebar + 列表 + 自訂 view\n```\n\n* * *\n\n## 五、如何確保動態資料正確與程式安全載入：五層安全鏈\n\n\u003ctable style=\"min-width: 75px;\"\u003e\u003ccolgroup\u003e\u003ccol style=\"min-width: 25px;\"\u003e\u003ccol style=\"min-width: 25px;\"\u003e\u003ccol style=\"min-width: 25px;\"\u003e\u003c/colgroup\u003e\u003ctbody\u003e\u003ctr\u003e\u003cth colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e層級\u003c/p\u003e\u003c/th\u003e\u003cth colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e防護內容\u003c/p\u003e\u003c/th\u003e\u003cth colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e失敗後果\u003c/p\u003e\u003c/th\u003e\u003c/tr\u003e\u003ctr\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e第 0 層：tsc\u003c/p\u003e\u003c/td\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e型別正確、模組存在\u003c/p\u003e\u003c/td\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003ebundle 產不出來\u003c/p\u003e\u003c/td\u003e\u003c/tr\u003e\u003ctr\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e第 1 層：esbuild\u003c/p\u003e\u003c/td\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003eimport 解析、語法\u003c/p\u003e\u003c/td\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e編譯失敗\u003c/p\u003e\u003c/td\u003e\u003c/tr\u003e\u003ctr\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e第 2 層：\u003ca target=\"_blank\" rel=\"noopener noreferrer\" class=\"editor-link\" href=\"http://validator.sh\"\u003evalidator.sh\u003c/a\u003e\u003c/p\u003e\u003c/td\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003eeval()、icon 合法、MarkdownEditor、CRUD\u003c/p\u003e\u003c/td\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003ecommit 不給過\u003c/p\u003e\u003c/td\u003e\u003c/tr\u003e\u003ctr\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e第 3 層：plugin_publish\u003c/p\u003e\u003c/td\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e伺服器端再驗證 + schema 註冊\u003c/p\u003e\u003c/td\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e不回寫 DB\u003c/p\u003e\u003c/td\u003e\u003c/tr\u003e\u003ctr\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e第 4 層：Item Server\u003c/p\u003e\u003c/td\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e寫入時欄位白名單 + 型別驗證\u003c/p\u003e\u003c/td\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e惡意欄位丟棄\u003c/p\u003e\u003c/td\u003e\u003c/tr\u003e\u003ctr\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e第 5 層：Frontend Runtime\u003c/p\u003e\u003c/td\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003egeneric 過濾 + 預設值 + ErrorBoundary + bwrap + 0555\u003c/p\u003e\u003c/td\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e單一 plugin 崩潰不影響 host\u003c/p\u003e\u003c/td\u003e\u003c/tr\u003e\u003c/tbody\u003e\u003c/table\u003e\n\n每一層的失敗都是「有明確錯誤訊息、可修復、可重試」的，不會出現靜默失敗。\n\n### Schema 演進相容性\n\nJSONB 的核心優勢：新增欄位有預設值撐著（`budgetAmount?: number = 0`），刪除欄位有多餘資料無害（留在 JSONB 裡但前端 class 不讀）。不需要 `ALTER TABLE`、不需要 migration script。\n\n* * *\n\n* * *\n\n## 六、為什麼可以動態載入 TypeScript 插件？\n\n這是最容易被誤解的一環：**瀏覽器從來不載入** `.ts` **檔**。整個鏈路是：\n\n```\nTypeScript 原始碼 (.tsx)\n       │\n       ▼  esbuild 編譯\n單一 self-contained JavaScript bundle (bundle.js)\n       │\n       ▼  plugin_publish → S3\nHost App 在執行期 fetch → 注入 DOM → 插件註冊 → onload()\n```\n\n### 6a. 為什麼 TS 不能直接在瀏覽器跑？\n\n瀏覽器只認 ECMAScript 標準語法。TypeScript 的型別標註、裝飾器（`@plugin()`）、JSX、路徑別名（`@cubelv/sdk`）瀏覽器全看不懂。\n\n### 6b. esbuild 編譯\n\n```bash\nnode /app/config/esbuild-plugin-bundle.mjs \\\n  plugins/搬遷管理/frontend/MovePlugin.tsx \\\n  plugins/搬遷管理/frontend/bundle.js\n```\n\ntsconfig.json 關鍵設定：`target: es2022`、`jsx: react-jsx`、`experimentalDecorators: true`、`noEmit: true`（tsc 只做型別檢查，esbuild 負責產出）。\n\n### 6c. bundle.js 內部結構\n\n```javascript\n/* cubelv-bundle source-sha:138ffe637beda193 plugin:搬遷管理 */\nvar __plugin__ = (() =\u003e {\n  // ① 輔助函式\n  // ② CSS 內嵌注入（\u003cstyle data-plugin-css=\"...\"\u003e）\n  // ③ Plugin class 與所有依賴（已 tree-shaking）\n  // ④ 模組匯出：var ce = {}; ae(ce, { MovePlugin: () =\u003e k });\n  // ⑤ registerPlugin(\"搬遷管理\", ce.MovePlugin);\n})();\n```\n\n五個部分：banner（版本驗證）→ CSS 內嵌（無額外請求）→ 業務邏輯 → 模組匯出（不汙染全域）→ registerPlugin（向 host 註冊）。\n\n### 6d. Host 載入流程\n\n```\nApp 啟動 → 讀 PLUGIN 清單 → 對每個啟用 plugin：\n  fetch bundle.js (from S3/cache) → 驗證 banner\n  → bwrap sandbox 執行 IIFE → CSS 注入 DOM → registerPlugin()\n→ Host 讀 registry → new Plugin() → onload()\n  → registerSection / registerFolderType → 渲染 UI\n```\n\n### 6e. CSS 為何內嵌？\n\n一個 bundle = 一個完整插件，下載一次全到位。`data-plugin-css` 屬性讓 host 能精準移除/更新樣式。\n\n### 6f. tsconfig 設計意圖\n\n`@cubelv/sdk` 只有 `.d.ts` 型別宣告無實作 — 跟 webpack externals 同概念。SDK 是 host 提供的平台 API，plugin 只帶自己的業務邏輯。\n\n### 6g. 為什麼能「動態」載入？\n\n五個前提：self-contained IIFE、間接註冊機制、CSS 作用域隔離、bwrap sandbox、資料程式分離。\n\n* * *\n\n* * *\n\n## 七、原始碼儲存架構：S3 / Vault Mirror / Git 三層\n\nplugin 的 `.ts` / `.tsx` 原始碼同時存在三個地方，各自扮演不同角色：\n\n```\n┌─────────────────────────────────────────────────┐\n│ 📁 vault/plugins/{插件名}/   ← AI 鍛造工作區       │\n│                                                 │\n│  frontend/                                      │\n│  ├── Plugin.tsx / View.tsx / *.css              │\n│  ├── bundle.js          ← 編譯產物               │\n│  ├── schemas/           ← itemType 定義          │\n│  └── stores/            ← config accessor        │\n│  tsconfig.json                                  │\n│  .git/                  ← 本地版本控制            │\n│                                                 │\n│  角色：forge 時讀寫，完成後唯讀鏡像（從 S3 同步）  │\n└─────────────────────────────────────────────────┘\n\n┌─────────────────────────────────────────────────┐\n│ ☁️ S3（AWS ap-northeast-1）                      │\n│                                                 │\n│  bundle.js  ← Host App 執行期 fetch              │\n│  *.ts / *.tsx / *.css  ← plugin_publish 上傳     │\n│                                                 │\n│  角色：正本來源（canonical source of truth）       │\n│  只存最新版本，不存 Git 歷史                       │\n└─────────────────────────────────────────────────┘\n\n┌─────────────────────────────────────────────────┐\n│ 🗄️ PostgreSQL（item_type_schemas table）         │\n│                                                 │\n│  從 @itemType class 反射的 schema JSON：          │\n│  { \"itemType\": \"MOVE_SHOPPING_ITEM\",            │\n│    \"fields\": { \"budgetAmount\": {\"type\":\"number\"} │\n│              , \"category\": {\"type\":\"string\"} } } │\n│                                                 │\n│  角色：Item Server 驗證白名單 + 前端表單生成依據    │\n└─────────────────────────────────────────────────┘\n```\n\n### 7a. Git 是純本地開發工具\n\n每個插件底下有獨立的 `.git/`，但**沒有 remote**（不連 GitHub、不 push）：\n\n```bash\n$ git -C plugins/搬遷管理/.git remote -v\n（無輸出 — 沒有 remote）\n\n$ git -C plugins/搬遷管理/.git config --list\nuser.name=文鴻 黃-6a3ea4880213c1c27aa965\nuser.email=6a3ea4880213c1c27aa965@cubelv.com\n```\n\nGit 在這裡是**鍛造流程的品質閘門**：validator 過了才 commit，commit 了才能 publish。\n\n### 7b. Git 歷史不上 S3\n\n```\nplugin_publish 上傳到 S3：\n  ✅ bundle.js / *.ts / *.tsx / *.css / tsconfig.json\n  ❌ .git/（整個目錄不上傳）\n\n結果：\n  S3 上只有最新版本的「檔案內容」\n  本地 Git 保留所有 commit 歷史（但 session 重啟後 mirror 從 S3 同步，歷史會消失）\n```\n\nS3 的角色是「現在線上的版本是什麼」，Git 的角色是「開發過程中改了什麼」。兩者分工明確。\n\n### 7c. 跨 session 的行為\n\n```\nSession A（鍛造 + 多次修改）：\n  commit v1 → commit v2 → commit v3 → plugin_publish\n  → S3 收到 v3 的檔案\n  → 本地 Git 保留 v1, v2, v3 歷史\n\nSession B（新 session，從 S3 重新同步）：\n  vault/plugins/搬遷管理/\n    ├── frontend/*.tsx   ← 從 S3 拉回（v3 內容）\n    ├── bundle.js        ← 從 S3 拉回\n    └── .git/            ← 空的（歷史消失）\n```\n\nGit 歷史是 session-local 的暫存開發紀錄，S3 是跨 session 的永久正本。\n\n* * *\n\n* * *\n\n## 八、鍛造環境工具鏈：Container 預裝 CLI\n\n鍛造 plugin 的 CLI Worker Sandbox **出廠自帶所有必要工具**，不需要 `npm install`：\n\n```\nContainer 預裝工具：\n\n  /usr/bin/git                                    → git 2.30.2\n  /usr/local/bin/node                             → node v20.18.1\n  /usr/local/bin/esbuild                          → esbuild 0.28.1\n  /app/config/node_modules/typescript/bin/tsc     → tsc 6.0.3\n\n  鍛造腳本：\n  /app/config/esbuild-plugin-bundle.mjs            → 打包腳本\n  /app/config/plugin-validator.sh                  → 安全檢查\n```\n\n### 各工具在流程中的角色\n\n```\nedit 程式碼           → AI 用 Edit 工具直接寫（不需 CLI）\n    │\n    ▼\ntsc（型別檢查）       → /app/config/.../tsc\n    │                   tsconfig.json: \"noEmit\": true\n    │                   ← 只報型別錯誤，不產出 .js\n    ▼\nesbuild（編譯打包）   → node /app/config/esbuild-plugin-bundle.mjs\n    │                   所有 .tsx → 單一 bundle.js\n    ▼\nvalidator.sh         → bash /app/config/plugin-validator.sh\n    │                   檢查 eval()、icon、MarkdownEditor、CRUD\n    ▼\ngit commit           → git add -A \u0026\u0026 git commit\n    │                   純本地，無 remote\n    ▼\nplugin_publish       → MCP tool（server 端處理）\n                       S3 上傳 + PG schema 註冊 + runtime invalidate\n```\n\n### 為什麼 tsc 和 esbuild 都要？\n\n\u003ctable style=\"min-width: 75px;\"\u003e\u003ccolgroup\u003e\u003ccol style=\"min-width: 25px;\"\u003e\u003ccol style=\"min-width: 25px;\"\u003e\u003ccol style=\"min-width: 25px;\"\u003e\u003c/colgroup\u003e\u003ctbody\u003e\u003ctr\u003e\u003cth colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e​\u003c/p\u003e\u003c/th\u003e\u003cth colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003etsc\u003c/p\u003e\u003c/th\u003e\u003cth colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003eesbuild\u003c/p\u003e\u003c/th\u003e\u003c/tr\u003e\u003ctr\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e做什麼\u003c/p\u003e\u003c/td\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e型別檢查\u003c/p\u003e\u003c/td\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e編譯 + 打包\u003c/p\u003e\u003c/td\u003e\u003c/tr\u003e\u003ctr\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e產出\u003c/p\u003e\u003c/td\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e無（\u003ccode\u003enoEmit: true\u003c/code\u003e）\u003c/p\u003e\u003c/td\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003ebundle.js\u003c/p\u003e\u003c/td\u003e\u003c/tr\u003e\u003ctr\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e速度\u003c/p\u003e\u003c/td\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e慢（完整型別推導）\u003c/p\u003e\u003c/td\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e極快（Go 寫的）\u003c/p\u003e\u003c/td\u003e\u003c/tr\u003e\u003ctr\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e失敗時\u003c/p\u003e\u003c/td\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e型別錯誤 → 停在這裡\u003c/p\u003e\u003c/td\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003eimport 解析失敗\u003c/p\u003e\u003c/td\u003e\u003c/tr\u003e\u003c/tbody\u003e\u003c/table\u003e\n\n兩者分工：tsc 確保程式碼「語意正確」，esbuild 確保「能編譯成 JS」。\n\n* * *\n\n* * *\n\n## 九、Plugin Code 與 Schema 的 Harness：兩條註冊線如何接在一起\n\n這是整個動態插件系統最核心的整合層：**bundle.js 裡的 class 和 PG 裡的 schema JSON，在執行期是怎麼對齊的。**\n\n### 9a. 從 bundle.js 原始碼看 @itemType decorator 的真相\n\n`@itemType` 編譯成 JavaScript 後長這樣（來自搬遷管理 bundle.js）：\n\n```javascript\n// MoveShoppingItem class（原始碼已被 esbuild 壓縮）\nvar N = class extends S.BaseItem {\n  constructor() {\n    super();\n    this.category = \"日用品\";       // ← 預設值變成 constructor 賦值\n    this.budgetAmount = 0;\n    this.priority = \"一般\";\n    this.isPurchased = false;\n    this.dueDate = \"\";\n    this.notes = \"\";\n  }\n};\n\n// ← 這是 @itemType decorator 的編譯結果：\nN = w([(0, D.itemType)(\"MOVE_SHOPPING_ITEM\", \"搬遷購物項目\")], N);\n//      └─────────────┬─────────────┘  └──────┬──────┘\n//           itemType 字串名稱              顯示名稱\n```\n\n這行 decorator 在 **class 定義的瞬間立即執行**，做兩件事：\n\n1.  `N.__itemType__ = 'MOVE_SHOPPING_ITEM'` — 把 itemType 字串寫在 class 靜態屬性上\n    \n2.  `globalItemTypeRegistry['MOVE_SHOPPING_ITEM'] = N` — 把 class 註冊到全域對照表\n    \n\n### 9b. 兩條平行註冊線，靠 itemType 字串橋接\n\n```\n═══════════════════════════════════════════════════════════\n線 A：執行期 class 註冊（bundle.js 載入的瞬間）\n═══════════════════════════════════════════════════════════\n\nbundle.js 載入 → IIFE 執行 → class 定義 → @itemType 觸發\n                                           │\n                          ┌────────────────┘\n                          ▼\n              globalItemTypeRegistry = {\n                'MOVE_SHOPPING_ITEM':       class MoveShoppingItem,\n                'MOVE_SHOPPING_FOLDER':     class MoveShoppingFolder,\n                'MOVE_BUDGET_SECTION_CONFIG': class MoveBudgetSectionConfig,\n                ...\n              }\n                          │\n                          ▼\n              onload() 執行時善用這個 registry：\n\n                registerConfig(MoveBudgetSectionConfig)\n                  → host 讀 class.__itemType__\n                  → 知道它對應 'MOVE_BUDGET_SECTION_CONFIG'\n                  → 用這個字串去 store 建立/查詢 singleton\n\n                registerFolderType(MoveShoppingFolder, {\n                  children: [{ class: MoveShoppingItem, ... }]\n                })\n                  → host 讀 class.__itemType__\n                  → 知道 children 的 itemType 是 'MOVE_SHOPPING_ITEM'\n                  → 渲染列表時用這個字串查 store\n\n\n═══════════════════════════════════════════════════════════\n線 B：PG schema 註冊（plugin_publish 時）\n═══════════════════════════════════════════════════════════\n\nplugin_publish → 從 class 反射出 schema JSON → PG:\n\n  item_type_schemas table:\n  ┌──────────────────────────┬────────────────────────┐\n  │ item_type                │ fields (JSONB)         │\n  ├──────────────────────────┼────────────────────────┤\n  │ MOVE_SHOPPING_ITEM       │ { category: string,    │\n  │                          │   budgetAmount: number │\n  │                          │   ... }                │\n  │ MOVE_SHOPPING_FOLDER     │ {}                     │\n  │ MOVE_BUDGET_SECTION_...  │ { totalBudget: number  │\n  │                          │   ... }                │\n  └──────────────────────────┴────────────────────────┘\n                          │\n                          ▼\n              用途：\n                • Item Server 寫入驗證（白名單比對）\n                • Host 自動生成表單（string→input, number→數字...）\n                • Host 知道這個 itemType 有哪些欄位\n\n\n═══════════════════════════════════════════════════════════\n橋接點：'MOVE_SHOPPING_ITEM' 這個字串\n═══════════════════════════════════════════════════════════\n\n  同一個字串同時存在於兩個完全獨立的系統：\n\n    線 A（執行期）：class.__itemType__ = 'MOVE_SHOPPING_ITEM'\n                   → 讓 host 知道「這個 class 就是這個 itemType」\n\n    線 B（PG）：item_type_schemas['MOVE_SHOPPING_ITEM']\n               → 讓 Item Server 知道「這個 itemType 有哪些合法欄位」\n\n    Store 查詢：useItemsByType('MOVE_SHOPPING_ITEM', folderId)\n               → 從 store 拿資料 → 對照 registry 找到 class → 型別匹配\n\n    Item Server：vault_create_items({ itemType: 'MOVE_SHOPPING_ITEM', ... })\n                → 查 PG schema → 欄位白名單驗證 → 寫入\n```\n\n### 9c. 完整 harness 時序\n\n```\nbundle.js 載入\n    │\n    ├→ ① @itemType decorator 觸發（每個 class 定義時）\n    │     MoveShoppingItem.__itemType__ = 'MOVE_SHOPPING_ITEM'\n    │     itemTypeRegistry['MOVE_SHOPPING_ITEM'] = MoveShoppingItem\n    │\n    ├→ ② registerPlugin 觸發\n    │     pluginRegistry['搬遷管理'] = MovePlugin\n    │\n    └→ ③ Host 呼叫 plugin.onload()\n          │\n          ├→ registerConfig(MoveBudgetSectionConfig)\n          │     → host 讀 class.__itemType__\n          │     → 用字串在 store 建立/查詢 singleton\n          │     → singleton 的欄位結構來自 PG schema（線 B）\n          │\n          ├→ registerFolderType(MoveShoppingFolder, { ... })\n          │     → host 讀 class.__itemType__\n          │     → 知道 folder 的 itemType 是 'MOVE_SHOPPING_FOLDER'\n          │\n          └→ children: [{ class: MoveShoppingItem, ... }]\n                → host 讀 class.__itemType__\n                → 知道子項目的 itemType 是 'MOVE_SHOPPING_ITEM'\n                → 渲染列表：useItemsByType('MOVE_SHOPPING_ITEM', folderId)\n                → 點擊開表單：host 查 PG schema → 自動生成 input\n                → 寫入：Item Server 查 PG schema → 白名單驗證\n```\n\n### 9d. 對比：有 plugin code vs 純卡片模板\n\n\u003ctable style=\"min-width: 75px;\"\u003e\u003ccolgroup\u003e\u003ccol style=\"min-width: 25px;\"\u003e\u003ccol style=\"min-width: 25px;\"\u003e\u003ccol style=\"min-width: 25px;\"\u003e\u003c/colgroup\u003e\u003ctbody\u003e\u003ctr\u003e\u003cth colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e​\u003c/p\u003e\u003c/th\u003e\u003cth colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e插件型（搬遷管理）\u003c/p\u003e\u003c/th\u003e\u003cth colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e卡片模板（台北咖啡廳）\u003c/p\u003e\u003c/th\u003e\u003c/tr\u003e\u003ctr\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e\u003cstrong\u003eclass 存在嗎？\u003c/strong\u003e\u003c/p\u003e\u003c/td\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e✅ bundle.js 裡有 class\u003c/p\u003e\u003c/td\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e❌ 沒有 plugin code\u003c/p\u003e\u003c/td\u003e\u003c/tr\u003e\u003ctr\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e\u003cstrong\u003eitemType 誰定義的？\u003c/strong\u003e\u003c/p\u003e\u003c/td\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e\u003ccode\u003e@itemType\u003c/code\u003e decorator 在 class 上\u003c/p\u003e\u003c/td\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e系統內建 \u003ccode\u003eCARD\u003c/code\u003e + \u003ccode\u003eCARD_FOLDER\u003c/code\u003e\u003c/p\u003e\u003c/td\u003e\u003c/tr\u003e\u003ctr\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e\u003cstrong\u003eschema 誰定義的？\u003c/strong\u003e\u003c/p\u003e\u003c/td\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003eclass 屬性 → publish 反射 → PG\u003c/p\u003e\u003c/td\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e\u003ccode\u003ecardFieldsDef\u003c/code\u003e JSON → 直接寫在 CARD_FOLDER 的 fields 裡\u003c/p\u003e\u003c/td\u003e\u003c/tr\u003e\u003ctr\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e\u003cstrong\u003e表單誰生成的？\u003c/strong\u003e\u003c/p\u003e\u003c/td\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003ehost 讀 PG schema\u003c/p\u003e\u003c/td\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003ehost 讀 cardFieldsDef\u003c/p\u003e\u003c/td\u003e\u003c/tr\u003e\u003ctr\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e\u003cstrong\u003e自訂 view？\u003c/strong\u003e\u003c/p\u003e\u003c/td\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e✅ plugin 的 \u003ccode\u003erenderComponent()\u003c/code\u003e\u003c/p\u003e\u003c/td\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e❌ 用宿主內建的卡片模板引擎\u003c/p\u003e\u003c/td\u003e\u003c/tr\u003e\u003ctr\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e\u003cstrong\u003e共用同一套 bridge？\u003c/strong\u003e\u003c/p\u003e\u003c/td\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e✅ 都是 itemType 字串橋接 store / PG\u003c/p\u003e\u003c/td\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e✅ 只是 cardFieldsDef 替代了 plugin schema\u003c/p\u003e\u003c/td\u003e\u003c/tr\u003e\u003c/tbody\u003e\u003c/table\u003e\n\n### 9e. 為什麼兩條線必須分離？\n\n如果 class 和 schema 是同一件事（例如 schema 直接寫在 class 的靜態屬性上），會有兩個致命問題：\n\n1.  **Item Server 無法驗證** — Item Server 是 Node.js 後端，它不載入 bundle.js（那是瀏覽器的事）。PG 裡的 schema JSON 是它唯一能讀到的欄位定義。\n    \n2.  **不載入 bundle 就不知道結構** — 如果 schema 只存在於 class 上，那任何不跑 bundle.js 的環境（後端驗證、CLI、其他 microservice、AI agent 的 MCP tool）都無法知道 itemType 有哪些合法欄位。\n    \n\n所以**必須分兩條線**：bundle.js 負責執行期的 class 身份（線 A），PG 負責跨服務的 schema 定義（線 B）。`@itemType('MOVE_SHOPPING_ITEM')` 裡的那個字串就是兩條線之間的鉸鏈。\n\n\u003e **一句話：**`@itemType('MOVE_SHOPPING_ITEM')` **的 decorator 是 harness 的鉸鏈 — bundle.js 載入時把 class 註冊到執行期 registry（線 A），plugin_publish 時把 schema 寫入 PG（線 B），之後所有操作（查 store、驗證寫入、生成表單）都靠** `'MOVE_SHOPPING_ITEM'` **這個字串在兩條線之間對齊。**\n\n* * *\n\n* * *\n\n# 附錄：插件撰寫實戰案例\n\n以下三個案例來自 CubeLV 實際運行的插件，展示從 schema 定義到 UI 渲染的完整撰寫模式。\n\n* * *\n\n## 案例一：搬遷管理插件（真實資料夾 + 完整 CRUD + 預算追蹤）\n\n### 檔案結構\n\n```\nplugins/搬遷管理/frontend/\n├── MovePlugin.tsx          ← 插件入口\n├── MoveView.tsx            ← 主畫面\n├── move.css                ← 樣式\n├── schemas/\n│   ├── moveSchema.ts               ← itemType 定義\n│   └── moveBudgetSectionConfigSchema.ts\n└── stores/\n    └── moveBudgetSectionConfigStore.ts\n```\n\n### Schema 定義\n\n```typescript\n@itemType('MOVE_SHOPPING_FOLDER', '搬遷購物清單資料夾',\n  { defaultFolder: '我的購物清單' })  // ← 自動建預設資料夾\nexport class MoveShoppingFolder extends BaseItem {}\n\n@itemType('MOVE_SHOPPING_ITEM', '搬遷購物項目')\nexport class MoveShoppingItem extends BaseItem {\n    category?: string = '日用品';\n    budgetAmount?: number = 0;\n    actualAmount?: number = 0;\n    priority?: string = '一般';\n    isPurchased?: boolean = false;\n    dueDate?: string = '';\n    notes?: string = '';\n}\n\n// Singleton config（全 plugin 一份）\n@itemType('MOVE_BUDGET_SECTION_CONFIG', '搬遷預算設定')\nexport class MoveBudgetSectionConfig extends BaseItem {\n    totalBudget?: number = 0;\n    furnitureBudget?: number = 0;\n    applianceBudget?: number = 0;\n    dailyBudget?: number = 0;\n    renovationBudget?: number = 0;\n    otherBudget?: number = 0;\n}\n```\n\n### Config Store（三行程式碼）\n\n```typescript\nconst accessor = createSingletonAccessor(MoveBudgetSectionConfig);\nexport const useMoveBudgetSectionConfig = accessor.use;\nexport const updateMoveBudgetSectionConfig = accessor.update;\n```\n\nSDK 自動處理：查詢/建立 singleton、React 響應式訂閱、寫入自動 sync。\n\n### Plugin 註冊（onload 五步模板）\n\n```typescript\n@plugin('搬遷管理', { dependencies: ['Sidebar'] })\nexport class MovePlugin extends Plugin {\n    async onload() {\n        this.registerPluginRoot('moveManagement');          // ① root\n        this.registerConfig(MoveBudgetSectionConfig);       // ② config\n        this.registerSection({                              // ③ sidebar\n            id: 'moveManagement', title: '搬遷管理',\n            orderAt: 550, useData: useMoveSidebarData,\n        });\n        this.registerFolderType(MoveShoppingFolder, {       // ④ folder\n            folderIcon: 'package',\n            views: [{ type: 'move-dashboard',\n                      creator: (leaf) =\u003e new MoveDashboardView(leaf) }],\n            children: [{                                     // ⑤ children\n                class: MoveShoppingItem, icon: 'shopping-bag',\n                displayText: (item) =\u003e item.name,\n            }],\n        });\n    }\n}\n```\n\n### View 中的五種 SDK 模式\n\n```typescript\n// 模式 A：資料讀取\nconst folderId = useSelectedItemsStore(s =\u003e s.lastFolderId);\nconst items = useItemsByType\u003cMoveShoppingItem\u003e('MOVE_SHOPPING_ITEM', folderId);\nconst budgetConfig = useMoveBudgetSectionConfig();\n\n// 模式 B：完整新增\nawait useItemStore.getState().upsertItem({\n    id: generateObjectID(),\n    itemType: 'MOVE_SHOPPING_ITEM',\n    name, parents: { [folderId]: Date.now() },\n    category, budgetAmount, priority, isPurchased: false,\n}, { needSync: true });\n\n// 模式 C：部分更新（要送完整欄位！）\nawait useItemStore.getState().upsertItem({\n    id, itemType, name, parents,\n    category, budgetAmount, actualAmount, priority,\n    isPurchased: !isPurchased,  // 只翻轉一個 bool，但整包都要送\n    dueDate, notes,\n}, { needSync: true });\n\n// 模式 D：刪除\nawait useItemStore.getState().removeItems([id], { needSync: true });\n\n// 模式 E：PluginTopbar + FAB\n\u003cPluginTopbar rightButtons={[\n    { icon: 'plus', onClick: () =\u003e setAdding(true) },\n    { icon: 'settings', onClick: () =\u003e setBudgetOpen(true), priority: 'menu' },\n]} /\u003e\n\u003cFloatingActionButton id=\"move-add-fab\" icon=\"plus\" onClick={...} /\u003e\n```\n\n* * *\n\n## 案例二：知識回顧面板（Virtual Folder + 跨 itemType 查詢）\n\n### Virtual Folder vs 真實 Folder\n\n\u003ctable style=\"min-width: 75px;\"\u003e\u003ccolgroup\u003e\u003ccol style=\"min-width: 25px;\"\u003e\u003ccol style=\"min-width: 25px;\"\u003e\u003ccol style=\"min-width: 25px;\"\u003e\u003c/colgroup\u003e\u003ctbody\u003e\u003ctr\u003e\u003cth colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e​\u003c/p\u003e\u003c/th\u003e\u003cth colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e真實 Folder（搬遷管理）\u003c/p\u003e\u003c/th\u003e\u003cth colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003eVirtual Folder（知識回顧）\u003c/p\u003e\u003c/th\u003e\u003c/tr\u003e\u003ctr\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e掛載方式\u003c/p\u003e\u003c/td\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e\u003ccode\u003eregisterFolderType\u003c/code\u003e\u003c/p\u003e\u003c/td\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e\u003ccode\u003eregisterVirtualFolderType\u003c/code\u003e\u003c/p\u003e\u003c/td\u003e\u003c/tr\u003e\u003ctr\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e有真實 item？\u003c/p\u003e\u003c/td\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e是，使用者可建 item\u003c/p\u003e\u003c/td\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e否，只是一個 UI 入口\u003c/p\u003e\u003c/td\u003e\u003c/tr\u003e\u003ctr\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003esidebar 清單\u003c/p\u003e\u003c/td\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e列出所有 folder\u003c/p\u003e\u003c/td\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e只有一個虛擬入口\u003c/p\u003e\u003c/td\u003e\u003c/tr\u003e\u003ctr\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003echildren\u003c/p\u003e\u003c/td\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e有宣告子 itemType\u003c/p\u003e\u003c/td\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e無（不裝子 item）\u003c/p\u003e\u003c/td\u003e\u003c/tr\u003e\u003c/tbody\u003e\u003c/table\u003e\n\n### Schema\n\n```typescript\n// Virtual folder：沒有任何自訂欄位\n@itemType('KNOWLEDGE_REVIEW_VIRTUAL_FOLDER', '知識回顧面板入口')\nexport class KnowledgeReviewVirtualFolder extends BaseItem {}\n\n// Config 包含巢狀結構\ninterface ReviewRecord {\n    lastReviewedAt: number;\n    interval: number;       // 1→3→7→30→90\n    nextReviewAt: number;\n    reviewCount: number;\n}\n\n@itemType('KNOWLEDGE_REVIEW_SECTION_CONFIG', '知識回顧面板設定')\nexport class KnowledgeReviewPanelSectionConfig extends BaseItem {\n    reviewRecords?: Record\u003cstring, ReviewRecord\u003e = {};\n    lastInspirationPushAt?: number = 0;\n    dailyReviewLimit?: number = 10;\n}\n```\n\n### Plugin 註冊\n\n```typescript\n@plugin('知識回顧面板', { dependencies: ['Sidebar'] })\nexport class KnowledgeReviewPanelPlugin extends Plugin {\n    onload() {\n        this.registerPluginRoot('knowledgeReview');\n        this.registerSection({\n            id: 'knowledgeReview', title: '知識回顧',\n            orderAt: 350,\n            useData: () =\u003e ({ items: [] }),  // ← sidebar 不顯示清單\n        });\n        this.registerVirtualFolderType(KnowledgeReviewVirtualFolder, {\n            icon: 'brain',\n            useDisplayName: () =\u003e '知識回顧面板',\n            views: [{ type: 'knowledge-review-dashboard',\n                      creator: (leaf) =\u003e new KnowledgeReviewPanelLeafView(leaf) }],\n        });\n    }\n}\n```\n\n### 跨 itemType 查詢 + MarkdownEditor 唯讀渲染\n\n```typescript\n// 讀取所有 NOTE（跨 itemType）\nconst allNotes = useItemStore(\n    useCallback((s) =\u003e (s.byType?.['NOTE'] ?? []), [])\n);\n\n// 渲染筆記內容：只用 MarkdownEditor editable={false}\n\u003cMarkdownEditor content={note.content} editable={false} /\u003e\n```\n\n* * *\n\n## 案例三：卡片模板系統（台北咖啡廳）\n\n不需要 plugin code，透過 CARD_FOLDER 的定義達成。\n\n```json\n{\n  \"cardFieldsDef\": [\n    { \"name\": \"店名\",     \"type\": \"TEXT\"     },\n    { \"name\": \"照片\",     \"type\": \"IMAGE\"    },\n    { \"name\": \"風格\",     \"type\": \"SELECT\",\n      \"options\": [\"北歐\", \"工業\", \"日系\", \"復古\", \"極簡\"] },\n    { \"name\": \"招牌飲品\", \"type\": \"TEXT\"     },\n    { \"name\": \"評分\",     \"type\": \"RATING\"   },\n    { \"name\": \"地址\",     \"type\": \"LOCATION\" },\n    { \"name\": \"官網\",     \"type\": \"URL\"      }\n  ]\n}\n```\n\n模板語法：`{{欄位名}}` → host 自動取代為 `cardFields[\"欄位名\"]`。CSS 必須用 `var(--card)`、`var(--foreground)` 等主題變數確保深色模式。\n\n* * *\n\n## 三個案例模式總結\n\n\u003ctable style=\"min-width: 75px;\"\u003e\u003ccolgroup\u003e\u003ccol style=\"min-width: 25px;\"\u003e\u003ccol style=\"min-width: 25px;\"\u003e\u003ccol style=\"min-width: 25px;\"\u003e\u003c/colgroup\u003e\u003ctbody\u003e\u003ctr\u003e\u003cth colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e模式\u003c/p\u003e\u003c/th\u003e\u003cth colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e案例\u003c/p\u003e\u003c/th\u003e\u003cth colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e適用場景\u003c/p\u003e\u003c/th\u003e\u003c/tr\u003e\u003ctr\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e真實資料夾 + CRUD\u003c/p\u003e\u003c/td\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e搬遷管理\u003c/p\u003e\u003c/td\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e需要使用者建立/管理資料\u003c/p\u003e\u003c/td\u003e\u003c/tr\u003e\u003ctr\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003eVirtual Folder\u003c/p\u003e\u003c/td\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e知識回顧面板\u003c/p\u003e\u003c/td\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e純儀表板/分析面板\u003c/p\u003e\u003c/td\u003e\u003c/tr\u003e\u003ctr\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e卡片模板\u003c/p\u003e\u003c/td\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e台北咖啡廳\u003c/p\u003e\u003c/td\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e收藏結構化實體，每個 folder 不同視覺\u003c/p\u003e\u003c/td\u003e\u003c/tr\u003e\u003c/tbody\u003e\u003c/table\u003e\n\n### 通用撰寫順序\n\n```\n1. schemas/*.ts    → @itemType class\n2. stores/*.ts     → createSingletonAccessor（如有 config）\n3. Plugin.tsx       → onload() 五步註冊\n4. View.tsx         → renderComponent() React 元件\n5. *.css            → var(--xxx) 主題變數\n```\n\n### 常見踩坑\n\n\u003ctable style=\"min-width: 50px;\"\u003e\u003ccolgroup\u003e\u003ccol style=\"min-width: 25px;\"\u003e\u003ccol style=\"min-width: 25px;\"\u003e\u003c/colgroup\u003e\u003ctbody\u003e\u003ctr\u003e\u003cth colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e坑\u003c/p\u003e\u003c/th\u003e\u003cth colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e正確做法\u003c/p\u003e\u003c/th\u003e\u003c/tr\u003e\u003ctr\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003eicon 傳 JSX/字串\u003c/p\u003e\u003c/td\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e必須用 iconRegistry 合法名稱字串\u003c/p\u003e\u003c/td\u003e\u003c/tr\u003e\u003ctr\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e改 item 只送單一欄位\u003c/p\u003e\u003c/td\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e物件型欄位要整包送（DB 存 JSONB 字串）\u003c/p\u003e\u003c/td\u003e\u003c/tr\u003e\u003ctr\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003emarkdown 自刻渲染\u003c/p\u003e\u003c/td\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e一律 \u003ccode\u003e\u0026lt;MarkdownEditor editable={false}\u0026gt;\u003c/code\u003e\u003c/p\u003e\u003c/td\u003e\u003c/tr\u003e\u003ctr\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e跨 itemType 讀資料\u003c/p\u003e\u003c/td\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e\u003ccode\u003euseItemStore(s =\u0026gt; s.byType?.['NOTE'])\u003c/code\u003e\u003c/p\u003e\u003c/td\u003e\u003c/tr\u003e\u003ctr\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003eCSS 寫死顏色\u003c/p\u003e\u003c/td\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e用 \u003ccode\u003evar(--foreground)\u003c/code\u003e 等主題變數\u003c/p\u003e\u003c/td\u003e\u003c/tr\u003e\u003ctr\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003ecardFieldsDef 無 templateHtml\u003c/p\u003e\u003c/td\u003e\u003ctd colspan=\"1\" rowspan=\"1\"\u003e\u003cp\u003e卡片只顯示純文字 key-value\u003c/p\u003e\u003c/td\u003e\u003c/tr\u003e\u003c/tbody\u003e\u003c/table\u003e\n\n​","createdAt":1782532476316,"deletedAt":null,"id":"ad62d0bf638e28487f955e98","isNew":false,"isPublic":true,"itemType":"NOTE","name":"CubeLV 動態 Plugin 與 Schema 實作機制","parents":{"68d768c0cf1ad24f7b8105ed":0},"preParentID":null,"updatedAt":1785390316822,"updatedBy":{"userId":"6a3ea4880213c1c27aa965","userName":"文鴻 黃"},"version":18,"websiteContent":"[{\"originUrl\":\"https://immian.com/\",\"title\":\"巷子內的生活，國內外美食旅遊隨筆\",\"content\":\"1. 旗艦性能 盡在掌中：Canon EOS R7 飛羽／生態／追焦 全面評測！\\n2. 動態攝影的新定義 Canon EOS R3 實拍評測\\n3. Sony RX100m7、6、5、4、3 差別在哪？寫給選擇困難症的 RX100 系列選購攻略\\n4. 寫下全幅無反的新歷史 Sony FE 400mm F2.8 GM 開箱/實拍/測試\\n5. 20 間夜貓咖啡廳懶人包，晚上 10 點後還在營業的深夜咖啡店整理\\n\\n\\n• 2024 年新版的貓砂比較文章又來哩！隨著養分分兩兄弟的時間又多了一年，今年使用過的貓砂品牌又更多了，所以當然是要記錄一…\\n• Tamron 50-400mm F4.5-6.3 F4.5-6.3 Di III VC VXD（A067） Openin…\\n• Tamron 20-40mm F2.8 Di III VXD（A062） Opening 這次分享的是這顆恆定光圈超廣角…\\n\",\"imageUrl\":\"https://immian.com/wp-content/uploads/2024/09/20240909171549_0_09c475-400x270.jpg\"},{\"originUrl\":\"https://fikafikacafe.com\",\"title\":\"Fika Fika Cafe\",\"content\":\"\\n• {{ 'fb_in_app_browser_popup.desc' | translate }}\\n          {{ 'fb_in_app_browser_popup.copy_link' | translate }}\\n• {{ 'in_app_browser_popup.desc' | translate }}\\n• {{word('consent_desc')}} {{word('read_more')}}\\n\",\"imageUrl\":\"https://img.shoplineapp.com/media/image_clips/5c8b169e6b9cd9003b92aeab/original.png?1552619166\"},{\"originUrl\":\"http://validator.sh\",\"title\":\"Validator.sh - Domain Name for Sale\",\"content\":\"1. Domain for Sale\\n2. © 2026 validator.shAll rights reserved.© 2026 validator.shAll rights reserved.validator.sh\\n3. Inquire About This Domain\\n4. Premium Domains For Sale\\n\\n\\n• Your project starts with the right domain. Make a smart investment by acquiring:\\n• Validator.sh is a concise, highly readable domain name built for tools that check, verify, and enforce rules across code, data, and systems. It fits validation engines, API testing platforms, schema checkers, CI pipelines, and security audi...\",\"imageUrl\":\"https://premiumdoms.io/site/assets/images/domain-logos/validator-sh-20260609_183752.png\"},{\"originUrl\":\"http://plugin-validator.sh\",\"title\":null,\"content\":\"抓取失敗：fetch failed\",\"imageUrl\":null}]"}]}